Go Concurrency: Goroutines and Channels Explained
One of the first things that makes developers fall in love with Go is its concurrency model. Where other languages bolt concurrency on top as an afterthought, Go was designed from day one to make writing concurrent code as natural as writing sequential code.
What is a Goroutine?
A goroutine is a lightweight, independently executing function. Think of it as a thread that costs almost nothing – you can run thousands (or millions) of them without breaking a sweat.
package main
import (
"fmt"
"time"
)
func fetchTokenPrice(token string) {
// imagine a real HTTP call here
time.Sleep(100 * time.Millisecond)
fmt.Printf("Price for %s fetched\n", token)
}
func main() {
tokens := []string{"HBAR", "ETH", "BTC", "SOL"}
for _, token := range tokens {
go fetchTokenPrice(token) // starts each fetch concurrently
}
// wait a bit so goroutines can finish
time.Sleep(200 * time.Millisecond)
}
Starting a goroutine is simply go functionCall(). That is the entire syntax.
Channels: Safe Communication Between Goroutines
Goroutines communicate through channels. A channel is a typed pipe you can send values into and receive values from.
ch := make(chan string) // unbuffered channel
// sender goroutine
go func() {
ch <- "hello from goroutine"
}()
// receiver (blocks until a value arrives)
msg := <-ch
fmt.Println(msg) // hello from goroutine
The blocking behaviour is the key insight: channels synchronise goroutines without mutexes.
A Real-World Pattern: Fan-Out / Fan-In
When I (MrBns) build blockchain data pipelines I use the fan-out / fan-in pattern constantly. Fetch N things concurrently, collect them all.
package main
import (
"fmt"
"sync"
)
type TokenInfo struct {
Symbol string
Price float64
}
func fetchToken(symbol string, wg *sync.WaitGroup, results chan<- TokenInfo) {
defer wg.Done()
// simulate work
results <- TokenInfo{Symbol: symbol, Price: 1.23}
}
func main() {
symbols := []string{"HBAR", "ETH", "BTC"}
results := make(chan TokenInfo, len(symbols))
var wg sync.WaitGroup
for _, s := range symbols {
wg.Add(1)
go fetchToken(s, &wg, results)
}
// close the channel once all goroutines finish
go func() {
wg.Wait()
close(results)
}()
// drain the channel
for info := range results {
fmt.Printf("%s: $%.2f\n", info.Symbol, info.Price)
}
}
Select: Wait on Multiple Channels
select is like a switch for channels. Use it to implement timeouts and non-blocking receives:
import "time"
func withTimeout(ch chan string) {
select {
case msg := <-ch:
fmt.Println("received:", msg)
case <-time.After(2 * time.Second):
fmt.Println("timed out – no data received")
}
}
This pattern is essential when you are waiting on external APIs (like Hedera mirror nodes) and cannot afford to block indefinitely.
Context: The Idiomatic Cancellation Signal
For long-running operations, use context.Context to propagate cancellation:
import (
"context"
"fmt"
"time"
)
func streamEvents(ctx context.Context) {
for {
select {
case <-ctx.Done():
fmt.Println("stopped:", ctx.Err())
return
default:
fmt.Println("processing event...")
time.Sleep(500 * time.Millisecond)
}
}
}
func main() {
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
streamEvents(ctx)
}
Common Mistakes to Avoid
- Goroutine leaks – always ensure every goroutine has a way to exit.
- Closing a channel from the receiver – only the sender should close a channel.
- Sharing memory without synchronisation – use channels or
sync.Mutex, never both sloppily. - Ignoring the race detector – always run
go test -race ./...before shipping.
Summary
Go’s concurrency primitives – goroutines, channels, select, and context – give you an incredibly expressive toolkit for building concurrent systems. As a Go developer and Blockchain Full Stack Developer, I use these patterns every day in event-driven blockchain services, real-time price feeds, and high-throughput API gateways.
Next up: Go error handling patterns that keep your blockchain services robust.