Context: cancel the parent, the children hear it
GoByte Skills #3: One cancel() at the root closes Done on every context below it, and closing a channel wakes every waiter at once. Context is a tree, not a kill switch. Also: why a dropped stop() leaks.
Piere is one of GoByte's characters. This post was drafted by AI agents in Piere's voice, then fact checked, run and edited by the GoByte team.
context.WithCancel(parent) makes a node in a tree. Call cancel() on any node and every context below it is canceled too, so one call at the root of a request reaches all of its goroutines.
Transcript
A request context node gets three children, db, cache and rpc, whose goroutines park on Done; one cancel at the root sends a signal down every edge and each child turns to context canceled.
package main
import (
"context"
"fmt"
"sync"
)
func main() {
req, cancel := context.WithCancel(
context.Background())
names := []string{"db", "cache", "rpc"}
errs := make([]error, len(names))
var wg sync.WaitGroup
for i := range names {
ctx, stop := context.WithCancel(req)
defer stop()
wg.Add(1)
go func() {
defer wg.Done()
<-ctx.Done() // parked until canceled
errs[i] = ctx.Err()
}()
}
cancel() // one call, every child hears it
wg.Wait()
for i, n := range names {
fmt.Println(n, errs[i])
}
}
db context canceled
cache context canceled
rpc context canceled
Why it works#
WithCancel registers the new context in its parent's set of children. cancel() closes the node's Done channel, cancels each child the same way, recursively, and removes the node from its parent. Closing a channel is a broadcast: every receiver wakes, so one close reaches three goroutines or three thousand. It flows down, never up: cancel a child and the parent's Err() stays nil, so a failed cache lookup does not abort the request unless you decide it should.
Where it breaks#
Cancellation is cooperative. The context closes a channel, it kills nothing. A goroutine that never selects on ctx.Done(), or calls code that ignores its ctx, keeps running. Pass ctx as the first argument all the way down and check it in long loops.
Forgetting stop leaks#
Until its cancel function runs, a child stays registered in its parent, so a dropped one lives as long as the parent. go vet flags it: "the cancel function returned by context.WithCancel should be called, not discarded". Hence defer stop(), though a defer in a loop runs only when the function returns, so in a long loop call stop() at the end of each iteration.
Version notes#
The loop relies on Go 1.22 per-iteration loop variables; with go 1.21 in go.mod, every goroutine shares one i. Go 1.20 added WithCancelCause (so context.Cause says why) and Go 1.21 added WithoutCancel, for work that must outlive the request, like an audit log.
Rule of thumb#
Whoever creates a context cancels it, whoever receives one listens to it. Ignoring ctx.Done() is wearing noise canceling headphones to a fire drill.
Your product here? Partner with us
Back to topDiscussion
No comments yet. Signed in GoByte members with a verified e-mail can join. Community guidelines
Reading is open to everyone. Commenting and voting need a GoByte account with a verified e-mail.