在 Go 里写过一个稍微像样的服务,就一定绕不开 context。它贯穿每一次 HTTP 请求处理、每一次 RPC 调用、每一次数据库查询。标准库的 net/http、database/sql、grpc-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
}
这四个方法各有分工:Deadline 和 Done 负责取消语义(前者告诉你“什么时候”,后者告诉你“发生了”);Err 负责告诉你在取消后原因是什么,是 Canceled 还是 DeadlineExceeded;Value 负责请求作用域的值传递。注意,这是一个只读接口——你无法通过 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+)取消根因
}
三个要点:
done从 Go 1.21 起改为atomic.Value懒初始化,只有第一次调用Done()时才真正创建 channel,避免提前派生时白白分配。children是一个以canceler接口实例为 key 的 map,记录了所有“应该跟着我一起取消”的子 context。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:超时的实现
WithTimeout 和 WithDeadline 本质上是同一回事——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 被取消时优雅地做资源回收。