Every goroutine needs an owner, an end, and an error path. Prefer context for cancellation over ad hoc signals.
When to use
- Parallelize independent I/O (fan-out) with a concurrency limit.
- Pipelines with clear stages and backpressure.
- Background work tied to the lifecycle of the process or the request.
- Do not use a goroutine to “let the handler return faster” without lifecycle ownership.
Non-negotiable rules
- Derive contexts:
context.WithCancel/WithTimeout/WithDeadlinefrom the parent. - Cancel and
Wait(or equivalent) before returning in functions that spawn work. - Never ignore the return value of errors from child goroutines.
- Channels: define who closes; receivers do not close what they do not own.
- Shared data:
mutex, channels, or concurrent structures — choose one strategy, not both blindly. - No
go func()in a loop capturing an iteration variable without explicit binding (v := v).
Context and errgroup
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, id := range ids {
id := id
g.Go(func() error {
return fetchOne(ctx, id)
})
}
if err := g.Wait(); err != nil {
return err
}
golang.org/x/sync/errgroupfor fan-out with cancellation on the first error.SetLimitto avoid a goroutine explosion.- Propagate the same
ctxall the way to I/O (DB, HTTP, disk).
Worker pool
jobs := make(chan Job)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for job := range jobs {
process(ctx, job)
}
}()
}
for _, job := range batch {
select {
case <-ctx.Done():
close(jobs)
wg.Wait()
return ctx.Err()
case jobs <- job:
}
}
close(jobs)
wg.Wait()
- Buffer only with a reason (decouple rhythms); unbounded growth is a bug.
- Shutdown: stop sending → close the jobs channel →
Waitworkers.
Channels and select
- Prefer communication via channels for ownership handoff.
selectwithctx.Done()on sends/receives that can block.- Avoid a notification channel + confusing boolean; use
closeor context. - Fan-in: one goroutine per source or
errgroup, explicit merge.
sync
| Primitive | Use |
|---|---|
Mutex / RWMutex |
Short shared state |
WaitGroup |
Wait for N workers |
Once |
Idempotent init |
Map / atomics |
Specific cases; measure before optimizing |
- Hold the lock for as little time as possible; do not do I/O under a mutex.
- Document invariants of the protected state.
Leaks and races
- Symptoms: growing goroutines, flaky tests, shutdown that does not finish.
- Tools:
go test -race, pprof goroutine, timeouts in tests. - Each
gomust have an exit condition tied to context, channel close, orWaitGroup.
Anti-patterns
- Goroutine without cancellation or join
time.Sleepas coordination- Fire-and-forget in an HTTP request without tracking
- Closing the channel on the receiver side
- Mutex + channel for the same coordination without need
- Ignoring
ctx.Err()afterselect - Pool without a limit under load
Completion criteria
- Every goroutine has a clear lifecycle (start/stop)
- Context propagated and respected
- Errors from child goroutines aggregated or handled
- No data races (
-raceclean in the areas touched) - Backpressure or concurrency limit where there is fan-out
- Shutdown does not leave orphaned work