Back to Notes

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 out from a worker (multiple senders → panic) instead of the dedicated closer goroutine.
  • Worker sends result while consumer already quit → all workers block forever; the select on ctx.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 Wait returns — 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 Wait still 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

  1. 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 under errgroup.WithContext — each receive and each send wrapped in select { case ...: case <-ctx.Done(): return ctx.Err() }; (3) reorder stage holds a map[int]Result + next counter, 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 their ctx.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 for next), and unbounded reorder buffer growth (window cap). Verify with -race and goleak in tests.
  2. 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.
  3. 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]]