Context: Cancellation Trees, Deadlines and Causes
Objective
A context.Context is a node in a tree: a child registers with its nearest cancelable ancestor,
a cancel closes Done on every descendant, and nothing flows back up. This lesson measures that
tree: cancel fan-out, what an uncalled cancel keeps alive, Err versus Cause, AfterFunc
and WithoutCancel (Go 1.21), and what the server sees when a client gives up.
All output comes from Go 1.27.0 (GOTOOLCHAIN=go1.27.0, module line go 1.27), standard
library only, on an Apple M4 Max with macOS (darwin/arm64). Run counts are given per result.
Use Cases
- A request handler that must stop database calls and RPCs the moment the client disconnects.
- A per-call budget (
WithTimeout) under a per-request budget, where the shorter one wins. - Telling "the deadline passed" apart from "something upstream failed" in logs, via
Cause. - Unblocking an API that ignores contexts (a
net.Conn, async.Cond) withAfterFunc. - Audit writes or cache fills that must finish after the response is sent (
WithoutCancel).
Deep Dive
The tree: cancel flows down, never up
goroot, cancelRoot := context.WithCancel(context.Background())
a, cancelA := context.WithTimeout(root, time.Hour)
b := context.WithValue(root, reqIDKey{}, "r-42")
c, cancelC := context.WithCancel(b)
defer cancelC()
fmt.Println(c)
cancelA()
fmt.Printf("after cancelA: root=%v a=%v c=%v\n", root.Err(), a.Err(), c.Err())
cancelRoot()
fmt.Printf("after cancelRoot: root=%v c=%v\n", root.Err(), c.Err())textcontext.Background.WithCancel.WithValue(main.reqIDKey, r-42).WithCancel after cancelA: root=<nil> a=context canceled c=<nil> after cancelRoot: root=context canceled c=context canceled
WithValue cannot be canceled, so c joins root's children set. In the 1.27 source,
cancelCtx.cancel closes its channel, then cancels each child under its lock: the fan-out is
synchronous. A parent with N direct children, cancel() timed, children checked (5 runs):
| children | cancel() took |
closed when cancel returned |
|---|---|---|
| 1,000 | 30 to 40 ยตs | 1,000 |
| 10,000 | 0.26 to 0.38 ms | 10,000 |
| 100,000 | 3.4 to 4.8 ms | 100,000 |
Linear, 30 to 50 ns per child, but only with standard library parents. Under a hand-rolled
Context with its own Done channel and no AfterFunc method, propagateCancel starts a
watcher goroutine per child: 1,000 children added 1,000 goroutines (0 under WithCancel).
Deadline versus cancel, and naming the reason
go// cancel funcs are deferred in the real program; discarded here to save lines
d, _ := context.WithTimeout(bg, 10*time.Millisecond) // <-d.Done(), then print d.Err()
parent, cancelP := context.WithCancelCause(bg)
child, _ := context.WithTimeout(parent, time.Hour)
cancelP(errUpstream) // print child.Err() and context.Cause(child)
p2, cancelP2 := context.WithCancelCause(bg)
k2, cancelK2 := context.WithCancelCause(p2)
cancelK2(errors.New("child gave up first"))
cancelP2(errUpstream) // print both causes
t, _ := context.WithTimeoutCause(bg, 5*time.Millisecond, errSLO) // <-t.Done()texttimeout: context deadline exceeded true true child Err: context canceled child Cause: upstream closed the stream parent cause: upstream closed the stream | child cause: child gave up first timeout cause: context deadline exceeded | checkout SLO budget (50ms) spent
Line 1 is Err(), errors.Is(err, context.DeadlineExceeded) and its Timeout() method. Err
only returns one of the two sentinels, so existing checks keep working; Cause carries the real
reason to every descendant. The first cancellation wins: a child canceled before its parent
keeps its own cause. Cause is nil before cancellation and cancel(nil) makes it
context canceled. WithCancelCause arrived in Go 1.20, the deadline variants in 1.21.
AfterFunc and its stop function
gofunc readCtx(ctx context.Context, conn net.Conn, buf []byte) (int, error) {
stop := context.AfterFunc(ctx, func() { conn.SetReadDeadline(time.Now()) })
n, err := conn.Read(buf)
if !stop() && ctx.Err() != nil { // f already started: report the context error
return n, ctx.Err()
}
return n, err
}textstop before cancel: true stop after cancel: false read returned after 30ms: context deadline exceeded
AfterFunc runs f in its own goroutine once ctx is done. stop() before the cancel returned
true and f never ran; after it, false. Per the docs, stop does not wait for f to finish.
Read itself failed with read pipe: i/o timeout, mapped to the context error, at 30 ms (4 of 4
runs). 1,000 AfterFunc registrations on a standard context added 0 goroutines; 1,000
go func(){ <-ctx.Done() }() watchers added 1,000.
WithoutCancel: work that outlives the request
net/http cancels r.Context() when ServeHTTP returns, so an audit goroutine dies with it:
goctx := context.WithValue(r.Context(), userKey{}, "ana")
go audit(ctx, "request ctx ", done)
bg, cancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
go func() { defer cancel(); audit(bg, "WithoutCancel", done) }()
w.WriteHeader(http.StatusAccepted) // handler returnstextWithoutCancel: Done==nil: true deadline: false request ctx : aborted, context canceled WithoutCancel: written for ana
The detached context keeps the values (the user, a trace ID) but has no deadline, no Err and
a nil Done (2 of 2 runs), so always put a fresh WithTimeout on top of it.
WithValue: key hygiene and lookup cost
Two packages that both use the string "user" as a key silently overwrite each other:
goctx := auth.WithUser(context.Background(), "u-1001") // WithValue(ctx, "user", id)
ctx = tenant.WithTenant(ctx, "acme") // WithValue(ctx, "user", name)
fmt.Println("auth.User sees:", auth.User(ctx))
// fix inside package tenant: type key struct{}; WithValue(ctx, key{}, name)textauth.User sees: acme auth.User sees: u-1001 | tenant: acme
go vet printed nothing for the string key. The WithValue docs ask for an unexported key
type, ideally struct{} so storing it in an interface{} does not allocate, plus typed
accessors. Lookup walks the chain node by node (b.Loop benchmark, key at the root, 3 runs):
| chain depth | 1 | 10 | 100 |
|---|---|---|---|
| ns/op | 2.54 to 2.58 | 19.6 to 19.9 | 183 to 186 |
Forgetting cancel: what vet says and what it costs
gofunc Discarded(parent context.Context) context.Context {
ctx, _ := context.WithTimeout(parent, time.Second)
return ctx
}
func OnePath(parent context.Context, fast bool) error {
ctx, cancel := context.WithCancel(parent)
if fast {
return nil
}
defer cancel()
<-ctx.Done()
return ctx.Err()
}textvetdemo/vetdemo.go:9:7: the cancel function returned by context.WithTimeout should be called, not discarded, to avoid a context leak vetdemo/vetdemo.go:14:2: the cancel function is not used on all paths (possible context leak) vetdemo/vetdemo.go:16:3: this return statement may be reached without using the cancel var defined on line 14
The cost shows under a long-lived parent: 100,000 calls of WithTimeout(appCtx, time.Hour):
textcancel called=false: +29.5 MB live heap after the parent is canceled: +6.1 MB live heap cancel called=true: +0.0 MB live heap
Live heap after two GCs: about 300 bytes per forgotten child, held by the parent's children set
until the parent is canceled (29.5 MB in 2 of 2 runs, residue 1.8 and 6.1 MB after it). go test
passed on the vet demo package: lostcancel is not in the vet subset go test runs.
Passing ctx to an HTTP call and to a select
goctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
defer cancel()
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, srv.URL+"/slow", nil)
_, err := http.DefaultClient.Do(req)
select { // in the server handler
case <-time.After(2 * time.Second): // slow dependency
case <-r.Context().Done(): // record r.Context().Err()
}textclient after 100ms: Get "http://127.0.0.1:55541/slow": context deadline exceeded Is DeadlineExceeded: true server: context canceled after 100ms
Same result in 3 of 3 runs. The deadline does not cross the wire: the server only sees the
connection close, so its context reports Canceled, not DeadlineExceeded. Any blocking
select in the handler needs a ctx.Done() case, or the work keeps running for nobody.
Why a context does not belong in a struct
gotype Client struct{ ctx context.Context } // built during request 1, then cached
func (c *Client) Fetch(id int) error { /* checks c.ctx.Err() */ }
func FetchCtx(ctx context.Context, id int) error { /* checks ctx.Err() */ }textrequest 1: <nil> request 2: fetch 2: context canceled | request 2 own ctx: <nil> request 2 with FetchCtx: <nil>
Request 2 inherited request 1's dead context. The package docs say to pass ctx as the first
parameter; the blog post "Contexts and structs" adds that a stored context hides its lifetime
and mixes scopes. Its one exception, backward compatibility, is why http.Request has one.
Trade-offs
- Synchronous fan-out: cancel is linear in descendants, under the parent's lock (4.8 ms for 100,000). Prefer a tree that mirrors the call graph over one huge flat parent.
- Cause versus Err:
Errstays one of two sentinels for compatibility. Logcontext.Cause, branch onerrors.Is(err, context.DeadlineExceeded). - Values are not parameters: lookups are linear (183 ns at depth 100) and untyped. Use them for request-scoped data that crosses APIs (trace IDs, auth), never for optional arguments.
- WithoutCancel needs a new bound: it drops the deadline as well as the cancel. Without a
fresh
WithTimeoutthe detached work can run forever. - Server sees Canceled: a client deadline arrives as a closed connection. Send the remaining budget explicitly (a request header, for example) if the server must know it was a timeout.
Documentation Links
- Package context
- Go Concurrency Patterns: Context, The Go Blog
- Contexts and structs, The Go Blog
- Go 1.21 Release Notes: AfterFunc, WithoutCancel, WithDeadlineCause
- Go 1.20 Release Notes: WithCancelCause and Cause
- lostcancel analyzer, golang.org/x/tools
- net/http: Request.Context
- 100 Go Mistakes and How to Avoid Them, Teiva Harsanyi (Manning)