Go Concurrency Patterns
Worker Pool
What: A fixed set of N goroutines consuming from a shared jobs channel — bounds concurrency and memory regardless of input size.
Syntax/Signature: jobs chan In + results chan Out + sync.WaitGroup (or errgroup with SetLimit).
func pool(ctx context.Context, jobs <-chan Job, workers int) <-chan Result {
out := make(chan Result)
var wg sync.WaitGroup
for i := 0; i < workers; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for j := range jobs { // exits when jobs is closed
select {
case out <- process(j):
case <-ctx.Done():
return
}
}
}()
}
go func() { wg.Wait(); close(out) }() // close after ALL workers exit
return out
}
How it works under the hood: All workers range over one channel; hchan's FIFO recvq distributes jobs to whichever worker parks first — free load balancing. Closing jobs terminates every worker's range loop; the closer-goroutine pattern (wg.Wait() then close(out)) is what makes the output channel safely closable with multiple senders.
Interview angle: The #1 Go coding exercise: "N URLs, fetch with max K concurrent, collect results and errors, stop on first error / on timeout." Practice writing this in <10 minutes with context + errgroup and with raw channels.
Gotchas / common wrong answers:
- Closing
outfrom a worker (multiple senders → panic) instead of the dedicated closer goroutine. - Worker sends result while consumer already quit → all workers block forever; the
selectonctx.Done()around the send is mandatory, not decorative. - Sizing: CPU-bound → ~
runtime.GOMAXPROCS(0)workers; I/O-bound → sized by downstream limits (DB pool, rate limits), not by CPU — see [[Python Concurrency]] for the same sizing question in a different language, useful as a cross-check.
Trade-offs / when NOT to use: For a small fixed set of tasks, goroutine-per-task with a semaphore/errgroup is simpler than a standing pool. Standing pools shine for long-lived services with a steady job stream.
Fan-Out / Fan-In (Pipelines)
What: Fan-out: multiple goroutines read one channel to parallelize a stage. Fan-in: merge multiple channels into one. Composed, they form pipelines with per-stage parallelism.
Syntax/Signature: stage func(ctx context.Context, in <-chan T) <-chan U; each stage owns and closes its output.
func fanIn[T any](ctx context.Context, ins ...<-chan T) <-chan T {
out := make(chan T)
var wg sync.WaitGroup
for _, in := range ins {
wg.Add(1)
go func() {
defer wg.Done()
for v := range in {
select {
case out <- v:
case <-ctx.Done():
return
}
}
}()
}
go func() { wg.Wait(); close(out) }()
return out
}
How it works under the hood: Each stage's unbuffered output applies natural backpressure — a slow downstream stalls upstream instead of queueing unboundedly (see [[Message Queues & Kafka]] for the same idea at a system-design scale). Cancellation must flow upstream (via ctx in every send-select), because a producer blocked on send to an abandoned channel is the canonical pipeline leak (see [[Go Goroutine Leaks]]).
Interview angle: "Design a pipeline: read file → parse → enrich (parallel) → write. How do you shut it down cleanly on error?" Expected: ctx threaded through every stage, every send wrapped in select, each stage closes its own output, ordering handled explicitly if required.
Gotchas / common wrong answers:
- Fan-out destroys ordering — if order matters, tag items with an index and reorder at the sink (or use a windowed reorder buffer).
- Forgetting the ctx-select on sends: cancelling the consumer strands every upstream goroutine.
- Closing the fan-in output before all inputs finish (missing the WaitGroup+closer pattern).
Trade-offs / when NOT to use: Pipelines add per-item channel overhead (~sub-microsecond, but real at millions/sec — batch items to amortize). For simple parallel-map over a slice, indexed writes + WaitGroup beat a pipeline.
errgroup (golang.org/x/sync/errgroup)
What: WaitGroup + first-error capture + optional context cancellation + concurrency limit. The default tool for "run N tasks, fail fast."
Syntax/Signature: g, ctx := errgroup.WithContext(ctx); g.SetLimit(n); g.Go(func() error {...}); err := g.Wait().
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8) // at most 8 in flight; further g.Go calls block
results := make([]Item, len(ids))
for i, id := range ids {
g.Go(func() error {
item, err := fetch(ctx, id) // ctx is cancelled on first error
if err != nil { return fmt.Errorf("fetch %s: %w", id, err) }
results[i] = item
return nil
})
}
if err := g.Wait(); err != nil { return err }
How it works under the hood: A WaitGroup + sync.Once around the first non-nil error (later errors are dropped) + the cancel func from WithContext, invoked on first error and again in Wait. SetLimit is a buffered-channel semaphore inside; Go blocks acquiring a token — so don't call g.Go while holding a lock the running tasks need.
Interview angle: "Fetch from 3 services, return as soon as any fails, cancel the rest" — this is the errgroup one-liner; writing it by hand with channels is the follow-up ("now without the library").
Gotchas / common wrong answers:
- Ignoring that the group ctx is cancelled after
Waitreturns — don't reuse it for follow-up work. - Expecting all errors — only the first survives; need all → collect into an error slice with a mutex, or per-task result structs.
- Tasks that don't actually honor ctx — cancellation is advisory; a task ignoring ctx still runs to completion and
Waitstill waits for it.
Trade-offs / when NOT to use: It's x/sync, not stdlib — some interviewers want the channel version. No per-task results (use indexed slices as above). For independent errors where you must aggregate, errors.Join (see [[Go Error Handling]]) + mutex.
Interview Drills
- Design: process a stream of events with 8 parallel enrichers, preserve original order at the output, stop everything cleanly on first error or ctx cancel. Sketch the architecture and name the failure modes you're defending against.
Architecture: (1) sequencer stage tags each event with an index and sends
{idx, ev}into a jobs channel; (2) 8 workers undererrgroup.WithContext— each receive and each send wrapped inselect { case ...: case <-ctx.Done(): return ctx.Err() }; (3) reorder stage holds amap[int]Result+nextcounter, emitting contiguous runs (bound the map size to cap memory if one slow item stalls the window — that's the backpressure trade-off to call out); (4) each stage closes only its own output; fan-in of worker outputs closes via WaitGroup-then-close goroutine. First worker error cancels the group ctx → upstream sends unblock via theirctx.Done()cases → all goroutines exit →g.Wait()returns the first error. Failure modes defended: producer stranded on send after consumer death (ctx-select on sends), fan-in closing early (wg+closer), reorder deadlock when the erroring item's index never arrives (on error, drain/abandon via ctx rather than waiting fornext), and unbounded reorder buffer growth (window cap). Verify with-raceand goleak in tests. - Why must the "close the output channel" step happen in a dedicated goroutine (
wg.Wait(); close(out)) instead of any single worker closing it directly? — Multiple workers share the same output channel; if any one of them closed it directly, a second worker's subsequent send would panic ("send on closed channel"). A separate goroutine that waits for all workers to finish before closing guarantees no worker is still sending when the close happens. - When would you write a worker pool by hand instead of using
errgroup? — When you need per-task results collected alongside errors (errgroup gives you only the first error, no return values), or when the interviewer specifically asks for the channel-based version to test whether you understand the underlying mechanics errgroup is built on.
Cross-links
[[Go Goroutines & Scheduling]] · [[Go Channels]] · [[Go Context & Cancellation]] · [[Go Goroutine Leaks]] · [[Go Error Handling]] · [[Message Queues & Kafka]]