Why Concurrency Is Hard
Concurrency bugs are invisible until they cause real damage. A race condition in a payment service might surface once every hundred thousand requests — impossible to reproduce locally, catastrophic when it does.
Go's concurrency model is genuinely good, but "good" doesn't mean "automatic." You still need to think carefully about ownership, lifetimes, and cancellation.
Goroutines Are Cheap — Until They're Not
The Go runtime can schedule hundreds of thousands of goroutines. This creates a subtle trap: it's tempting to spawn goroutines freely without tracking them.
// This leaks goroutines if the channel is never drained
func processBatch(items []Item) {
ch := make(chan Result)
for _, item := range items {
go func(i Item) {
ch <- process(i) // blocks forever if nobody reads
}(item)
}
}
Always ensure goroutines can exit. If a goroutine sends to a channel, someone must read it, or you must provide a way to abandon the send.
Contexts for Cancellation
Every goroutine that does I/O should respect context.Context. This is not optional in production code.
func fetchUser(ctx context.Context, id string) (*User, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
fmt.Sprintf("/users/%s", id), nil)
if err != nil {
return nil, err
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("fetch user: %w", err)
}
defer resp.Body.Close()
var user User
if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
return nil, fmt.Errorf("decode user: %w", err)
}
return &user, nil
}
When the caller cancels the context (timeout, request abort), the HTTP client cancels the in-flight request. Without this, your goroutines keep running after the caller has moved on.
WaitGroups for Fan-Out
When you fan out to N goroutines and need to wait for all of them, sync.WaitGroup is the tool.
func enrichOrders(ctx context.Context, orders []Order) ([]EnrichedOrder, error) {
results := make([]EnrichedOrder, len(orders))
errs := make([]error, len(orders))
var wg sync.WaitGroup
for i, order := range orders {
wg.Add(1)
go func(idx int, o Order) {
defer wg.Done()
enriched, err := enrich(ctx, o)
results[idx] = enriched
errs[idx] = err
}(i, order)
}
wg.Wait()
// Collect first non-nil error
for _, err := range errs {
if err != nil {
return nil, err
}
}
return results, nil
}
Note the index assignment results[idx] — writing to separate indices in a slice from multiple goroutines is safe because they don't overlap. This is not the same as concurrent writes to a map.
errgroup for Cleaner Fan-Out
golang.org/x/sync/errgroup combines WaitGroup with first-error propagation:
func enrichOrdersErrGroup(ctx context.Context, orders []Order) ([]EnrichedOrder, error) {
g, ctx := errgroup.WithContext(ctx)
results := make([]EnrichedOrder, len(orders))
for i, order := range orders {
i, order := i, order // capture loop vars
g.Go(func() error {
enriched, err := enrich(ctx, order)
if err != nil {
return fmt.Errorf("order %s: %w", order.ID, err)
}
results[i] = enriched
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return results, nil
}
When errgroup.WithContext creates a context, it cancels that context when any goroutine returns an error. All other goroutines should check ctx.Done() and exit early.
Mutexes vs Channels
Go's famous advice: "communicate by sharing memory, don't share memory by communicating." But in practice, a mutex is often simpler.
Use a channel when:
- You're passing ownership of data between goroutines
- You want to signal events or completion
- You're implementing a worker pool or pipeline
Use a mutex when:
- Multiple goroutines need read/write access to shared state
- The protected section is short
- You'd otherwise build a channel just to serialize access
type Cache struct {
mu sync.RWMutex
items map[string]Item
}
func (c *Cache) Get(key string) (Item, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
item, ok := c.items[key]
return item, ok
}
func (c *Cache) Set(key string, item Item) {
c.mu.Lock()
defer c.mu.Unlock()
c.items[key] = item
}
The sync.RWMutex allows concurrent reads and exclusive writes — a good default for a read-heavy cache.
The One Rule
If you're not sure which primitive to use, ask: who owns this data, and when does ownership transfer? Channels make ownership transfer explicit. Mutexes keep shared ownership but serialize access. Clarity about ownership prevents most concurrency bugs before they're written.