Context Cancel Trees Internals
Context Cancel Trees Internals
Overview
context.Context is a tree of cancellation and values. Understanding parent→child propagation explains goroutine leaks, deadline bugs, and why context.Background() at random depths is an outage pattern.
Diagram: Cancel flows down
parent ctx
├── child (timeout)
└── child (values)
cancel(parent) ──► cancels all children
cancel(child) ──► does NOT cancel parent
Tree Shape
Background
└─ WithValue(request_id)
└─ WithTimeout(2s) <- parent cancel cancels children
├─ WithCancel <- child cancel does NOT cancel parent
└─ WithTimeout(500ms)
Rules:
- Cancel/timeout flows down.
- Child cancel does not cancel siblings/parent.
- Values flow by lookup up the parent chain.
- Always
defer cancel()for WithCancel/Timeout/Deadline.
Implementation Intuition
Each cancelable context holds:
- A done channel (closed on cancel)
- Parent linkage / children set
- Optional timer for deadlines
- Optional cause error (
WithCancelCause)
Closing parent walks children. This is why leaking cancel funcs can retain subtrees longer than you expect.
Values
type key struct{} // unexported key type avoids collisions
ctx = context.WithValue(ctx, key{}, rid)Do not store optional params that belong in function arguments. Values are for request-scoped data (IDs, loggers), not control flow.
Error Taxonomy
ctx.Err() == context.Canceled
ctx.Err() == context.DeadlineExceeded
// WithCancelCause: context.Cause(ctx)Map these to client retries carefully — cancel from client disconnect ≠ retryable server error necessarily.
Experiment
go mod init example
go run .package main
import (
"context"
"fmt"
"time"
)
func main() {
parent, cancel := context.WithCancel(context.Background())
defer cancel()
child, cancelChild := context.WithTimeout(parent, 50*time.Millisecond)
defer cancelChild()
stop := make(chan struct{})
go func() {
<-child.Done()
fmt.Println("child", child.Err())
close(stop)
}()
<-stop
cancel() // parent cancel after child already done
fmt.Println("parent", parent.Err())
// child cancel does not kill parent
p2, c2 := context.WithCancel(context.Background())
ch, c3 := context.WithCancel(p2)
c3()
fmt.Println("child2", ch.Err(), "parent2", p2.Err())
c2()
}What to notice: Timeout sets child error first; canceling a child leaves parent active until its own cancel.
Try next: Pass context.WithoutCancel(ctx) (Go 1.21+) when you must detach a background job intentionally — and document why.