Go 标准库与生态 · 第一篇

context:取消信号是怎么在 goroutine 树里传播的

context.Context 用得最多的两个场景是"取消一整棵调用树"和"给一次调用设个超时"。这一篇不满足于会用 context.WithCancel/WithTimeout,而是打开 context 包的真实源码,搞清楚三件事:取消一个 context 之后,它下面挂着的那些 context 是怎么跟着一起被取消的;超时到底是靠什么机制真正触发的;还有一个容易被忽略的优化——嵌套 context 请求了一个比父级更晚的超时,会发生什么。

这一篇跟以前 K8s/MongoDB 系列不一样的地方是:本机装的就是真实 Go 1.26.6,标准库源码($GOROOT/src/context)就是本机 go run 时真正在跑的那份代码——不是从网上拉的近似版本,是完全同一份。所有实验都是本机真实执行的 Go 程序。

4 个真实 Go 程序
本机真实 go run,不是伪代码,验证传播方向、超时精度、嵌套优化
3 处真实源码
跟本机 go1.26.6 完全同版本的标准库源码,而不是网上的近似版本

真实实验:取消一个 context,它下面的全部子孙都会被取消

搭一棵真实的 context 树:parent 派生出 childAchildB,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 纹丝不动。

真实源码:传播靠的是一个 children 集合

$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 请求更晚的超时,会发生什么

父 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 不会线性叠加出一堆多余的定时器。

交互演示:一棵 context 树的取消传播

把上面全部真实实验按发生顺序串成一条演示。

context 传播实录未开始
点击"下一步"或"播放"开始。

判断逻辑由 Python 脚本按真实源码转写:传播规则(只往下走、不往上走)直接照抄 cancelCtx.cancel() 只遍历自己 children 的行为,5 个节点的最终取消状态和本机真实 go run 输出逐项断言相等;另有一套用广度优先搜索重新计算"谁是谁的子孙"的独立实现交叉核对过;嵌套超时的优化用真实测得的 101ms 和模型预测的 100ms 相互印证。

参考与说明

  • 本文源码引用(cancelCtx 结构体、cancelCtx.cancel()WithDeadlineCause 里的 time.AfterFunc 调用)均取自本机安装的 Go 1.26.6 自带的标准库源码($GOROOT/src/context/context.go)——这是本机 go run 实际执行时用的同一份代码,不是从网上拉取的某个近似版本,版本号 100% 对应。
  • 全部实验(propagate.goupward.gotimeout.goshorter_deadline.go)均为本机真实 go run 产生的输出,未做删改,包括真实测得的毫秒级触发时间。
  • 演示数据的自检:context 树的传播结果和本机真实程序输出逐项断言相等;用广度优先搜索独立重新计算过一遍"取消会波及谁",跟递归 cancel() 的结果一致;嵌套超时优化的模型预测值(100ms)和真实测量值(101ms)相差在几毫秒内,吻合。
  • 没有涉及:context.WithValue 的实现与查找链、context.AfterFunc(Go 1.21 新增的回调注册机制)、select 语句里同时监听 ctx.Done() 和业务 channel 的常见模式细节。
☕ 如果这篇文章帮到你,可以请作者喝杯咖啡 · 爱发电