Go context 源码阅读笔记
从 context.Context 接口到 cancelCtx 的树形传播机制,以及为什么 context 传递要遵循"父传子"的规则。
context.Context 是 Go 里被吐槽最多也被用得最多的标准库之一。它到底是怎么工作的?这篇文章把源码里几个关键机制梳理一遍。
Context 是什么
从接口定义看,Context 非常简单:
type Context interface {
Deadline() (deadline time.Time, ok bool)
Done() <-chan struct{}
Err() error
Value(key any) any
}
四个方法,分别对应:截止时间、取消信号、错误原因、携带的值。核心是 Done() 返回的 channel——这是 Go 实现级联取消的基础。
树形结构与传播
Context 的精髓在于它是树形的。每个 context 都有一个父 context(除了根 context.Background())。当父 context 被取消,所有子 context 也会被取消。
Background()
└── ctx1 (WithCancel)
├── ctx2 (WithTimeout)
└── ctx3 (WithValue)
调用 cancel(ctx1) 会级联取消 ctx2、ctx3。这个机制是怎么实现的?
cancelCtx 的实现
看 cancelCtx 的核心字段:
type cancelCtx struct {
Context
mu sync.Mutex
done atomic.Value // chan struct{}
children map[canceler]struct{}
err error
}
关键在 children 这个 map——它记录了所有"可取消的子 context"。
父子如何建立关联
当我们调用 context.WithCancel(parent) 时,内部会调用 propagateCancel:
func propagateCancel(parent Context, child canceler) {
// ...
if p, ok := parentCancelCtx(parent); ok {
p.mu.Lock()
if p.err != nil {
// 父已经被取消了,直接取消子
child.cancel(false, p.err)
} else {
// 把子加入父的 children map
if p.children == nil {
p.children = make(map[canceler]struct{})
}
p.children[child] = struct{}{}
}
p.mu.Unlock()
} else {
// 父没有实现取消机制,起一个 goroutine 监听
go func() {
select {
case <-parent.Done():
child.cancel(false, parent.Err())
case <-child.Done():
}
}()
}
}
两种情况:
- 父是 cancelCtx 类型:把自己加进父的
childrenmap。父取消时遍历这个 map,逐个 cancel。 - 父不是 cancelCtx(比如自定义 Context):起一个 goroutine 监听父的 Done,父取消时再取消子。
级联取消
当调用 cancel() 时:
func (c *cancelCtx) cancel(removeFromParent bool, err error) {
// ...
c.mu.Lock()
c.err = err
close(c.done) // 关闭 done channel,所有监听者收到信号
for child := range c.children {
child.cancel(false, err) // 递归取消所有子
}
c.children = nil
c.mu.Unlock()
if removeFromParent {
removeChild(c.Context, c) // 从父的 children 里移除自己
}
}
注意 close(c.done)——关闭 channel 是广播操作,所有 <-ctx.Done() 的 goroutine 都会同时收到。这就是为什么用 channel 而不是普通变量来传递取消信号。
为什么要"父传子"
理解了上面的机制,就明白为什么 Go 强调 context 要从父传到子、不要存到结构体里当字段。
// ❌ 错误:把 context 存起来
type Service struct {
ctx context.Context
}
// ✅ 正确:每次调用显式传递
func (s *Service) Do(ctx context.Context) { ... }
如果存到结构体,这个 context 的生命周期就和请求脱钩了——请求结束该取消的没取消,goroutine 泄漏。或者不同请求复用了同一个旧 context,取消信号乱串。
context 的生命周期应该和请求一一对应,这是它能正确工作的前提。
几个实用细节
context.TODO()是占位符,表示"我还没想好该用哪个 context",不要在生产代码里留。WithValue不要滥用。它本质是链表查找,层数多了有性能损耗,而且类型不安全。只传请求级的必要元数据(traceID、userID),别拿来传业务参数。- 手动 cancel 别忘调。
WithCancel/WithTimeout返回的 cancel 函数必须调用,否则子 context 和它的 children 无法被 GC,会泄漏。defer 一下最稳:
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel() // 别忘
读一遍源码,context 就没那么神秘了——它就是一棵用 map 串起来的、靠 close channel 广播信号的取消树。