Pipelines under cancellation: guarded sends, closing discipline, and the canonical goroutine leak
Pipeline stages connect by channels; every send must select on ctx.Done or the goroutine leaks the moment downstream stops reading. Only the sender closes; fan-in closes after a WaitGroup. Buffers absorb jitter, not imbalance. Stage depth gauges make leaks visible.
The nightly reconciliation batch had run for 14 months. Then someone added a validation step to the final stage, and on bad input it did the natural thing: logged and returned. One malformed record at 02:13, the consumer goroutine exits — and the three upstream stages do not notice, because they are not supposed to. They keep producing until each one blocks on a channel send that no one will ever receive. Per run that froze roughly 40,000 goroutines mid-send: ~160 MB of stacks plus every parsed record they held. The batch “completed” (the scheduler saw the main goroutine return), memory just never came back. Eight nights later the pod started OOMKilling at 03:00, and the goroutine profile read like a confession: one stack, chan send, count 312,887. The pipeline pattern is from a 2014 Go blog post; the leak is from forgetting the one rule the post is actually about — a send that cannot be abandoned is a goroutine that cannot exit.
Stages and channels — the shape
A pipeline is goroutines connected by channels: each stage receives values upstream, transforms them, and sends downstream. The 2014 blog version used a per-pipeline done channel; the modern form is context:
func parse(ctx context.Context, in <-chan []byte) <-chan Record {
out := make(chan Record)
go func() {
defer close(out) // this stage owns out — only the sender closes
for raw := range in {
rec, err := decode(raw)
if err != nil {
continue // or: report on an error path, below
}
select {
case out <- rec:
case <-ctx.Done():
return // abandon the send; defer closes out
}
}
}()
return out
}Two structural facts make the pattern composable. The stage owns its output channel: it creates it, it alone sends, and defer close(out) signals “no more values” downstream — which is what lets the next stage use a plain for range. And the function returns the receive-only channel immediately; the pipeline is wired before any data flows.
The canonical leak: the unguarded send
Strip the select and the stage reads cleaner — out <- rec — and is wrong. A channel send blocks until a receiver is ready. The moment downstream stops reading — it crashed, returned early on bad input like the Hook, or simply decided it had enough — that send blocks forever. The goroutine is parked on chan send, the GC cannot touch it (a parked goroutine is a root, pinning its stack and every record it references), and no error surfaces anywhere. This is the single most common real-world goroutine leak, and the rule that prevents it is mechanical: every send into a pipeline channel sits inside a select with a ctx.Done() case. Receives in a for range are safe only because the sender’s defer close is guaranteed — if a stage can exit without closing its output, downstream ranges leak the same way.
A three-stage pipeline has unguarded sends (a bare send into out, no select). The final consumer hits a bad record and returns early without cancelling anything. What is the steady state?
Closing discipline and fan-in
When you first wire a fan-in, the question “who closes the merged channel?” is easy to get wrong — every sender feels like a candidate, but any one of them closing early triggers a panic on the others. The closing rule has no exceptions: only the sender closes a channel, because a send on a closed channel panics and a double close panics — the receiver can never know it is safe. Single-sender stages get it for free with defer close(out). Fan-in — N workers sending into one merged channel — is where discipline gets a shape: nobody may close until all senders are done, so you count them:
func merge(ctx context.Context, ins ...<-chan Record) <-chan Record {
out := make(chan Record)
var wg sync.WaitGroup
for _, in := range ins {
wg.Add(1)
go func() { // one forwarder per input
defer wg.Done()
for rec := range in {
select {
case out <- rec:
case <-ctx.Done():
return
}
}
}()
}
go func() { wg.Wait(); close(out) }() // WaitGroup-then-close, in its own goroutine
return out
}The wg.Wait(); close(out) pair runs in a separate goroutine so merge returns immediately — the close happens exactly once, exactly after the last forwarder exits, whether that exit was “input drained” or “ctx cancelled mid-send”.
Drain or abandon — who unblocks whom on cancel
Cancellation tears the pipeline down from both ends, and you should know which mechanism you are relying on. Abandoning is the default above: every sender guards its own send with ctx.Done, so when ctx is cancelled each stage unblocks itself and returns; values in flight are dropped. Draining is the alternative: the consumer keeps for range-ing until the channel closes, so upstream finishes naturally and nothing is dropped — at the cost of processing (or at least receiving) everything already produced. Draining without guarded sends is how people convince themselves the guards are optional; it holds only while every consumer is disciplined, which is exactly what the Hook’s one early return broke. The robust posture: guard every send anyway, and choose draining only when the data must not be lost (then the drain loop itself needs a bound — count or deadline — or a slow producer turns shutdown into a hang).
▸Why this works
Why does the leak point at the sender rather than the receiver who left? Because in CSP-style channels the send is the commitment: a sender mid-send has no way to time out, check a flag, or be garbage-collected — its only exits are a receiver arriving or another select case firing. The receiver who returns early commits no crime the type system can see; it just removes the future the sender was waiting for. That asymmetry is the entire argument for the guarded-send idiom: the sender is the only party that can save itself, so the escape hatch must be compiled into the send site.
Buffers: what they buy, honestly
Making stage channels buffered — make(chan Record, 1024) — feels like a throughput fix. Be precise about what it does. A buffer absorbs jitter: short bursts, GC pauses, a momentarily slow consumer; producers keep moving instead of handing off in lockstep, and unbuffered handoff latency (~hundreds of nanoseconds per send-receive rendezvous) stops dominating tiny work items. What a buffer does not fix is imbalance: if a stage produces at 12k/s and the next consumes at 9k/s, a 1024-slot buffer is full after ~340 ms and the pipeline runs at exactly 9k/s — same as unbuffered, plus 1024 records of extra memory and staleness. Steady-state throughput is the slowest stage, full stop. Buffers also delay leak symptoms: an unguarded send into a big buffer fails only when the buffer fills, often minutes after the consumer died — your 02:13 bug surfaces at 02:47 with a stack that no longer points at the cause.
A stage produces 12k records/s; the next stage consumes 9k/s for hours. An engineer inserts a 1024-slot buffer between them. What changes?
Errors and observability
A stage that can fail needs an error path. Two workable designs. Per-stage error channel: the stage sends errors into errc and the orchestrator selects over it — explicit, but every consumer must drain errc or you have built a second leak. errgroup-wrapped stages — usually the better default: each stage runs as g.Go, returns its error, and the group ctx cancels every other stage’s guarded sends; g.Wait gives you the first failure after the whole pipeline has unwound. For visibility, instrument the channels themselves: a periodic gauge of len(ch) against cap(ch) per stage shows where the queue sits — a buffer pinned at capacity marks the bottleneck stage, a buffer pinned at zero marks starvation upstream, and runtime.NumGoroutine() trending up while traffic is flat is the leak alarm from the previous lesson. Cheap numbers, and they turn “the batch is slow” into “stage 3 is the wall”.
- 01Explain the canonical pipeline leak: the mechanism, why the runtime and GC both stay silent, and the idiom that prevents it.
- 02State the closing discipline, how fan-in implements it, and the drain-vs-abandon choice on cancellation.
The pipeline pattern is stages of goroutines connected by channels, and its modern form has context threaded through every stage. The structure that composes: each stage creates and owns its output channel, sends into it, and guarantees defer close(out) so downstream can for-range; the function returns the channel immediately and data flows after wiring. The rule that keeps it alive under failure: every send is a select with a ctx.Done case, because an unguarded send blocks forever the moment downstream stops reading — the goroutine parks on chan send, the GC treats it as a root pinning its stack and in-flight records, the runtime sees no deadlock since other goroutines still run, and 40,000 frozen senders per nightly run is what that silence buys. Closing discipline is sender-only; fan-in counts its forwarders with a WaitGroup and closes in a separate wait-then-close goroutine. Cancellation tears down either by abandoning — each sender rescues itself through Done, dropping in-flight values — or by draining, where the consumer receives to close so nothing is lost, bounded or it hangs. Buffers are jitter absorbers, not imbalance fixes: a 1024-slot buffer in front of a persistently slower stage fills in seconds and the pipeline settles at the slowest stage rate, with extra memory and delayed leak symptoms as the price. Run stages under errgroup so one failure cancels every guarded send and Wait returns the first error; export len over cap per channel and the goroutine count, and the pipeline tells you where its queue and its leaks live. Now when you see a goroutine profile with hundreds of identical chan send stacks — you know which stage’s consumer stopped reading, and you know the one line that would have let those goroutines save themselves.
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.
Apply this
Put this lesson to work on a real build.