在 Go 里写过一个稍微像样的服务,就一定绕不开 context。它贯穿每一次 HTTP 请求处理、每一次 RPC 调用、每一次数据库查询。标准库的 net/httpdatabase/sqlgrpc-go 都要求把 context.Context 作为第一个参数透传。可以说 context 是 Go 并发控制的基石。

context 也是 Go 里最容易被误用的部分。我们在线上排查过不少莫名其妙的问题——请求已经返回了、下游却还在收到调用;某个超时怎么都触不发;一个缓存的值怎么也读不到——最终都指向 context 用错了。这篇文章我们直接从源码层面拆开 context,看清它的取消传播、超时与值传递到底是怎么运作的,再总结四类工程上反复出现的误用模式。

一、Context 接口的四个方法

整个 context 包的核心只有一个接口,定义在 src/context/context.go

type Context interface {
    // Deadline 返回该 context 被取消的时间点,以及是否设置了截止时间
    Deadline() (deadline time.Time, ok bool)

    // Done 返回一个 channel,context 被取消或超时时它会被 close
    Done() <-chan struct{}

    // Err 返回 context 被取消的原因,未取消时返回 nil
    Err() error

    // Value 沿 context 树向上查找与 key 关联的值
    Value(key any) any
}

这四个方法各有分工:DeadlineDone 负责取消语义(前者告诉你“什么时候”,后者告诉你“发生了”);Err 负责告诉你在取消后原因是什么,是 Canceled 还是 DeadlineExceededValue 负责请求作用域的值传递。注意,这是一个只读接口——你无法通过 Context 本身去“设置”取消或值,只能通过 With 系列函数派生出新的 Context。这正是它线程安全的关键:派生产生新对象,原对象永不变更。

包里提供了几个根 context:

var (
    backgroundCtx = &emptyCtx{}
    todoCtx       = &emptyCtx{}
)

func Background() Context { return backgroundCtx }
func TODO() Context       { return todoCtx }

Background() 是所有 context 树的根,永远不会被取消、没有值,通常用在 main、初始化以及与 runtime 交互的顶层入口里。则是一个明确的过渡标记——当你还不确定该用哪个 context,但又需要凑齐函数签名时用它,静态分析工具(比如 go vet 配合 contextcheck)会帮你揪出所有过渡上下文调用点,提醒后续补上真正的 context。

二、cancelCtx:取消是如何传播的

WithCancel 返回的核心结构是 cancelCtx。这是整个 context 体系里最需要看懂的一块:

type cancelCtx struct {
    Context

    mu       sync.Mutex            // 保护下面字段
    done     atomic.Value          // chan struct{}, 懒初始化
    children map[canceler]struct{} // 子 context 集合
    err      error                 // 取消原因
    cause    error                 // (Go 1.20+)取消根因
}

三个要点:

  1. done 从 Go 1.21 起改为 atomic.Value 懒初始化,只有第一次调用 Done() 时才真正创建 channel,避免提前派生时白白分配。
  2. children 是一个以 canceler 接口实例为 key 的 map,记录了所有“应该跟着我一起取消”的子 context。
  3. err/cause 区分了“取消原因”和“取消根因”,WithCancelCause 引入后者是为了让上层能拿到引发级联取消的最原始错误。

2.1 派生时如何挂到父节点上

当你 WithCancel(parent) 时,内部会调用 propagateCancel(parent, child)。它的逻辑可以用下面这段精简后的伪代码表达:

func (c *cancelCtx) propagateCancel(parent Context, child canceler) {
    c.Context = parent
    done := parent.Done()
    if done == nil {
        return // 父节点(如 Background)永远不会取消,无需挂载
    }

    select {
    case <-done:
        // 父节点已经取消了,立即把子节点也取消掉
        child.cancel(false, parent.Err(), Cause(parent))
        return
    default:
    }

    if p, ok := parentCancelCtx(parent); ok {
        // 父节点是标准的 cancelCtx,挂进它的 children map
        p.mu.Lock()
        if p.err != nil {
            child.cancel(false, p.err, p.cause)
        } else {
            if p.children == nil {
                p.children = make(map[canceler]struct{})
            }
            p.children[child] = struct{}{}
        }
        p.mu.Unlock()
    } else {
        // 父节点是自定义实现,启动一个 goroutine 监听
        // ...
    }
}

这里有一个非常容易忽视的细节:children map 的插入只在父节点还没取消时才发生。如果父节点已经取消,子节点会在派生时就立刻被取消,根本进不了 map——这点后面讲误用时还会用到。

2.2 取消动作本身

cancelCtx.cancel 是真正干活的函数。它的逻辑极其克制:

func (c *cancelCtx) cancel(removeFromParent bool, err, cause error) {
    c.mu.Lock()
    if c.err != nil {
        c.mu.Unlock()
        return // 已经取消过了,幂等
    }
    c.err = err
    c.cause = cause

    d, _ := c.done.Load().(chan struct{})
    if d == nil {
        c.done.Store(closedchan) // 没人订阅过,直接存预创建的 closedchan
    } else {
        close(d)                 // 关键:close channel 是唯一的“通知”动作
    }

    for child := range c.children {
        // 递归把所有子节点取消,注意传 false,避免每个子节点都去父节点删自己
        child.cancel(false, err, cause)
    }
    c.children = nil
    c.mu.Unlock()

    if removeFromParent {
        removeChild(c.Context, c) // 把自己从父节点的 children 中移除
    }
}

整段代码的核心只有一行:close(d)。Go 的 channel 机制保证了 close 是线程安全且对所有接收方可见的——所有 select { case <-ctx.Done(): ... } 的阻塞者会同时被唤醒。这正是 context 取消能做到“一次 close,千处响应”的原因。它没有用条件变量、没有用广播锁,channel 的 close 语义本身就是最高效的广播机制

三、timerCtx:超时的实现

WithTimeoutWithDeadline 本质上是同一回事——WithTimeout(parent, d) 就是 WithDeadline(parent, time.Now().Add(d))。它们返回的是 timerCtx

type timerCtx struct {
    cancelCtx
    timer    *time.Timer // 底层定时器
    deadline time.Time
}

timerCtx 嵌入了 cancelCtx,所以它天然具备取消传播能力。它额外做的事是:在派生时调用 time.AfterFunc(d, func() { c.cancel(true, DeadlineExceeded, ...) }),把“到点自动取消”注册到底层 runtime timer 上。这样定时器到期时,会自动触发一次和手动 cancel() 完全等价的流程。

这里有一个性能上的小优化值得知道:WithDeadline 会判断距离 deadline 还剩多久。如果已经过期或剩余时间极短,它会直接同步 cancel,不再去 time.AfterFunc 开一个根本来不及触发的 timer,省掉一次 goroutine 调度。所以你不必担心“传 1 毫秒超时”会创建无用的定时器。

四、valueCtx:链式查找的代价

WithValue 返回的是一个链表节点:

type valueCtx struct {
    Context
    key, val any
}

它的 Value 方法实现是一个自底向上的线性查找:

func (c *valueCtx) Value(key any) any {
    if c.key == key {
        return c.val
    }
    return c.Context.Value(key) // 沿父链继续往上找
}

这意味着每取一次值都是 O(链深度) 的查找。这也是为什么官方反复强调:context 只用来传递请求作用域的元数据(traceID、tenantID、认证身份),不要用来传业务参数。把几百个 key 全塞进 context,链上查找的常数开销会悄悄吃掉你不少 CPU,而且让代码变得无法静态追踪——你根本不知道一个值是谁放进去的。

五、四类常见工程误用

下面四类误用,是我们团队 code review 里出现频率最高的、也都是真实吃过亏的。

5.1 把 context 存进 struct 字段

错误写法:

type Worker struct {
    ctx context.Context // 千万别这么干
    // ...
}

func (w *Worker) Run() {
    for {
        select {
        case <-w.ctx.Done():
            return
        // ...
        }
    }
}

func NewWorker(ctx context.Context) *Worker {
    return &Worker{ctx: ctx}
}

问题在于:context 的生命周期应当跟随请求,而 struct 往往生命周期更长(比如被复用、被放进 pool)。把请求级 context 锁死在 struct 里,意味着这个 Worker 被请求 A 创建后,请求 A 结束、ctx 被取消,Worker 就废了;要是 Worker 被复用,更是灾难。正确做法是把 context 作为方法参数显式传递:

type Worker struct {
    // 不再持有 context
}

func (w *Worker) Run(ctx context.Context) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        // ...
        }
    }
}

官方在 context 包文档里写得很明确:“Do not store Contexts inside a struct type; instead, pass a Context explicitly to each function that needs it.”

5.2 传入 nil context

错误写法:

func FetchUser(id int) (*User, error) {
    // 直接传 nil,图省事
    return fetchUserFromDB(nil, id)
}

func fetchUserFromDB(ctx context.Context, id int) (*User, error) {
    // 取值时 panic:nil pointer dereference
    traceID := ctx.Value(traceIDKey)
    // ...
}

对一个 nil 接口调用方法会直接 panic,没有任何恢复余地。在生产环境里这种崩溃通常出现在冷门分支,QA 根本测不到。正确做法是用 context.TODO() 顶替(语义清晰、静态工具能识别),或者干脆把 ctx 加到调用方的签名里,让 context 一路透传:

func FetchUser(ctx context.Context, id int) (*User, error) {
    return fetchUserFromDB(ctx, id)
}

5.3 用 context 传业务参数

错误写法:

ctx = context.WithValue(ctx, "userID", uid)
ctx = context.WithValue(ctx, "amount", amount)
ctx = context.WithValue(ctx, "currency", "CNY")
processOrder(ctx)

func processOrder(ctx context.Context) {
    uid := ctx.Value("userID").(int64)
    amount := ctx.Value("amount").(float64)
    // ...
}

这把 context 当成了全局变量口袋。问题有三:第一,类型断言 .(int64) 一旦值缺失或类型不符直接 panic;第二,链式查找是 O(n),参数一多性能下降;第三,最致命的是可读性归零——签名上看不出 processOrder 依赖什么,IDE 也跳不过去,重构时无从下手。正确做法:业务参数老老实实写成结构体参数。context 只保留真正需要跨层透传、且不参与业务逻辑的元数据,且必须用自定义未导出类型作为 key,避免冲突:

type ctxKey int
const (
    traceIDKey ctxKey = iota
    tenantKey
)

// 仅放请求作用域的元数据
ctx = context.WithValue(ctx, traceIDKey, "req-abc-123")
ctx = context.WithValue(ctx, tenantKey, "tenant-7")

// 业务参数走正经的函数签名
processOrder(ctx, order{
    UserID:   uid,
    Amount:   amount,
    Currency: "CNY",
})

5.4 启动 goroutine 后立刻 cancel 导致子任务丢失

这是一个非常隐蔽的误用。错误写法:

func (s *Service) Batch(ctx context.Context, ids []int) {
    for _, id := range ids {
        ctx, cancel := context.WithCancel(ctx) // 每个循环新建
        go func(id int) {
            defer cancel()
            s.processOne(ctx, id) // 异步处理
        }(id)
        // 循环立刻进入下一轮,cancel 还没被调用
    }
}

这段看起来没问题,但换个写法就出大事:

func (s *Service) FanOut(ctx context.Context, ids []int) {
    for _, id := range ids {
        ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
        go s.processOne(ctx, id)
        cancel() // 错!goroutine 刚启动就被取消了
    }
}

由于 cancel() 紧跟在 go 后面同步执行,而 processOne 还没来得及跑——根据前面讲过的 propagateCancel 机制,父节点一 cancel,子节点立刻进入取消态。processOne 真正开始执行时,ctx.Err() 已经是 Canceled,它的第一个 select { case <-ctx.Done() } 或带 context 的下游调用会瞬间返回错误,任务被静默丢弃。

这种 bug 极其难查:没有 panic,没有日志,就是任务“没干成”,生产环境表现为数据缺失、消息没发出。正确做法是把 cancel 的生命周期交给 goroutine 内部管理:

func (s *Service) FanOut(ctx context.Context, ids []int) {
    var wg sync.WaitGroup
    for _, id := range ids {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            ctx, cancel := context.WithTimeout(ctx, 30*time.Second)
            defer cancel() // 在 goroutine 退出时再取消,确保子任务生命周期完整
            s.processOne(ctx, id)
        }(id)
    }
    wg.Wait()
}

六、context 与 errgroup 配合做并发错误处理

当多个并发子任务需要“任意一个失败就全部取消”的语义时,errgroup 是最顺手的工具。它的核心是 WithContext:返回的 ctx 派生自传入的 parent,errgroup 内部会在第一个 goroutine 返回非 nil error 时,立即 cancel 这个 ctx。结合前面讲的取消传播,所有正在跑的子任务会立刻收到 Done 信号并退出。

import "golang.org/x/sync/errgroup"

func (s *Service) Aggregate(ctx context.Context) (*Report, error) {
    g, gctx := errgroup.WithContext(ctx)

    var (
        profile *Profile
        orders  []Order
        coupons []Coupon
    )

    g.Go(func() error {
        var err error
        profile, err = s.fetchProfile(gctx)
        return err
    })
    g.Go(func() error {
        var err error
        orders, err = s.fetchOrders(gctx)
        return err
    })
    g.Go(func() error {
        var err error
        coupons, err = s.fetchCoupons(gctx)
        return err
    })

    if err := g.Wait(); err != nil {
        return nil, err // 拿到第一个出错的子任务的 error
    }
    return buildReport(profile, orders, coupons), nil
}

这里有几个工程上的关键点值得强调:

  • 用 gctx,不要用原 ctx。如果子任务里继续用外层 ctx,errgroup 的取消信号就传不进去,某个任务失败后另外两个仍在空跑,白白浪费下游资源。
  • 给每个子任务再派生一层超时。errgroup 本身不带超时,靠的是 parent ctx 的截止时间。生产环境务必给最外层 ctx 设置 deadline,否则一个挂死的下游会让整个 Wait() 永远阻塞。
  • 并发度需要限制时用 SetLimit(n)。下面这段是我们聚合接口里限制并发数的真实写法,避免一次性扇出几百个 goroutine 把下游打挂:
g, gctx := errgroup.WithContext(ctx)
g.SetLimit(8) // 最多 8 个并发

for _, item := range items {
    item := item
    g.Go(func() error {
        return s.processOne(gctx, item)
    })
}
if err := g.Wait(); err != nil {
    return err
}

七、写在最后

把 context 的源码读懂之后,很多“看起来很玄”的行为就都解释得通了:取消为什么能做到整棵树级联?因为 children map 在派生时挂载、cancel 时递归。为什么用 channel close 而不是别的?因为 close 是天然的广播。为什么 Value 要限制用途?因为它是 O(n) 的链式查找。

掌握这些原理,再回过头看前面那四类误用,你会发现它们都有共同的根源:把 context 当成了一个普通的工具对象,而忽略了它本质上是一棵与请求生命周期绑定的、不可变的派生树。守住两个原则——context 永远作为第一个参数显式传递、永远只承载请求作用域的取消与元数据——大部分坑就不会再踩。

下一篇我们会接着聊 errgroup 与信号量在更复杂并发编排场景下的组合用法,以及如何用 context.AfterFunc(Go 1.21 引入)在 context 被取消时优雅地做资源回收。