select, pipelines & cancellation: composing and stopping concurrent work
A single channel (Lesson 09) lets two goroutines hand off one value. Real systems need more: wait on several channels at once, give up after a deadline, fan work out to many workers and back, and — the hard one — tell a tree of running goroutines to stop. This lesson adds the three tools that turn one channel into a system: select, the pipeline pattern, and context.Context. The team-up payoff is that Go makes the failure mode of all this — the goroutine leak — into something visible and preventable rather than a slow memory creep no one can find.
context.Context is that bet made concrete: cancellation becomes an ordinary argument threaded through your call graph, not magic. The complexity Go earns here is small (one interface, one passed value); the complexity it lets you avoid is the unbounded, invisible kind.close and the happens-before guarantee. You also need goroutines and the scheduler from Lesson 08.New capability: wait on multiple channels with
select; build a fan-out/fan-in pipeline; cancel a whole tree of goroutines on a deadline or a caller's signal with context.Context; and recognize and prevent the goroutine leak — the canonical Go concurrency bug.select lets a goroutine wait on several outcomes at once. (2) Compose goroutines into a pipeline — stages connected by channels — and fan work out and back. (3) The real problem: how do you make all those goroutines stop? Meet the goroutine leak, then context.Context as the standard answer. (4) The cost — what context buys, what it taxes, and the rules that keep it honest.1 · The problem: a blocked channel commits you to one outcome
A receive on a channel, v := <-ch, blocks the goroutine until a value arrives. That is exactly what you want when there is one thing to wait for. But a server rarely waits for one thing. It waits for "a request or a shutdown signal," for "a result or a timeout," for "work from any of three producers." A plain receive can't express "or" — it picks one channel and parks on it, blind to everything else. If the value it's waiting for never comes, the goroutine is stuck forever.
select is the language's answer: it is a switch whose cases are channel operations, and it blocks until one of them can proceed. If several are ready at once, it picks one uniformly at random (deliberately — to prevent starvation, so a hot channel can't permanently shut out a quiet one). A default case makes the whole thing non-blocking: if no case is ready, default runs immediately.
// Wait for a result, but give up after 2 seconds.
select {
case res := <-results:
handle(res)
case <-time.After(2 * time.Second): // returns a channel that fires once, later
return errTimeout
}
time.After(d) returns a <-chan Time that the runtime sends on once, d later. Composed into a select, it turns "wait forever" into "wait at most d" — the single most common use of select in production code. Two things to internalize about it: the timer fires relative to when select began evaluating its cases, and if the result does arrive first, the timer's goroutine and channel linger until the timer expires (~one timer's worth of memory). In a tight loop that runs millions of times, prefer a reusable time.NewTimer you Stop(); for a once-per-request timeout, time.After is fine.
defaultdefaultdefault runs immediately — a non-blocking try-send or try-receive.select{}main; usually a bug.One subtlety worth its own sentence: a receive from a nil channel blocks forever, and so does a send. That sounds like a footgun, but it's a tool — setting a channel variable to nil inside a select loop disables that case, which is the clean idiom for "I'm done reading from this input, keep selecting on the others."
2 · Composing goroutines: the pipeline, fan-out, fan-in
Question 2 of the five — who owns this data and how does it move? — has a beautiful answer once you have channels and select: build your computation as a pipeline of stages, each a goroutine, connected by channels. Each stage owns its data exclusively while it works, then transfers ownership downstream by sending. No stage touches another's memory, so there is nothing to lock (we'll cover the exception in Lesson 11). A stage signals "no more data" by close-ing its output channel; downstream's for v := range ch ends cleanly when the channel is drained and closed (Lesson 09's close semantics).
// A stage: owns out, closes it when done, returns it as receive-only.
func gen(nums ...int) <-chan int {
out := make(chan int)
go func() {
defer close(out) // tell downstream "no more values"
for _, n := range nums {
out <- n // ownership of n moves to the receiver
}
}()
return out
}
func sq(in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in { // ends when in is drained AND closed
out <- n * n
}
}()
return out
}
Notice the return types: <-chan int is a receive-only channel. By handing callers a directional channel, the stage encodes in the type system that the caller may only read — they cannot accidentally send into, or close, a channel they don't own. That is the same instinct as value semantics in Lesson 02: make the ownership visible at the boundary.
Fan-out is just starting several goroutines that read the same input channel — the runtime hands each queued value to whichever worker is ready, giving you parallelism for free (bounded by GOMAXPROCS, by default the number of CPUs). Fan-in is the reverse: merge several output channels into one. The merge uses a sync.WaitGroup to know when every input has closed before it closes the merged channel — a first taste of Lesson 11's "channels orchestrate, primitives count."
func merge(cs ...<-chan int) <-chan int {
out := make(chan int)
var wg sync.WaitGroup
wg.Add(len(cs))
for _, c := range cs {
go func(c <-chan int) { // pre-Go1.22: pass c as an arg to avoid loop-var capture
defer wg.Done()
for v := range c {
out <- v
}
}(c)
}
go func() { wg.Wait(); close(out) }() // close once, after all inputs drain
return out
}
3 · The real problem: making it all stop — leaks and context
The pipeline above has a hidden assumption: that the consumer drains every value. Suppose it doesn't — it finds the answer after one result and returns. Now every upstream stage is blocked forever on out <- v, because no one will ever receive. Those goroutines never return, never get garbage collected (a goroutine is a GC root while it's alive or blocked), and never release what they captured. That is a goroutine leak: no panic, no error, no log line — just goroutines accumulating until the process is killed for memory. It is the single most common serious bug in concurrent Go.
You could give each stage a private done chan struct{} and select on it. That works, but threading an ad-hoc done channel through every function — plus deadlines, plus request-scoped values — got reinvented incompatibly across the ecosystem. So the standard library blessed one shape: context.Context. A Context is a small interface; the part that matters here is Done() <-chan struct{} — a channel that's closed when the work should stop (because a deadline passed, or someone called cancel()). A closed channel makes every receiver unblock at once (Lesson 09), so one cancel() call fans a stop signal out to an entire tree of goroutines.
// Every blocking op in a stage now also selects on ctx.Done().
func sq(ctx context.Context, in <-chan int) <-chan int {
out := make(chan int)
go func() {
defer close(out)
for n := range in {
select {
case out <- n * n: // normal path
case <-ctx.Done(): // canceled: bail out, deferred close runs
return
}
}
}()
return out
}
// Caller controls the lifetime and MUST release it.
ctx, cancel := context.WithCancel(context.Background())
defer cancel() // releasing twice is safe; never releasing leaks
results := sq(ctx, gen(ctx, 2, 3, 4))
first := <-results // take one and stop
cancel() // closes ctx.Done() -> every stage returns
The select { case out <- v: case <-ctx.Done(): return } pattern is the whole game: the goroutine is now guaranteed to return — either it sends, or it's told to stop. The leak is structurally impossible. context also gives you the two convenience constructors that cover most needs:
| Constructor | Fires Done() when… | Typical use |
|---|---|---|
WithCancel(parent) | you call the returned cancel() | manual stop (consumer found its answer) |
WithTimeout(parent, d) | d elapses or cancel() called | "this RPC gets 200ms" — the common case |
WithDeadline(parent, t) | clock reaches t or cancel() called | a fixed wall-clock budget shared across calls |
Background() / TODO() | never | the root of a tree; TODO = "wire a real one later" |
Contexts form a tree: each WithX derives a child from a parent, and canceling a parent cancels all descendants — but never the reverse. This mirrors a request's call graph exactly. An HTTP handler gets a ctx that's canceled when the client disconnects; it passes that ctx to the database call, which passes it to the driver — so a hung-up client tears down the entire in-flight stack automatically. That propagation is the feature: you wire cancellation once, at the boundary, and it reaches everywhere downstream.
chan struct{} and not chan bool?struct{} occupies zero bytes — all instances share one address. A chan struct{} is a pure signal carrying no data, and close-ing it (rather than sending) broadcasts to all waiters at once. That's why Done() returns <-chan struct{} and is closed, never sent on: the signal is "stop," and it must reach every listener.4 · What it costs, and the rules that keep it honest
Per the bargain, name the bill. context is cheap at runtime — a Context is a few words, derivation allocates one small struct and (for timeouts) one timer, and checking ctx.Done() in a select is a channel-readiness test, nanoseconds. The real cost is in the source: ctx context.Context becomes the first parameter of nearly every function in a server, threaded explicitly down the call graph. Go chose that visible plumbing over an invisible thread-local or ambient cancellation — the same choice it made for errors (Lesson 07): make the cross-cutting concern an ordinary value you can see, not magic you can't.
- No leaks by construction. Every goroutine has a guaranteed exit: it finishes or it's canceled.
- Tree-shaped cancellation. One
cancel()/ one expired deadline tears down a whole subgraph. - Deadlines that compose. A child can shorten but never extend a parent's budget.
- One ecosystem convention. Every stdlib and third-party API that does I/O takes a
ctxfirst arg.
- Plumbing tax.
ctxas a parameter everywhere; verbose, deliberate, likeif err != nil. - Pass it, don't store it. A
Contextis request-scoped — never put one in a struct field; pass it as an argument. WithValueis for request metadata only (trace IDs, auth), never for optional function parameters — it's untyped and easily abused.- Always release.
defer cancel()even on the timeout path, or you leak the timer until it fires.
Cancellation in Go is cooperative, not preemptive: cancel() closes a channel, but a goroutine only stops if it checks ctx.Done(). A stage stuck in a pure CPU loop with no channel op and no context check will run to completion regardless. The runtime won't kill it for you — the discipline of selecting on ctx.Done() at every blocking point (and periodically in long compute loops) is yours to keep. That is the honest cost: context gives you the mechanism, not the guarantee; you supply the guarantee by using it everywhere.
Checkpoint exercise
runtime.NumGoroutine() before the pipeline, after taking one result, and after a time.Sleep — watch the count not return to baseline. Now thread a context.WithCancel through every stage, add the select { case out <- v: case <-ctx.Done(): return } guard, and call cancel() after the first result. Confirm the goroutine count returns to baseline. Then answer question 2 of the five for each version: who owns each value, and what happens to that ownership when the consumer walks away? The leaking version has values whose owner is blocked forever; the fixed version gives every owner an exit.Where this points next
Pipelines and context work because each stage owns its data and transfers it — there is nothing shared, so there is nothing to protect. But some state genuinely is shared: a connection pool, a counter, an in-memory cache that many goroutines read and write at once. Channels are the wrong shape for that — orchestrating access to a single shared map through a channel is slow and awkward. Lesson 11 covers the other half of Go concurrency: sync.Mutex, RWMutex, WaitGroup, Once, and sync/atomic; the -race detector that finds the bugs you'll otherwise ship; and the decision rule that ties this lesson to that one — channels orchestrate work; mutexes protect state — with the judgment for which to reach for.
select waits for the first of several — random among ready cases to prevent starvation, non-blocking with a default, and the home of the time.After timeout idiom. Composing goroutines as a pipeline of channel-connected stages answers "who owns the data and how does it move?": each stage owns its values, transfers them by sending, and signals end-of-stream by close — fan-out is N workers on one input, fan-in merges many outputs with a WaitGroup. The bug this all invites is the goroutine leak — a goroutine blocked forever on a channel that will never proceed, invisible until memory climbs — and the team-up fix is to make stopping as explicit as starting: context.Context, whose Done() channel closes on a cancel() or deadline and fans a stop signal through a tree of derived contexts that mirrors the call graph. The cost is honest and small — ctx threaded as an explicit first argument everywhere, cancellation that is cooperative so you must actually select on it — and the transferable habit is to design every concurrent worker with a guaranteed path to return before you start it.Interview prompts
- If two cases of a
selectare ready simultaneously, which runs? (§1 — one is chosen pseudo-randomly, by design, to prevent a busy channel from starving a quiet one; never rely on case order for priority.) - How do you make a channel operation non-blocking, and how do you add a timeout? (§1 — a
defaultcase makesselectnon-blocking; acase <-time.After(d)gives up afterd, sincetime.Afterreturns a channel that fires once.) - What is a goroutine leak and why doesn't the GC collect it? (§3 — a goroutine blocked forever on a channel that will never proceed; a live/blocked goroutine is a GC root, so it and everything it references stay alive — no panic, just memory climbing.)
- What does
context.Contextactually do, mechanically, to stop work? (§3 — itsDone()returns a<-chan struct{}that is closed on cancel/deadline; a closed channel unblocks all receivers at once, so onecancel()fans a stop signal through the whole derived-context tree.) - Why is
Done()achan struct{}that's closed rather than achan boolyou send on? (§3 —struct{}is zero bytes (pure signal), andclosebroadcasts to every waiter simultaneously, whereas a send reaches only one receiver.) - Is Go's cancellation preemptive? What's the engineer's responsibility? (§4 — no, it's cooperative:
cancel()only closes a channel; a goroutine stops only if itselects onctx.Done(). A pure CPU loop that never checks will run to completion.) - Give the rules for using
contextcorrectly. (§4 — pass it as the first argument, never store it in a struct; useWithValueonly for request-scoped metadata, not optional params; alwaysdefer cancel()to release the timer even on the success path.)