Skip to content
intermediate Phase 10 · Concurrency Patterns and Context

Cancellation with context

Propagate cancel signals through derived contexts and react on the Done channel.

50m
0 problems
Topic Progress 0%

The Request That Outlived Its Request

A browser cancels a search after 300ms — the user typed something else. Your handler did not notice: it is still waiting on a 3-second downstream query, still aggregating, still holding a database connection, and will write its JSON response to a socket nobody is reading. Multiply by every abandoned request and you have wasted downstream capacity, held connections for work no one wants, and produced latency metrics that describe work that was thrown away.

The missing signal is cancellation, and it has to travel: cancelling the handler must reach the query, the fetch, the queue append — every operation started on the request's behalf, however deep. Go carries that signal in context.Context, whose most-used property is a channel:

ctx, cancel := context.WithCancel(r.Context())
defer cancel() // always

go func() {
	// whatever runs under this ctx must watch Done
	select {
	case rows := <-query(ctx, "SELECT ..."):
		process(rows)
	case <-ctx.Done():
		log.Printf("abandoned: %v", ctx.Err())
	}
}()

ctx.Done() returns a channel that closes when the context is cancelled or times out. A closed channel — as a receiver in select — is always ready, so case <-ctx.Done() fires promptly no matter when cancellation arrives. ctx.Err() then reports why: context.Canceled when someone called cancel(), context.DeadlineExceeded when a deadline passed (the next topic's territory).

Two design facts explain why context became the standard instead of a bare chan struct{}:

  • Contexts form a tree. Every WithCancel(ctx) child is cancelled when its parent is — request cancelled → handler → its query → its fetch, all in one call, with no bookkeeping list of children to notify. Children can also cancel independently without affecting siblings or the parent.
  • Cancellation is information plus convention. The first function parameter convention (func handler(ctx context.Context, ...)), the standard-library adoption (http.NewRequestWithContext, db.QueryContext), and Done/Err/Err semantics are shared across the ecosystem, so a context you did not create behaves the way you expect.

What context is not: a place to hang arbitrary data (that is the values topic), a substitute for closing channels when the data stream itself ends, and not magic preemption — nothing in Go stops a goroutine. That last limitation is the subject every working Go programmer eventually meets.

WithCancel: Create, Propagate, Clean Up

The lifecycle discipline around context.WithCancel is short enough to memorize and consequential enough that violations show up as production leaks:

// service layer: derive your own scope, cancel it yourself
func (s *Service) Import(ctx context.Context, src Source) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // releases the child when Import returns — success OR failure

	g, egCtx := errgroup.WithContext(ctx)
	for _, shard := range src.Shards() {
		g.Go(func() error {
			return s.ingest(egCtx, shard) // downstream sees the whole chain
		})
	}
	return g.Wait()
}

// worker: context-aware from entry to exit
func (s *Service) ingest(ctx context.Context, sh Shard) error {
	for {
		select {
		case <-ctx.Done():
			return ctx.Err() // report cancellation as an error, honestly
		case job, ok := <-sh.Jobs():
			if !ok {
				return nil
			}
			if err := s.step(ctx, job); err != nil {
				return fmt.Errorf("shard %s: %w", sh.ID(), err)
			}
		}
	}
}

Every line of the lifecycle rules is visible there:

defer cancel() without exception. WithCancel allocates resources in the runtime's cancellation tree; cancel is how you return them. Omitting it on the success path — the version where if err != nil { cancel() } is the only call — leaks a registration that lives until the parent dies. One defer covers success, failure, and panic. The compiler enforces neither, and staticcheck's lostcancel check exists precisely because this is missed often.

Derive from the incoming context, never from context.Background(). The handler above receives ctx from its caller and builds its child from it, so an upstream cancel reaches everything below automatically. Reaching for Background() inside a request path severs the chain: everything downstream becomes un-cancellable regardless of what the caller does. Reserve Background() for main, tests, and top-level batch jobs where truly nothing can cancel you.

Pass ctx as the first parameter; never store it in a struct. The struct-field habit (type Server struct { ctx context.Context }) looks convenient and breaks ownership: which request's cancellation should that field carry? Contexts are per-operation values — like the request they represent — so they travel as arguments where each function's scope is visible in its signature.

Report cancellation as an error. Returning ctx.Err() lets callers distinguish abandonment from success with errors.Is(err, context.Canceled) — the same identity-checking discipline as any other error (Error Handling). Swallowing it (returning nil on cancel) makes cancelled work look completed, which is how aborted imports end up reported as successes.

The propagation shape worth seeing once as a whole: browser aborts → server framework cancels r.Context() → handler's child cancels → errgroup's derived ctx cancels → each worker's select fires → database driver cancels the in-flight query. One cancel() at the top, every layer reacting through the tree it built on the way down.

Cooperative Cancellation: Find Every Blocking Point

Cancellation does not preempt. cancel() closes a channel; a goroutine that is not listening keeps running until it finishes or reaches a point where it does listen. This is why a cancelled request sometimes lingers: the goroutine is parked somewhere that does not consult ctx.Done() at all.

Audit a worker for every place it can wait, and give each one an escape route:

func (w *Worker) Run(ctx context.Context) error {
	for {
		next, err := w.queue.Claim(ctx) // (1) queue claim: driver must accept ctx
		if err != nil {
			return err // context.Canceled surfaces here normally
		}

		if err := w.send(ctx, next); err != nil { // (2) network write with ctx
			return err
		}

		w.mu.Lock() // (3) mutex: cancellable ONLY if lock holder checks ctx — usually don't

		select { // (4) own channel ops: always selectable
		case w.outbound <- next.Result():
		case <-ctx.Done():
			w.mu.Unlock()
			return ctx.Err()
		}
		w.mu.Unlock()
	}
}

The four blocking classes and their honest answers:

  1. Channel operations you own — wrap in select with ctx.Done(). This is the case you control completely, and forgetting it is the classic leak: a worker blocked sending results to a consumer that stopped reading will sit there forever after cancellation unless the send participates in the select.
  2. Calls into the standard library or drivers — pass the context to the context-aware variant: NewRequestWithContext, QueryContext, DialContext. The library handles the wakeup. Calling the non-context version under a cancellable ctx is the integration bug: your code checks Done only between calls, so cancellation waits for the entire 30-second query to return.
  3. Infinite loops — add a ctx.Done() case (in a select or a dedicated check per iteration). A loop that only checks after each item does not notice cancellation while waiting for an item that will never come.
  4. Mutexes and non-cancellable library code — there is no clean answer, which is why designs push such waits to the edges. You cannot cancel a goroutine blocked in mu.Lock(); if a critical section can hold long enough for this to matter, the section is too long.

A practical debugging workflow: on shutdown complaints, dump goroutines with SIGQUIT (or the pprof goroutine profile from profiling) and read the stacks. Each parked goroutine tells you which class it is — a <-ch with no select nearby, a mutex.LockSignature, a driver call missing its ctx argument. The stack names the blocking point; the fix follows from the class.

The discipline in one sentence: every goroutine started under a context must reach a ctx.Done() observation during its longest potential wait — not merely between waits — and the way you prove it is by finding each blocking call in the stack and asking what wakes it.

Boundaries: What Cancellation Replaces and What It Doesn't

Context is one cancellation mechanism among several, and choosing the wrong one leaves programs either half-stopped or over-coupled.

close() ends a data stream; context cancels an operation. The producer finishes sending → close(jobs) → every consumer's range terminates after draining. That is end-of-stream for data, and closing semantics own it: close means drain then stop, applies to the channel itself, and needs no tree of participants. Context means stop now, pending work may be discarded, fans out across many operations through the tree, and carries a reason (Err()). Code that closes a work channel to interrupt workers throws away queued items and panics any producer still sending — the exact confusion this boundary prevents. When both exist in one system, their roles separate cleanly: channels for results, a context for whether those results are still wanted.

Done channel vs context — when a hand-rolled channel is still right. A component with exactly one stop signal, no timeout, no values, and no upstream linkage can use done := make(chan struct{}) and be complete — that is all ctx.Done() is underneath, plus the tree. Reach for context once you need any of: cancellation derived from above (the tree), deadlines (next topic), attaching request metadata (values), or interop with standard-library APIs that accept context.Context. Internal shutdown signals in a package's own types may stay as bare channels without violating anything.

Cancellation does not replace error handling. A cancelled operation returns context.Canceled, and the caller must still decide what that means for its own contract — the error-handling chain applies unchanged: wrap with %w, branch with errors.Is. One policy worth setting explicitly in server code: a request cancelled by its own client is not a server error and should not be logged as one; a cancellation your service imposed (bulkhead, budget) usually is worth logging. Distinguish them with errors.Is(err, context.Canceled) plus knowledge of who cancelled — typically by comparing against the request-scoped context's Err().

The layering this phase has now assembled: channels move values and mark stream ends; select lets one goroutine watch values and shutdown together; errgroup packages batch failure; context carries cancellation (and, next, deadlines) through whole call trees. Each layer solved a gap the previous one structurally could not — and the final gap, goroutines that survive shutdown because some blocking point was missed, is exactly what the phase's last topic teaches you to detect.

Quiz

1. A handler derives `ctx, cancel := context.WithCancel(parent)` and returns without calling cancel on the success path. What is the consequence?

Question 1 options

2. A worker goroutine blocks in `results <- r` on an unbuffered channel after the caller cancelled ctx. Why doesn't cancellation wake it?

Question 2 options

3. Inside a request handler you need a context for background cleanup work that must NOT stop when the request ends. What do you use?

Question 3 options

4. ctx.Err() returns context.Canceled in your service. What does that tell you, and what should you log?

Question 4 options

Flashcards

Question

What does cancel() actually do, and how does a goroutine find out?

Answer

It closes the context's Done channel — that's all. Goroutines discover it by receiving from ctx.Done(), typically as a select case alongside their work, and then consult ctx.Err() to learn whether cancellation or a deadline caused it. Nothing is preempted: goroutines not watching the channel continue running.

Question

Why must every WithCancel have a defer cancel(), even when the parent will eventually be cancelled anyway?

Answer

Each WithCancel registers a child in the parent's tree; cancel() unregisters it immediately. Without the defer, a long-lived parent accumulates every child ever derived from it — a request handler's parent can collect thousands of finished requests' contexts. The defer returns the resources on every path: success, error, or panic.

Question

What is cooperative cancellation, and which four blocking classes need attention in a worker?

Answer

Cooperative means nothing preempts a goroutine — it stops only where it observes Done. Audit: (1) your own channel ops → wrap sends/receives in select with ctx.Done(); (2) library calls → use the Context-aware variant (NewRequestWithContext, QueryContext); (3) infinite loops → check Done per iteration; (4) mutex and non-cancellable waits → cannot be cancelled, so keep critical sections short and push them to edges.

Question

When should a data stream end with close() rather than context cancellation?

Answer

When the stream is complete by design: the producer has sent everything, and consumers should drain the remainder before stopping. close() means 'drain then end' and belongs to the channel itself; context means 'stop now, pending work may be discarded,' carries a reason via Err(), and fans out across a tree of operations. Closing to interrupt drops queued data and risks panicking in-flight senders.

Question

Where does a context belong in a function signature, and why never in a struct field?

Answer

As the first parameter: ctx is scoped to a single operation, and the signature shows every caller that cancellation flows through. A struct field implies one context for the struct's lifetime — but which request's cancellation would it hold? Long-lived objects receive ctx per method call, keeping ownership and lifetime unambiguous.

Revision Notes

Key Takeaways

  • 1. cancel() closes Done; only goroutines that observe Done react — cancellation is cooperative, never preemptive
  • 2. defer cancel() on every WithCancel, derive children from the incoming ctx, and pass ctx as the first parameter
  • 3. One cancel at the top reaches the whole tree — handler, errgroup, workers, and context-aware drivers all stop together
  • 4. Every blocking point in a cancellable goroutine needs its own escape: select-wrapped channel ops, Context-suffixed calls, per-iteration loop checks
  • 5. close() ends a data stream after draining; context aborts operations and reports why — mixing them drops data or leaks senders

Interview Tips

  • • "How does context cancellation work in Go?" — Done channel closes, select observes it, Err() reports Canceled vs DeadlineExceeded; immediately add the cooperative caveat and show where a naive worker still blocks
  • • "Walk through cancellation in a request path" — trace r.Context() → WithCancel child → errgroup ctx → worker select → driver query cancellation, naming who cancels whom at each hop
  • • "What mistakes do people make with contexts?" — missing defer cancel, using Background() inside a request, storing ctx in a struct, passing parent instead of derived ctx to children; pick two and explain the failure each causes
  • • "Your shutdown still hangs — how do you debug?" — SIGQUIT or pprof goroutine dump, classify each parked stack (channel/select, driver, mutex), fix the blocking point that lacks a Done observation

Cheat Sheet

Cancellation with context

Lifecycle:

func (s *Service) Handle(ctx context.Context, ...) error {
	ctx, cancel := context.WithCancel(ctx)
	defer cancel() // non-negotiable: success, failure, and panic
	// derive children from THIS ctx, never Background()
	return s.work(ctx)
}

Reacting:

select {
case <-ctx.Done():
	return ctx.Err() // Canceled or DeadlineExceeded
case out <- result:
	// proceed
}

Blocking-class audit:

Wait type Escape route
your channel send/receive select with ctx.Done()
stdlib/driver call *WithContext variant
infinite loop check Done each iteration
mutex / raw library block not cancellable — restructure

Boundary rules: close() = drain-then-stop on a data stream; context = stop-now across an operation tree with a reason. ctx first param, never a struct field. Report cancellation via ctx.Err() so callers can branch with errors.Is.

Continue Learning