Go Concurrency Patterns in Practice
2026-05-15
I’ve been writing Go for a few years now, and concurrency remains one of those topics where textbook knowledge doesn’t quite prepare you for production. Here are a few patterns I’ve found genuinely useful.
The Worker Pool
The classic. Useful when you need bounded parallelism:
func workerPool(jobs <-chan Job, results chan<- Result, count int) {
var wg sync.WaitGroup
for i := 0; i < count; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
results <- process(job)
}
}()
}
wg.Wait()
close(results)
}Context Propagation
Probably the single most important practice I’ve adopted: every long-running goroutine should accept a context. It makes cancellation and timeout handling explicit rather than an afterthought.
func fetchWithTimeout(ctx context.Context, url string) ([]byte, error) {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
if err != nil {
return nil, err
}
// ...
}The Select Loop
When you need to coordinate multiple channels, select is your friend. A common pattern:
for {
select {
case msg := <-messages:
handle(msg)
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
flush()
}
}Things I Wish I Knew Earlier
- Channel ownership — the goroutine that writes should also close the channel
- Buffered channels aren’t a fix for deadlocks — they just push the problem further out
sync.WaitGroupcopies by value — pass it by pointer, always- The race detector is free —
go test -raceshould be part of your CI
Concurrency in Go is easy to write but hard to get right. The compiler won’t save you from logical races. That’s on us.