Concurrency Patterns
Fan-out, worker pools, bounded concurrency, errgroup, HTTP connection pooling, and channel-vs-mutex trade-offs in Go.
14 questions
JuniorTheoryVery commonWhat is a worker pool, and how do you build one in Go?
What is a worker pool, and how do you build one in Go?
A worker pool is a fixed set of goroutines that all receive from one jobs channel and run tasks concurrently. You launch N goroutines, each ranging over the channel; a producer sends jobs and then closes it, which makes every worker's range loop end.
Common mistakes
- ✗Spawning a new goroutine per job instead of reusing a fixed set — that is not a pool
- ✗Forgetting to close the jobs channel, so workers block forever on receive and leak
- ✗Assuming workers stop on their own without the channel being closed or a context cancel
Follow-up questions
- →How do you collect results from the workers without a data race?
- →How would you stop a worker pool early, before all jobs are processed?
MiddleTheoryCommonHow do you cap the number of concurrent operations using a buffered channel?
How do you cap the number of concurrent operations using a buffered channel?
Use a buffered channel of capacity N as a counting semaphore: send a token (sem <- struct{}{}) before starting work and receive one when done. The send blocks once N tokens are in flight, so no more than N goroutines run at the same time.
Common mistakes
- ✗Confusing a semaphore (limits concurrency) with a worker pool (fixed workers draining a queue)
- ✗Using an unbuffered channel, which serialises work to one at a time instead of
N - ✗Forgetting to release the token on the error path, slowly draining all the slots
Follow-up questions
- →How does a semaphore differ from a worker pool for bounding concurrency?
- →How do you make the token release safe when the operation can panic?
MiddleTheoryCommonWhen should you guard state with a Mutex versus pass it over a channel?
When should you guard state with a Mutex versus pass it over a channel?
Use a sync.Mutex to guard mutable state updated in place — a counter, cache, or map — cheap for short critical sections. Use a channel to transfer ownership and coordinate goroutines. Both give happens-before; the choice is cost, not safety.
Common mistakes
- ✗Treating 'share memory by communicating' as an absolute ban on the mutex
- ✗Thinking a channel does not establish happens-before and so cannot publish data safely
- ✗Assuming a channel is always cheaper than a mutex for a simple shared counter
Follow-up questions
- →Why can a channel be overkill for a simple shared counter?
- →How do both a mutex unlock and a channel send create a happens-before edge?
MiddleTheoryCommonWhat does the concurrency helper errgroup add over a WaitGroup?
What does the concurrency helper errgroup add over a WaitGroup?
An errgroup.Group is like a WaitGroup, but Go captures the first non-nil error and Wait returns it. WithContext cancels its context on that error so siblings stop early, and SetLimit bounds concurrency. It returns only the first error.
Common mistakes
- ✗Expecting
Waitto return every goroutine's error instead of just the first non-nil one - ✗Forgetting that
WithContextis what gives cancellation on the first error — a plain group cancels nothing - ✗Thinking
errgroupbounds concurrency by default, whenSetLimitis opt-in and unlimited otherwise
Follow-up questions
- →How does
SetLimitcombine concurrency bounding with the error-and-cancel behaviour? - →How would you collect every error instead of just the first — for example with
errors.Join?
MiddleCodeCommonReturn the fastest of N searchers run concurrently
Return the fastest of N searchers run concurrently
Launch one goroutine per name, each timing testSearcher(ctx, name) and sending a {name, dur, err} result to a buffered channel that a closer goroutine closes after a WaitGroup. Range the channel, keep the result with the smallest dur among successes, and propagate ctx so cancellation stops in-flight probes. If all fail, return the last error.
Common mistakes
- ✗Returning the first searcher to respond instead of the one with the smallest reported duration
- ✗Updating shared
name/respTimefrom goroutines without synchronization — a data race - ✗Closing the results channel from a sender, or never closing it, so the
rangeblocks forever
Follow-up questions
- →How would you return early once a searcher beats a target latency, cancelling the rest?
- →Why must the channel be buffered (or sends guarded) to avoid leaking goroutines on early return?
MiddleTheoryCommonHow do you bound outbound connections when a Go service makes many HTTP calls to one host?
How do you bound outbound connections when a Go service makes many HTTP calls to one host?
Reuse one http.Client (never one per request) and tune its Transport: MaxConnsPerHost caps connections to a host, MaxIdleConnsPerHost keeps warm ones for reuse, and IdleConnTimeout reaps idle ones. Untuned, a burst of goroutines opens unbounded sockets and exhausts ephemeral ports. Pair it with a semaphore to also bound in-flight requests.
Common mistakes
- ✗Creating a new
http.Clientper request, defeating connection reuse and leaking sockets - ✗Assuming connection count is automatically bounded by goroutine count or
GOMAXPROCS - ✗Leaving
MaxConnsPerHostat its default of unlimited under a burst of concurrent calls
Follow-up questions
- →Why must you read and close
resp.Bodyfor a connection to return to the pool? - →How does
MaxIdleConnsPerHostinteract withMaxConnsPerHostunder sustained load?
MiddleDebuggingOccasionalConcurrent fetchers leak HTTP connections. Where is the resp.Body.Close() bug?
Concurrent fetchers leak HTTP connections. Where is the resp.Body.Close() bug?
The defer resp.Body.Close() runs after io.ReadAll, and the earlier if err != nil returns leave the body unclosed on the error path. Move the defer resp.Body.Close() to immediately after the Get error check, so every successful Get closes the body and frees the connection.
Common mistakes
- ✗Putting the
deferClose after the body-read error check, so a read error returns with the body still open - ✗Assuming a request's body never needs closing when the body is small or already read
- ✗Believing
deferorder is cosmetic — it fixes the leak only when registered before any early return
Follow-up questions
- →Why does an unclosed
resp.Bodyprevent the underlying TCP connection from being reused? - →When can
resp.Bodybenil, and doesClosestill need a guard there?
MiddleCodeOccasionalWhat does this goroutine loop print on Go 1.22+ vs Go ≤1.21?
What does this goroutine loop print on Go 1.22+ vs Go ≤1.21?
On Go 1.22+ each iteration gets its own v, so the goroutines print 1 2 3 in some order. On Go ≤1.21 they all shared one v mutated by the loop and typically printed 3 3 3. The portable fix is v := v inside the loop or passing v as an argument.
Common mistakes
- ✗Believing
go func(){...}()copies the loop variable at call time — it captures the variable, not its value - ✗Assuming the
3 3 3behavior is a data race rather than a deterministic capture of one shared variable - ✗Thinking Go 1.22 scoping also reorders the goroutines into
1 2 3— the order is still unspecified
Follow-up questions
- →How does the Go 1.22 change scope the loop variable, and does it cost an allocation per iteration?
- →Why does passing
vas a function argument fix the bug on every Go version?
MiddleCodeOccasionalProcess a []int through a CPU-bound worker pool
Process a []int through a CPU-bound worker pool
For CPU-bound work, size the pool to runtime.NumCPU() — extra goroutines beyond the cores only add scheduling cost. Send indices over a jobs channel; each worker writes out[i] = heavyCompute(nums[i]) to its own index, so no lock is needed and order is preserved. A sync.WaitGroup blocks until every worker finishes.
Common mistakes
- ✗Spawning one goroutine per element for CPU-bound work, so thousands of goroutines thrash the scheduler instead of saturating the cores
- ✗Collecting results by
appendinto a shared slice under a mutex, which serializes the workers and scrambles output order - ✗Sizing the pool to the element count or a hard-coded constant instead of
runtime.NumCPU()
Follow-up questions
- →Why does adding more workers than CPU cores fail to speed up CPU-bound work?
- →How would the concurrency helper
errgroupwithSetLimitreplace this manual pool?
SeniorCodeOccasionalTwo goroutines print 1..N in order, alternating odd and even
Two goroutines print 1..N in order, alternating odd and even
Use two signal channels as a ping-pong: the odd goroutine waits on <-odd, prints, then signals even <- struct{}{}; the even goroutine mirrors it. main kicks off with odd <- struct{}{} and waits on a done channel the even goroutine closes after N. The hand-off enforces strict ordering with no shared state.
Common mistakes
- ✗Reaching for a shared counter or mutex instead of channel hand-off
- ✗Assuming a single FIFO channel makes two consumers alternate deterministically
- ✗Thinking a
WaitGrouporders goroutine execution
Follow-up questions
- →Why does
struct{}{}make a good zero-size signal value on the channels? - →How does closing
donefrom the even goroutine cleanly stopmain?
SeniorCodeOccasionalFan out per-supplier RPCs with a context deadline and aggregate
Fan out per-supplier RPCs with a context deadline and aggregate
Launch one goroutine per supplier, each calling SearchRPC(ctx, …) and sending results to a buffered channel; a sync.WaitGroup plus a closer goroutine closes it after all finish. Aggregate by ranging the channel, and propagate ctx so a timeout cancels every in-flight RPC instead of waiting.
Common mistakes
- ✗Creating the context with
context.WithTimeoutbut never calling itscancel— leaks the timer until the deadline - ✗Closing the results channel from a sender goroutine instead of after
wg.Wait, causing a send on a closed channel - ✗Not passing
ctxintoSearchRPC, so the deadline never actually cancels a slow supplier
Follow-up questions
- →Why must the channel
closehappen afterwg.Wait, and why in a separate goroutine? - →How would
errgroupwithWithContextshorten this fan-out-and-aggregate code?
SeniorCodeOccasionalLaunch a service with graceful shutdown and aggregated cleanup errors
Launch a service with graceful shutdown and aggregated cleanup errors
Use the func Main() error pattern: main just does if err := Main(); err != nil { log.Fatal(err) }. Inside Main, after each successful init register a defer that runs cleanup and folds any cleanup error into the named return with errors.Join. Defers run LIFO, so Service.Stop runs before SendQueue.Close. This works because log.Fatal calls os.Exit, which skips defers, so they must live in Main, not main.
Common mistakes
- ✗Putting defers in
main, wherelog.Fatal'sos.Exitskips them - ✗Swallowing cleanup errors instead of joining them into the return
- ✗Running cleanup in init order rather than LIFO reverse order
Follow-up questions
- →Why does
log.Fatalinmainskip deferred cleanup, but returning an error does not? - →How does
errors.Joinlet one shutdown surface both a runtime error and a close error?
SeniorTheoryRareHow does a fan-out goroutine leak on a channel send, and how do you prevent it?
How does a fan-out goroutine leak on a channel send, and how do you prevent it?
A goroutine that does ch <- result after the receiver has already returned (e.g. on an early error or a hit deadline) blocks forever on the send, leaking. Fix it with a select over the send and ctx.Done(), or a buffered channel sized to the senders, so the send can never block past cancellation.
Common mistakes
- ✗Returning early from the receiver while senders are still blocked on
ch <-, stranding them forever - ✗Sizing the buffer smaller than the number of senders, so the surplus senders still block
- ✗Closing a channel from the receiver side to unblock senders — a closed channel still panics on send
Follow-up questions
- →Why does closing a channel not safely unblock a sender, unlike a blocked receiver?
- →How does
go test's goroutine leak detection surface this kind of stranded sender?
SeniorTheoryRareHow do you cancel in-flight work that is gated by a semaphore channel?
How do you cancel in-flight work that is gated by a semaphore channel?
Acquire the slot with a select over both the semaphore send and ctx.Done(), so a cancelled context aborts the wait instead of blocking forever. Each running goroutine also selects on ctx.Done() to stop early and releases its token via defer, so cancellation never leaks a slot.
Common mistakes
- ✗Blocking on the semaphore send alone, so a cancelled context still waits for a free slot
- ✗Releasing the token only on the happy path, leaking a slot whenever work is cancelled
- ✗Assuming context cancellation stops a goroutine automatically rather than being checked
Follow-up questions
- →Where do you put the token release so a panic in the work still frees the slot?
- →How does the concurrency helper
errgroup'sSetLimitcombine bounding and cancellation?