skip to content

What does context.WithCancel return, and why must the cancel function always be called?

level: juniorimportance: must knowfreq 78%

answer

  1. two return values, one obligation
  2. the second value is a plain func()
  3. closing a channel is a broadcast
  4. the parent holds a reference until you call it
  5. go vet has a check named after this bug

basics

~20 s

context.WithCancel(parent) returns a derived Context plus a cancel function. Calling cancel closes that context's Done channel and detaches it from its parent. Skipping the call leaves the child attached to a long-lived parent, so its memory is retained.

solid answer

~40 s

`context.WithCancel(parent)` returns two values: a derived `Context` and a `context.CancelFunc`, which is just a `func()`. The derived context's `Done()` channel closes when you call cancel or when the parent is cancelled, whichever happens first, and from then on `ctx.Err()` reports `context.Canceled`. The cancel function is not optional. Deriving the child registers it in the parent's set of children, so until cancel runs — or the parent itself finishes — the parent keeps a reference to the child and everything the child chain retains. The idiom is `ctx, cancel := context.WithCancel(parent)` immediately followed by `defer cancel()`, so it runs on the success path as well as on errors. Cancel is idempotent and safe to call from several goroutines, and calling it after the work finished normally costs nothing.

code

go · 11 lines
go
func transcode(parent context.Context, chunks [][]byte) error {
	ctx, cancel := context.WithCancel(parent)
	defer cancel() // runs on every return path, success included

	for _, c := range chunks {
		if err := encodeChunk(ctx, c); err != nil {
			return err
		}
	}
	return nil
}

go deeper

for a junior

Be ready to write the two lines from memory: derive with context.WithCancel, then defer cancel on the next line. Say what cancel does — closes Done, sets Err to context.Canceled — and that it runs even when the function succeeds.

for a middle

Explain the mechanics behind the rule: the child is registered in the parent's children set, so an uncalled cancel means a long-lived parent retains the child. Note that cancel is idempotent and concurrency-safe.

for a senior

Show the judgment about lifetime ownership. When a derived context outlives its creating function, say who stores the cancel func and which shutdown path invokes it, and mention go vet's lostcancel as the sweep you run over a codebase.

for a principal

Frame it as a convention you enforce rather than a fact you know: cancel discipline is cheap in review and vet, expensive to retrofit once a base context in a long-lived process has accumulated children across every code path.

## What the call gives you `context.WithCancel` has this signature: ``` func WithCancel(parent Context) (ctx Context, cancel CancelFunc) ``` It takes an existing context — often `context.Background()` at the top of a program, or the context your caller handed you — and returns a **new, derived** context plus a `CancelFunc`, which is defined as `type CancelFunc func()`. The parent is untouched: cancelling the child never cancels the parent, only the other way round. The derived context is a *cancellation node* in a tree. Every context derived from it, directly or through further derivations, is a descendant, and cancelling a node closes the `Done()` channel of that node and of every descendant. ## What cancel actually does Calling cancel does exactly three things: 1. It closes the context's `Done()` channel. `Done()` returns a `<-chan struct{}`; closing it is what makes every `case <-ctx.Done():` in a `select` become ready, everywhere, at once. Closing a channel is the only broadcast primitive in Go, which is why the API is shaped this way. 2. It sets the context's error, so `ctx.Err()` — which returned `nil` before — now returns `context.Canceled`, the sentinel error value the `context` package exposes. 3. It removes the context from its parent's set of children and cancels all of its own children. What cancel does **not** do is stop anything. There is no preemption, no injected panic, no goroutine kill. Cancellation is purely advisory: the goroutines and library calls running under that context have to be looking at `ctx.Done()` (or at `ctx.Err()`) and return by themselves. Well-behaved standard-library APIs that take a context — `net/http` requests, `database/sql` queries — do this for you; a plain CPU loop you wrote does not until you make it. ## Why the call is mandatory The reason `defer cancel()` is drilled into every Go codebase is resource retention, not politeness. When you derive a cancellable child, the package walks up to the nearest cancellable ancestor and **registers the child in that ancestor's children set** so that cancelling the ancestor can reach it. That is a live reference held by the ancestor. If the ancestor is long-lived — a server's base context, a job runner's root context that lives for the process — then a child you never cancel is retained for the life of the process, along with anything reachable from it. Do that once per unit of work and you have a slow, steady memory creep that looks nothing like a classic leak: no unbounded queue, no goroutine explosion, just a graph that never shrinks. (There is a second, less common cost: if the parent is a *custom* `Context` implementation rather than one produced by this package, the package cannot register into its children set, so it starts a goroutine that waits on the parent's `Done()` channel to propagate cancellation. Calling cancel stops that goroutine; not calling it parks the goroutine for as long as the parent lives.) Calling cancel after the work completed successfully is not a mistake and does not undo anything — the results have already been produced and returned. That is precisely why `defer cancel()` on the line after the derivation is the right habit: it fires on *every* return path, including the happy one, and you never have to reason about which paths you covered. ## The shape to write ``` ctx, cancel := context.WithCancel(parent) defer cancel() ``` Two lines, always adjacent. If the derived context has to outlive the function that created it — a constructor that starts background work, for example — you cannot defer, so the cancel func becomes state the owner is responsible for: it is stored and invoked from whatever shutdown path the owner exposes. That is a deliberate design decision, not an excuse to drop it. ## Properties worth knowing - **Idempotent.** The second and later calls do nothing; the error and the closed channel are set once, under a mutex. - **Concurrency-safe.** Several goroutines may call the same cancel func at the same time. - **First cause wins.** If the parent is cancelled before you call cancel, the child is already done with `context.Canceled`; your later call is a no-op. - **Never nil.** `WithCancel` always returns a usable cancel func; there is no case where ignoring it is correct. ## How the mistake is caught `go vet` ships a `lostcancel` analyzer that runs by default. It flags a cancel value that is discarded, and a function where some return path is reachable without the cancel variable having been used. `go vet ./...` over a package is the cheap sweep for this defect across a codebase.

  • Is the cancel function safe to call twice, or from two goroutines at once?
    Yes on both counts. The implementation takes a mutex, sets the error and closes the Done channel exactly once, and later calls are no-ops. That is what makes `defer cancel()` safe even in a function that already cancelled explicitly on an error path.
  • The derived context has to outlive the function that created it. Where does cancel go then?
    It becomes state owned by whoever owns the lifetime. A constructor that starts background work stores the cancel func on the struct and invokes it from the shutdown method it exposes. You cannot defer it, but you still must have exactly one place that calls it.
  • Does calling cancel stop work already running under that context?
    No. It closes `Done()` and sets `ctx.Err()` to `context.Canceled`; that is all. Nothing is preempted. Code running under the context returns early only if it selects on `ctx.Done()` or checks `ctx.Err()`, or if it is inside a standard-library call that does so on its behalf.

Deriving the context is like signing a child onto a visitor list at reception. Cancelling signs them out. Nobody leaves on their own, and reception keeps the whole list until you do.

saying these in an interview costs you the question

  • Thinks cancel only matters when a timeout is involved
  • Says the garbage collector reclaims the child context anyway
  • Calls cancel only on the error path, not on success
  • Believes cancel forcibly kills the goroutines using the context
  • Assigns the cancel func to _ to silence the compiler