all_lessons/Go/10 · select & contextlesson 11 / 18

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.

The thesis, here
Concurrency is where complexity hides best: a leaked goroutine doesn't crash, throw, or appear in a stack trace — it just sits blocked forever, holding its ~2 KB stack and whatever it captured, while the next engineer wonders why memory climbs under load. Go's bet is that the way you stop work should be as explicit and as visible in the signature as the way you start it. 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.
Linear position
Prerequisite: Lesson 09 — channels as typed conduits that move ownership of a value; unbuffered (synchronization) vs buffered (decoupling); 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.
The plan
Four moves. (1) The problem: one channel forces you to commit to one conversation — 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.

several cases ready
One is chosen pseudo-randomly, not top-to-bottom. Never rely on case order for priority.
no case ready, no default
The goroutine blocks until one becomes ready — this is how you wait on "any of N".
no case ready, with default
default runs immediately — a non-blocking try-send or try-receive.
empty select{}
Blocks forever. Occasionally useful to park 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).

generate square (fan-out: 3 copies) collect (fan-in) +--------+ +--------+ +---------+ | gen() | --ch-->| sq() |--+ | | +--------+ +--------+ | | | | sq() |--+--> merge() --merged--> | range | +--------+ | | | | sq() |--+ +---------+ +--------+ one producer N identical workers, each reads one consumer owns its sends the SAME input channel drains merged
// 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.

The leak, precisely
A goroutine blocked on a channel send/receive that will never complete is leaked. The runtime cannot prove the channel is dead, so it keeps the goroutine alive — holding its stack (≥2 KB) and any heap it references. Leaks don't show up in unit tests with small inputs; they show up as a slow memory climb in production. The fix is a rule: every goroutine must have a guaranteed path to return. That guarantee is what cancellation provides.

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:

ConstructorFires 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() calleda fixed wall-clock budget shared across calls
Background() / TODO()neverthe 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.

Why chan struct{} and not chan bool?
An empty struct 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.

What context buys
  • 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 ctx first arg.
What it costs / the rules
  • Plumbing tax. ctx as a parameter everywhere; verbose, deliberate, like if err != nil.
  • Pass it, don't store it. A Context is request-scoped — never put one in a struct field; pass it as an argument.
  • WithValue is 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

Try it
Take the leaking pipeline from §3 (consumer reads one value, returns) and run it with goroutine-count assertions: print 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.

Takeaway
One channel waits for one thing; 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