The idea in one minute#
select waits on several channel operations at once and runs whichever is ready first. It is
how a goroutine waits for work or a timeout or a stop signal.
context.Context is the standard way to carry that stop signal — plus a deadline and
request-scoped values — down a call chain. A context forms a tree: cancelling a parent
cancels every child. Each function that might block takes a ctx as its first parameter and
gives up when ctx.Done() is closed.
A goroutine cannot be killed from outside. Cancellation in Go is cooperative: you ask, and well-written code listens.
An analogy#
A kitchen ticket with “cancelled” stamped on it. When a customer leaves, the waiter stamps the order; every cook working on a part of that order — the grill, the salad station, the person who went to the cellar for wine — checks the ticket at natural pauses and stops. Nobody is dragged away mid-chop. And an order stamped at the top is cancelled for everyone working under it.
A picture#
flowchart TB BG["context.Background()"] --> REQ["WithTimeout(2s)<br/>one incoming request"] REQ --> A["WithCancel<br/>retrieve documents"] REQ --> B["WithTimeout(500ms)<br/>call the model"] A --> A1["query shard 1"] A --> A2["query shard 2"] B --> B1["HTTP request<br/>net/http watches ctx.Done()"] X["client disconnects<br/>or 2 s pass"] -.->|"cancel"| REQ REQ -.->|"Done() closes for the whole subtree"| A REQ -.-> B class BG neutral class REQ,A,B queue class A1,A2,B1 compute class X warn
How it really works#
select#
select {
case v := <-in: // receive
handle(v)
case out <- result: // send
case <-ctx.Done(): // cancellation
return ctx.Err()
case <-time.After(time.Second): // timeout for this one wait
return errTimeout
default: // optional: run if nothing else is ready (non-blocking)
}- If several cases are ready, one is chosen uniformly at random — so no case can starve the others, and you must not rely on order.
- With no
default,selectblocks until a case is ready. Withdefault, it never blocks. - A case on a nil channel is never ready: assign nil to switch a case off.
select {}blocks forever.- Internally
selectlocks all the channels involved, checks each, and if none is ready enqueues the goroutine on all of them; the first to fire wakes it and it is removed from the rest.
context#
type Context interface {
Deadline() (time.Time, bool)
Done() <-chan struct{} // closed when cancelled or expired
Err() error // nil, context.Canceled or context.DeadlineExceeded
Value(key any) any
}| Constructor | Cancels when |
|---|---|
context.Background() | Never. The root, used in main and tests |
WithCancel(parent) | You call cancel(), or the parent is cancelled |
WithTimeout(parent, d) / WithDeadline(parent, t) | Time runs out, cancel() is called, or the parent is cancelled |
WithCancelCause(parent) | As WithCancel, recording why; read it with context.Cause(ctx) |
WithoutCancel(parent) | Never — keeps values, drops cancellation (for work that must outlive the request) |
WithValue(parent, key, v) | Same as the parent; adds a value |
signal.NotifyContext(parent, os.Interrupt) | A signal arrives: graceful shutdown |
Rules:
ctxis the first parameter, namedctx. Do not store it in a struct.- Always call
cancel, usuallydefer cancel(). It releases the timer and the entry in the parent’s list of children;go vetreports a lost cancel. - Check it where you block or loop.
selectonctx.Done()beside channel operations; in a CPU loop, checkctx.Err()every so often. - Pass it down.
http.NewRequestWithContext,db.QueryContext,exec.CommandContext— the standard library stops work when the context ends. - Values are for request-scoped metadata that crosses API boundaries — a trace ID, an authenticated user — not for optional parameters. Use an unexported key type so packages cannot collide.
- Return
ctx.Err()(or wrap it) when you stop because of cancellation, so callers can testerrors.Is(err, context.Canceled).
Deadlines add up#
A child’s deadline can only be earlier than its parent’s. Budgeting a request therefore composes naturally: the handler has 2 s, gives the model call 1.5 s of it, and the model client’s own 30 s default never matters. For a streaming LLM call, distinguish the time to first byte (a per-step timeout you reset) from the total (the context deadline).
Timers#
time.After(d)is convenient inside aselect. Since Go 1.23 an unreferenced timer is collected promptly, so using it in a loop no longer leaks.time.NewTimerwithResetfor a reusable timeout;time.NewTickerfor periodic work — anddefer t.Stop().context.AfterFunc(ctx, f)runsfwhen a context is cancelled — handy for unblocking something that does not take a context (closing a connection, for example).
In a server#
net/http gives every request a context (r.Context()) that is cancelled when the client
disconnects or the handler returns. For an LLM service this is the most valuable cancellation
there is: a user who closes the tab should stop consuming GPU. Thread r.Context() all the way
into the generation loop and check it between tokens.
Code#
// cancel.go — select, timeouts, and a context cancelling a whole tree of work.
package main
import (
"context"
"errors"
"fmt"
"runtime"
"sync"
"time"
)
// generate streams tokens until done or cancelled. It checks ctx between tokens.
func generate(ctx context.Context, n int, perToken time.Duration, out chan<- string) error {
defer close(out)
for i := 0; i < n; i++ {
select {
case <-time.After(perToken): // "compute" one token
case <-ctx.Done():
return context.Cause(ctx)
}
select {
case out <- fmt.Sprintf("tok%d", i):
case <-ctx.Done(): // the consumer went away while we were trying to send
return context.Cause(ctx)
}
}
return nil
}
// fanOut queries several "shards"; the first error cancels the rest.
func fanOut(ctx context.Context, delays []time.Duration, failAt int) error {
ctx, cancel := context.WithCancelCause(ctx)
defer cancel(nil)
var wg sync.WaitGroup
for i, d := range delays {
wg.Add(1)
go func() {
defer wg.Done()
select {
case <-time.After(d):
if i == failAt {
cancel(fmt.Errorf("shard %d failed", i))
return
}
fmt.Printf(" shard %d finished\n", i)
case <-ctx.Done():
fmt.Printf(" shard %d stopped early: %v\n", i, context.Cause(ctx))
}
}()
}
wg.Wait()
return context.Cause(ctx)
}
func main() {
// 1. A deadline stops a stream part-way.
ctx, cancel := context.WithTimeout(context.Background(), 55*time.Millisecond)
out := make(chan string)
errc := make(chan error, 1)
go func() { errc <- generate(ctx, 100, 10*time.Millisecond, out) }()
n := 0
for range out {
n++
}
err := <-errc
cancel()
fmt.Printf("stream: got %d tokens, then: %v (deadline exceeded? %v)\n", n, err, errors.Is(err, context.DeadlineExceeded))
// 2. The consumer leaves; the producer notices and exits instead of leaking.
ctx, cancel = context.WithCancel(context.Background())
out = make(chan string)
go func() { errc <- generate(ctx, 100, time.Millisecond, out) }()
<-out
<-out
cancel() // "the client disconnected"
fmt.Println("after client disconnect:", <-errc)
// 3. One failure cancels its siblings.
fmt.Println("fan-out:")
err = fanOut(context.Background(), []time.Duration{5 * time.Millisecond, 20 * time.Millisecond, 200 * time.Millisecond}, 1)
fmt.Println(" result:", err)
// 4. A child cannot outlive its parent's deadline.
parent, cancelP := context.WithTimeout(context.Background(), 20*time.Millisecond)
child, cancelC := context.WithTimeout(parent, time.Hour)
dl, _ := child.Deadline()
fmt.Printf("child asked for 1h; effective deadline is in %v\n", time.Until(dl).Round(time.Millisecond))
cancelC()
cancelP()
time.Sleep(10 * time.Millisecond)
fmt.Println("goroutines still running:", runtime.NumGoroutine(), "(only main: nothing leaked)")
}Remember this#
selectwaits on many channel operations; ready cases are chosen at random;defaultmakes it non-blocking.- Cancellation is cooperative. A context is a tree: cancel a parent and its whole subtree stops.
ctxfirst, alwaysdefer cancel(), checkDone()wherever you block or loop.- In a server, the request context ends when the client disconnects — carry it into the work.
Try it#
- Run
cancel.go. Ingenerate, delete the secondselectand send with a plainout <- .... Which scenario now leaks a goroutine, and why? - Add a per-token timeout to the consumer: give up if no token arrives within 30 ms, even though the overall deadline has not passed.
- Write an HTTP handler that streams numbers once per 100 ms and stops when
r.Context()is done. Disconnect withcurland confirm the handler exits.
Check yourself#
- What does
selectdo when two cases are ready? - Why must
cancelalways be called? - What should a function return when it stops because its context was cancelled?