理解 cancelCtx 的源码,其实就是理解 Go 语言如何优雅地处理并发控制 和信号广播。
Go 的 context 源码在 src/context/context.go 中,代码量不多(不到 600 行),但设计非常精妙。核心逻辑可以归纳为三个关键词:挂载(Mount)、广播(Broadcast)、递归(Recursion)。
我们分三步来拆解 cancelCtx 的底层原理。
1 第一步:数据结构 ------ 它是怎么长的?
cancelCtx 是一个结构体,它继承了父 Context,同时自己维护了一套"家谱"关系。
go
// 源码简化版
type cancelCtx struct {
Context // 1. 嵌入父 Context,保证能调用父类方法
mu sync.Mutex // 2. 互斥锁,保证并发安全(保护下面的字段)
done chan struct{} // 3. 核心!用来发信号的 Channel
children map[canceler]struct{} // 4. 存自己的"孩子",以便取消时通知它们
err error // 5. 记录取消原因(Canceled 还是 DeadlineExceeded)
}
done:这是一个chan struct{}。这利用了 Go 的一个特性:当一个 channel 被 close(关闭)时,所有监听这个 channel 的 goroutine 都会收到信号(读到零值)。 这就是它的"广播"机制。children:这是一个 Set(用 Map 实现)。当父节点取消时,它需要遍历这个 Map,把所有的子节点也干掉。
2 第二步:挂载机制 ------ 如何与父节点建立联系?
当你调用 context.WithCancel(parent) 时,核心逻辑在于如何把自己"挂"到父节点的 children 列表里。
这主要依赖于内部函数 propagateCancel(传播取消)。
go
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent) // 创建自己
propagateCancel(parent, &c) // 【关键】把自己挂到父节点上
return &c, func() { c.cancel(true, Canceled) }
}
propagateCancel 的逻辑如下:
- 判断父节点状态 :如果父节点
parent.Done()已经是 nil(永远不会取消,比如Background()),那就不用挂了,因为父亲永远不死。如果父亲已经死了,直接提前cancel并返回;
go
done := parent.Done()
if done == nil {
return // parent is never canceled
}
select {
case <-done:
// parent is already canceled
child.cancel(false, parent.Err(), Cause(parent))
return
default:
}
- 查找祖先 :它会尝试找到最近的、也就是标准的
*cancelCtx类型的祖先。
go
if p, ok := parentCancelCtx(parent); ok {
// parent is a *cancelCtx, or derives from one.
p.mu.Lock()
if err := p.err.Load(); err != nil {
// parent has already been canceled
child.cancel(false, err.(error), p.cause)
} else {
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
return
}
- 判断是否实现了
afterFuncer接口:
go
if a, ok := parent.(afterFuncer); ok {
// parent implements an AfterFunc method.
c.mu.Lock()
stop := a.AfterFunc(func() {
child.cancel(false, parent.Err(), Cause(parent))
})
c.Context = stopCtx{
Context: parent,
stop: stop,
}
c.mu.Unlock()
return
}
- 挂载:
- 如果找到了亲爹(Go 标准库的 Context) :加锁,把自己塞到父亲的
childrenmap 里。
go
if a, ok := parent.(afterFuncer); ok {
// parent implements an AfterFunc method.
c.mu.Lock()
stop := a.AfterFunc(func() {
child.cancel(false, parent.Err(), Cause(parent))
})
c.Context = stopCtx{
Context: parent,
stop: stop,
}
c.mu.Unlock()
return
}
- 如果父亲是外人(比如自定义的 Context) :没办法直接操作它的 map。于是启动一个 Goroutine,在那傻傻地监听
parent.Done()。一旦父亲 Done 了,这个 Goroutine 就负责把自己干掉。
go
goroutines.Add(1)
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err(), Cause(parent))
case <-child.Done():
}
}()
3 第三步:取消机制 ------ 信号是如何传播的?
源码中的 cancel 方法(简化版逻辑):
go
// removeFromParent: 是否需要把自己从父亲的 map 中移除
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
c.mu.Lock()
if c.err != nil { // 如果已经取消过了,直接返回
c.mu.Unlock()
return
}
c.err = err // 记录错误原因
// 1. 【核心动作】关闭 channel!
// 此时,所有监听 <-ctx.Done() 的协程都会瞬间收到信号
if c.done == nil {
c.done = closedchan
} else {
close(c.done)
}
// 2. 【递归通知】遍历所有孩子,挨个调用它们的 cancel
for child := range c.children {
// 递归调用孩子的 cancel 方法
// 注意:这里传 false,意思是孩子不需要从我这里移除(因为我自己都要没了,大家一起销毁)
child.cancel(false, err)
}
c.children = nil // 释放 map,利用 GC 回收内存
c.mu.Unlock()
// 3. 【断绝关系】把自己从父亲的 map 里删掉
// 避免父亲一直持有我的引用,导致内存泄漏
if removeFromParent {
removeChild(c.Context, c)
}
}
4 总结:Context 的生命周期图解
假设有这样一个调用链:CtxA (根) -> CtxB -> CtxC。
-
建立连接:
CtxB创建时,将自己放入CtxA.children。CtxC创建时,将自己放入CtxB.children。
-
触发取消 (
CtxB.cancel()被调用):- 自身 :
CtxB关闭自己的donechannel。所有监听CtxB的代码收到停止信号。 - 向下(递归) :
CtxB遍历children,找到CtxC,调用CtxC.cancel()。于是CtxC也关闭donechannel。 - 向上(清理) :
CtxB调用removeChild,让CtxA把CtxB从 map 中删掉。
- 自身 :
5 补充
我们主动调用
cancel的时候,removeFromParent是不是传true?
回答:是的
当我们我们在业务代码中调用那个由 context.WithCancel(或 WithTimeout、WithDeadline)返回的 cancel() 函数时,传入 removeFromParent 的参数确实是 true。
这一步非常关键,它是防止 Context 树内存泄漏 的核心手段。
我们来看一下源码验证,顺便对比一下什么时候传 false。
1. 证据:源码中的入口
当你调用 context.WithCancel 时,源码是这样写的:
go
// src/context/context.go
func WithCancel(parent Context) (ctx Context, cancel CancelFunc) {
c := newCancelCtx(parent)
propagateCancel(parent, &c) // 把自己挂到父亲节点
// 【注意看这里】
// 返回的闭包函数,硬编码了 true
return &c, func() { c.cancel(true, Canceled) }
}
解析: 这里的 func() { c.cancel(true, Canceled) } 就是你拿到的那个 cancel 函数。 当你调用它时,你告诉 cancelCtx:"我已经干完活了(或者想终止了),请把我从父亲的 children 列表中移除。"
如果这里不传 true,父节点就会一直保留着指向这个子节点的引用,直到父节点自己被取消。如果父节点是一个全局的 Background 或者生命周期很长的 Context,那么这些早已死掉的子节点就会一直堆积在内存里,导致内存泄漏。
2. 对比:什么时候传 false?
在 cancel 方法内部,当它递归去取消自己的 子节点 时,传的是 false。
go
// src/context/context.go -> cancel 方法内部
// ... 前面是关闭 channel 的逻辑
// 遍历所有的孩子
for child := range c.children {
// 【注意看这里】
// 父亲取消孩子时,传的是 false
child.cancel(false, err)
}
c.children = nil // 父亲直接清空整个 map
c.mu.Unlock()
// 如果 removeFromParent 为 true,才去调用 removeChild
if removeFromParent {
removeChild(c.Context, c)
}
为什么这里传 false?这是一个性能优化的设计。
想象一下这个场景:
- 父节点取消了。
- 父节点需要通知它下面的 1000 个子节点也取消。
- 父节点遍历这 1000 个子节点,调用它们的
cancel。 - 关键点 :父节点在通知完所有孩子后,紧接着有一行
c.children = nil,它会直接把整个"孩子名册"撕碎丢进垃圾桶。
如果此时给子节点传 true,那么这 1000 个子节点每一个都会回头去抢父节点的锁(c.Context),试图从父节点的 map 中删除自己。 但在父节点看来:"你们不用一个个来辞职了,反正整个部门我都要裁掉,名单我已经准备销毁了。"
所以,传 false 避免了成百上千次无意义的锁竞争和 Map 删除操作。
总结:
- 主动调用(外部触发) :
removeFromParent = true。因为只有这一个节点要走,必须精准地从父节点的名单里删掉,防止内存泄漏。 - 级联取消(内部递归) :
removeFromParent = false。因为父节点随后会直接清空整个名单,子节点不需要自己去注销。