Concurrency Patterns
Knowing the primitives — goroutine, channel, sync.Mutex, context — is only half the job. A Go interview rarely asks "what is a channel"; far more often it asks you to dispatch work across goroutines, cap their number, collect the results, and stop cleanly on an error. That is what concurrency patterns are — settled ways to assemble the primitives so the scheme does not leak goroutines, does not panic on close, and does not crash the process under load.
Each pattern here solves a concrete pain. An unbounded fan-out — one goroutine per element of a huge slice — looks simple and easily blows up memory and descriptors; the worker pool and the semaphore put a cap on it. errgroup lifts the manual first-error and cancellation chores off WaitGroup. The HTTP connection pool lives inside a single http.Client, and a client per request silently kills reuse. And "channel vs mutex" is no fashion question: for state that stays put a mutex beats a channel on both speed and simplicity. This topic dissects these techniques layer by layer — from fan-out to the loop-variable-capture trap.
Topic Map
- Fan-out — one goroutine per task, results collected over a buffered channel, closed safely by a separate goroutine after a
WaitGroup. - Worker pool — a fixed N workers pull jobs from a shared channel; for CPU-bound work size to
runtime.NumCPU(). - Bounded concurrency — a buffered channel as a counting semaphore caps the number of simultaneous operations.
- errgroup — like a
WaitGroupbut captures the first error, cancels its context for fail-fast, and limits concurrency viaSetLimit. - Channels vs mutex —
sync.Mutexto update state in place, achannelto transfer ownership; both give happens-before, so the choice is cost. - HTTP connection pooling — reuse one
http.Clientand tune itsTransportso a burst of goroutines does not exhaust connections. - Loop variable capture — why goroutines in a loop shared one variable before Go 1.22, and get a per-iteration copy from 1.22.
Common Mistakes and Traps
| Mistake | Consequence |
|---|---|
| Launching one goroutine per element of a huge slice | Memory blowup and exhaustion of file descriptors and connections |
| Closing the result channel from a sender worker | A double close panics; a close before the others finish is "send on closed channel" |
| Closing the channel before all fan-out senders finish | A "send on closed channel" panic; a separate goroutine must close it after WaitGroup |
| Sending on an unbuffered channel after the consumer left | Senders hang forever — a goroutine leak; need a buffer or a select on ctx.Done() |
| Not closing the worker-pool job channel | Workers hang forever in range, wg.Wait() never returns — a deadlock |
Taking runtime.NumCPU() * 10 workers for CPU-bound work | Extra context switches instead of speedup; size to the core count |
Believing errgroup.Wait returns every error | Only the first comes back, the rest are silently dropped |
Thinking g.SetLimit collects every failure | SetLimit only caps concurrency; the error is still the first one |
Creating an http.Client per request | Each gets its own empty pool — pooling is killed, connections leak |
Believing MaxIdleConnsPerHost defaults high | The default is 2; under load warm connections are constantly recreated |
Closing resp.Body but not draining it | The connection may not return to the pool — reuse breaks |
| Capturing the loop variable in a goroutine on Go ≤ 1.21 | Every goroutine reads the same variable — the classic 3 3 3 output |
| Routing a hot counter through a channel "for idiom's sake" | An extra layer; for in-place state a mutex or atomic is faster and simpler |
Interview Relevance
Concurrency patterns are the favorite format for practical Go tasks: "dispatch the work across goroutines and collect the result", "cap the number of simultaneous requests", "stop everyone on the first error". The interviewer checks not your knowledge of the primitives but your ability to assemble them into a scheme that neither leaks nor panics.
What interviewers check:
- How to close the result channel safely on fan-out — a separate closer goroutine after
WaitGroup, not the sender itself. - How a worker pool differs from fan-out and how to pick the worker count — by core count for CPU-bound, more for IO-bound.
- How to cap simultaneous operations without a pool — a buffered channel as a counting semaphore.
- What
errgroupadds overWaitGroup— the first error, context cancellation for fail-fast, a limit viaSetLimit. - When to reach for a
channeland when for async.Mutex— transferring ownership vs guarding state in place. - Why one
http.Clientper service is mandatory and what to tune in theTransportfor a goroutine burst. - What a loop-with-goroutines prints on an old and a new Go version — the loop-variable-capture trap before 1.22.
A typical wrong answer: "just launch a goroutine per element — Go is cheap after all." That opens a discussion of how an unbounded fan-out blows up memory and descriptors, and how the number of simultaneous operations is capped with a worker pool or a semaphore on a buffered channel.