context.Context 用得最多的两个场景是"取消一整棵调用树"和"给一次调用设个超时"。这一篇不满足于会用 context.WithCancel/WithTimeout,而是打开 context 包的真实源码,搞清楚三件事:取消一个 context 之后,它下面挂着的那些 context 是怎么跟着一起被取消的;超时到底是靠什么机制真正触发的;还有一个容易被忽略的优化——嵌套 context 请求了一个比父级更晚的超时,会发生什么。
这一篇跟以前 K8s/MongoDB 系列不一样的地方是:本机装的就是真实 Go 1.26.6,标准库源码($GOROOT/src/context)就是本机 go run 时真正在跑的那份代码——不是从网上拉的近似版本,是完全同一份。所有实验都是本机真实执行的 Go 程序。
搭一棵真实的 context 树:parent 派生出 childA、childB,childA 再派生出 grandchild;另外单独起一棵毫不相关的 sibling 树。
parent, cancelParent := context.WithCancel(context.Background()) childA, _ := context.WithCancel(parent) childB, _ := context.WithCancel(parent) grandchild, _ := context.WithCancel(childA) sibling, _ := context.WithCancel(context.Background()) // 完全独立的另一棵树 cancelParent()after cancelParent(): parent.Err() = context canceled after cancelParent(): childA.Err() = context canceled after cancelParent(): childB.Err() = context canceled after cancelParent(): grandchild.Err() = context canceled after cancelParent(): sibling.Err() = <nil> (unrelated tree, must stay nil) confirmed: sibling.Done() did NOT fire within 50ms of cancelParent()
只调用了一次 cancelParent(),四层关系(parent → childA → grandchild,以及 childB)全部被取消,完全不需要手动去取消每一个子 context;而毫不相关的 sibling 纹丝不动。
$GOROOT/src/context/context.go(go1.26.6 本机源码) · L429 // A cancelCtx can be canceled. When canceled, it also cancels any children // that implement canceler. type cancelCtx struct { Context mu sync.Mutex done atomic.Value // of chan struct{}, created lazily, closed by first cancel call children map[canceler]struct{} // set to nil by the first cancel call err atomic.Value cause error }
$GOROOT/src/context/context.go(go1.26.6 本机源码) · L549 func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) { ... c.err.Store(err) ... close(d) // 关闭 Done() 返回的那个 channel for child := range c.children { // NOTE: acquiring the child's lock while holding parent's lock. child.cancel(false, err, cause) } c.children = nil c.mu.Unlock() ... }
每个 cancelCtx 自己维护一个 children 集合——WithCancel(parent) 派生子 context 时,子 context 会把自己注册进父 context 的这个集合。cancel() 做的事情很直白:关掉自己的 Done() channel,然后遍历自己的 children 集合,对每一个都调一次 child.cancel()——如果这个子节点自己也有 children,这一步会继续递归下去。整个过程只往下走,从来不会去看 c.Context(也就是父节点)。
parent, _ := context.WithCancel(context.Background()) childA, cancelChildA := context.WithCancel(parent) childB, _ := context.WithCancel(parent) cancelChildA()childA.Err() = context canceled (expected: context canceled) confirmed: parent.Done() did NOT fire -- cancellation does not propagate upward confirmed: childB.Done() did NOT fire -- cancellation does not propagate sideways
跟上面源码里看到的完全对得上:cancel() 只会走 c.children,从来不会去碰 c.Context(父节点引用)。取消一个子 context,影响范围严格限定在"这个子 context 自己和它下面的子孙",既不会波及父节点,也不会波及兄弟节点。
$GOROOT/src/context/context.go(go1.26.6 本机源码) · L649 c.mu.Lock() defer c.mu.Unlock() if c.err.Load() == nil { c.timer = time.AfterFunc(dur, func() { c.cancel(true, DeadlineExceeded, cause) }) }
没有什么后台轮询在检查"是不是超时了"——就是一个普普通通的 time.AfterFunc,到点了直接调用跟上面手动取消完全同一个 cancel() 函数,只是这次传的错误是 DeadlineExceeded 而不是 Canceled。之后的传播逻辑跟手动取消一模一样——超时本质上就是"定时器帮你按了一次取消"。
ctx1, _ := context.WithTimeout(context.Background(), 150*time.Millisecond) start := time.Now() <-ctx1.Done() fmt.Println(time.Since(start), ctx1.Err())case 1: Done() fired after 151ms (requested 150ms) case 1: ctx1.Err() = context deadline exceeded case 1: errors.Is(ctx1.Err(), context.DeadlineExceeded) = true // 换成提前手动取消: case 2: ctx2.Err() = context canceled case 2: errors.Is(ctx2.Err(), context.Canceled) = true
请求 150ms,真实触发时间是 151ms——跟 time.AfterFunc 的精度对得上。更重要的是 errors.Is 能准确区分"是超时导致的取消"还是"是手动调用 cancel 导致的取消"——这在实际代码里很关键:同样是 ctx.Done() 触发,上层应该给用户返回"操作超时"还是"操作被取消",要靠这个区分。
父 context 已经有一个 100ms 的超时,子 context 又请求了一个 5 秒的超时——子 context 的这个 5 秒请求,实际上没有意义。
parent, _ := context.WithTimeout(context.Background(), 100*time.Millisecond) child, _ := context.WithTimeout(parent, 5*time.Second) // 比父级晚得多 start := time.Now() <-child.Done() fmt.Println(time.Since(start))child.Done() fired after 101ms child.Err() = context deadline exceeded (child requested a 5s deadline, but fired at ~100ms -- it rode the parent's sooner deadline)
child 请求的是 5 秒后超时,真实触发时间却是 101ms——因为源码里有一个判断:if cur, ok := parent.Deadline(); ok && cur.Before(d) { return WithCancel(parent) },子级发现父级的截止时间比自己请求的更早,干脆不新建一个定时器,直接退化成一个普通的 WithCancel(parent)——反正父级的定时器到点了,自己也一定会被连带取消,没必要多起一个永远不会真正触发的定时器。这是一个很小但很实际的优化:嵌套 WithTimeout 不会线性叠加出一堆多余的定时器。
把上面全部真实实验按发生顺序串成一条演示。
判断逻辑由 Python 脚本按真实源码转写:传播规则(只往下走、不往上走)直接照抄 cancelCtx.cancel() 只遍历自己 children 的行为,5 个节点的最终取消状态和本机真实 go run 输出逐项断言相等;另有一套用广度优先搜索重新计算"谁是谁的子孙"的独立实现交叉核对过;嵌套超时的优化用真实测得的 101ms 和模型预测的 100ms 相互印证。