Pipelines, Fan-Out/Fan-In, and Cancellation in Go
Concept
A Go pipeline is a series of stages connected by channels, where each stage is a group of goroutines running the same function: it receives values from an inbound channel, does some work, and sends results on an outbound channel. [[../../LanguageInternals/Fundamentals/goroutines-and-the-go-scheduler\|Goroutines and the Go Scheduler]] covers what a goroutine costs and how the runtime schedules it; this note covers the idiomatic shapes for wiring many goroutines together safely — the layer most Go concurrency bugs actually live in.
Two compositional patterns sit on top of the basic pipeline. Fan-out is running multiple goroutines reading from the same input channel, splitting the work across them (used when a stage is CPU- or I/O-bound and independent per item). Fan-in is the reverse: multiplexing several channels onto one, so a downstream stage can consume from many upstream producers as a single stream — typically implemented with a sync.WaitGroup that tracks each source goroutine and closes the merged output channel once all sources are drained.
The pattern that makes both of these safe to tear down is a shared done channel (or, in modern Go, context.Context's Done() channel): every stage's select includes a case on done/ctx.Done() alongside its channel send/receive, so that when the pipeline needs to stop early — an error downstream, a caller timeout, a cancelled request — every goroutine in the pipeline notices and exits instead of blocking forever on a channel nobody will ever read or write again. Closing the done channel (rather than sending a value on it) is deliberate: a closed channel can be observed by an unbounded number of goroutines simultaneously, where a single value send only unblocks one receiver.
Tradeoffs
| Approach | Benefit | Cost |
|---|---|---|
Raw channels + a shared done channel (hand-rolled) |
No dependency beyond the standard library; full control over buffering/backpressure at each stage | Every stage must remember to select on done — a single stage that only does a blocking channel op without it reintroduces the leak the pattern exists to prevent |
context.Context for cancellation, channels for data |
Standard idiom across the ecosystem (HTTP handlers, gRPC, database drivers all thread ctx already); carries deadlines and cancellation reasons, not just a bare signal |
Slightly more ceremony than a bare done channel for a purely-internal pipeline with no external caller to propagate cancellation from |
golang.org/x/sync/errgroup (Group, WithContext) |
One line to get "first error wins, cancel the rest" — Group.Go launches, Wait blocks and returns the first non-nil error, and a WithContext-derived context cancels automatically on that first error |
External dependency (though it's the de facto standard x/ package); still requires every launched goroutine to itself respect the derived context to actually stop early |
| Unbuffered channel per item, no pooling | Simplest to reason about, natural backpressure (a fast producer blocks until a slow consumer catches up) | Under bursty load, throughput is capped by the slowest single consumer with no queueing at all — sometimes too strict |
Buffered vs. unbuffered channels is the sub-decision inside all of the above: an unbuffered channel is a synchronization point (send blocks until receive), giving free backpressure; a buffered channel decouples producer and consumer up to the buffer size, trading a bounded amount of memory for smoother throughput under bursty arrival. Sizing a buffer by guesswork just moves the leak/backpressure problem to a slightly later, harder-to-reproduce point.
When to use / when not to
- Reach for a pipeline whenever a unit of work naturally decomposes into sequential stages with different concurrency needs (e.g., read from disk → decode → transform → write to network) — running each stage as its own goroutine pool lets a fast stage keep working ahead of a slow one instead of the whole pipeline running at the speed of its slowest step serially.
- Use fan-out only when the per-item work is independent and the fan-out factor is bounded (a worker pool sized to
GOMAXPROCSfor CPU-bound work, or to the downstream dependency's concurrency limit for I/O-bound work) — unbounded fan-out reproduces the same resource-exhaustion problem a circuit breaker guards against on the calling side, just self-inflicted. - Reach for
errgroupspecifically when you need "any failure cancels the whole group" semantics; reach for a hand-rolleddone/ctxpattern when partial failure is acceptable and you want every goroutine to run to completion regardless of siblings' outcomes. - Don't build a multi-stage pipeline for a single, non-repeating operation with no independent sub-units — the cancellation/wiring overhead only pays for itself once there's real concurrent, decomposable work.
Common pitfall
Treating the done/context channel as advisory rather than universal: a pipeline with four stages where three correctly select on ctx.Done() and one has a plain blocking ch <- result still leaks that one goroutine forever once the pipeline is cancelled, because nothing is left to receive from ch. The bug is invisible under normal operation — it only manifests as a slow, silent goroutine-count climb under whatever the cancellation path actually is (client disconnects, upstream timeouts), which is exactly the profile that's hardest to catch in a short-lived load test and easiest to catch in pprof's goroutine profile or runtime.NumGoroutine() trending upward under production traffic.
Engineering Lens
The design instinct these patterns encode is the same one behind [[../../../../Idempotency/CircuitBreakers/Fundamentals/circuit-breaker-pattern\|circuit breakers]] and bounded worker pools: every concurrent unit of work needs an explicit, designed answer to "how does this stop," not just "how does this start." A pipeline stage that only knows how to run and never how to stop is a resource leak waiting for the right cancellation timing to expose it. In review, the tell for a well-designed Go pipeline isn't the choice of raw channels vs. errgroup — both are fine — it's whether every select that can block also has a cancellation case, and whether that was verified under an actual cancellation (a cancelled context mid-flight, a killed downstream), not just under the happy path.
Sources
- package errors — pkg.go.dev (
context.Context,WithCancel/WithTimeout, theDone()channel pattern) - package errgroup — pkg.go.dev (
Group.Go,SetLimit,WithContextcancel-on-first-error semantics)