2026-03-05 · 4 min read

Go context 源码阅读笔记

从 context.Context 接口到 cancelCtx 的树形传播机制,以及为什么 context 传递要遵循"父传子"的规则。

Go源码

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():
            }
        }()
    }
}

两种情况:

  1. 父是 cancelCtx 类型:把自己加进父的 children map。父取消时遍历这个 map,逐个 cancel。
  2. 父不是 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 广播信号的取消树。