Context 实战:超时、取消、CancelCause 与错误传播
Context 不是一个万能参数包,也不只是“超时 channel”。它描述的是一棵调用树:父调用被取消,属于它的子工作应尽快停止;父调用给出截止时间,子调用不能私自把预算延长。
flowchart TD
R[HTTP Request Context] --> S[Service]
S --> DB[Database Query]
S --> RPC[Inventory RPC]
RPC --> Retry[Retry Attempt]
R -->|cancel / deadline| S
S --> DB
S --> RPC
这棵树是理解 Context API 的主线。
Context 总是作为第一个参数传递
func Reserve(ctx context.Context, orderID string) error不要把 Context 存进 struct,也不要传 nil。存进 struct 会让一次请求的生命周期混入长期对象,调用方也无法为每次调用设置独立截止时间。
// 不推荐
type Client struct {
ctx context.Context
}
// 推荐
type Client struct {
transport Transport
}
func (c *Client) Fetch(ctx context.Context, id string) (Item, error)context.TODO() 表示“这里尚未决定应该传哪个 Context”,适合迁移中的临时标记;生产代码的调用根通常使用请求 Context、信号 Context 或由进程创建的根 Context。
子调用只能缩短截止时间
假设请求还剩 200ms,服务层创建一个 2 秒超时:
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()实际截止时间仍是父 Context 的 200ms。WithDeadline 会选择更早的截止时间,这正是调用树应有的行为。
但超时预算不能靠层层固定常量随意相加。一次请求包含排队、数据库、RPC 和重试,每层都设 500ms,外层总超时 600ms 时,后续阶段可能拿到一个几乎已经到期的 Context。
if deadline, ok := ctx.Deadline(); ok {
remaining := time.Until(deadline)
if remaining < minimumBudget {
return ErrInsufficientTime
}
}真正需要拆分预算时,应基于剩余时间和阶段重要性计算,并把指标打出来;不要把 Context 当作隐形 SLA 配置中心。
创建了 cancel,就调用它
ctx, cancel := context.WithTimeout(parent, 500*time.Millisecond)
defer cancel()主动 cancel 会移除父 Context 对子节点的引用,并停止关联 timer。即使操作很快成功、即使超时最终一定到达,也应调用。go vet 会检查一些遗漏路径,但无法替你设计生命周期。
CancelFunc 不会等待工作真正停止。它只是发出信号;需要确认退出时,还要配合 WaitGroup、errgroup 或组件的 Wait。
阻塞点必须监听取消
func publish(ctx context.Context, out chan<- Event, event Event) error {
select {
case out <- event:
return nil
case <-ctx.Done():
return ctx.Err()
}
}只在函数入口检查一次 ctx.Err() 不够。入口检查后,goroutine 仍可能阻塞在 channel、网络、锁或 sleep。标准库中带 Context 的数据库和 HTTP API 会负责在相应阻塞点传播取消;自定义等待逻辑也要这样做。
长 CPU 循环不会被 Context 抢占式终止,需要在合适粒度主动检查:
for i, item := range items {
if i%256 == 0 {
if err := ctx.Err(); err != nil {
return err
}
}
compute(item)
}检查过密会增加开销,过疏会拉长取消延迟,应通过任务粒度和基准测试权衡。
ctx.Err 与 context.Cause 的分工
ctx.Err() 只返回两个稳定类别:context.Canceled 或 context.DeadlineExceeded。它适合控制流判断。
WithCancelCause 可以记录更具体的停止原因:
var ErrInventoryRejected = errors.New("inventory rejected order")
ctx, cancel := context.WithCancelCause(parent)
defer cancel(nil)
go func() {
if err := watchInventory(ctx); err != nil {
cancel(fmt.Errorf("watch inventory: %w", err))
}
}()
<-ctx.Done()
fmt.Println(ctx.Err()) // context canceled
fmt.Println(context.Cause(ctx)) // 具体原因第一次取消决定 cause。父 Context 先取消时,子节点会继承父 cause;子节点先以自己的原因取消,则它保留自己的 cause。
不要把 cause 当作跨服务错误协议。Context 通常只在当前进程调用树内传播,RPC 边界仍应使用明确的状态码和错误模型。
WithTimeoutCause 记录的是“为何设置这个超时”
ctx, cancel := context.WithTimeoutCause(
parent,
300*time.Millisecond,
ErrInventoryTimeout,
)
defer cancel()超时触发时 context.Cause(ctx) 返回指定原因;手动调用返回的 cancel() 不会设置这个 cause。这适合区分多个阶段的截止时间,同时仍可用 errors.Is(ctx.Err(), context.DeadlineExceeded) 判断通用超时。
Value 只放请求级元数据
适合放入 Context 的值通常具备以下特征:
- 跟随单次请求传播;
- 是横切关注点,而不是业务必填参数;
- 缺失时不会让函数签名语义失真。
请求 ID、trace span、认证主体可以考虑;分页大小、数据库连接、logger 配置和业务选项不应该藏进去。
key 使用包内自定义类型,避免不同包的字符串 key 冲突:
type requestIDKey struct{}
func WithRequestID(ctx context.Context, id string) context.Context {
return context.WithValue(ctx, requestIDKey{}, id)
}
func RequestID(ctx context.Context) (string, bool) {
id, ok := ctx.Value(requestIDKey{}).(string)
return id, ok
}通过函数封装 key,也避免调用方到处做类型断言。
不要用 context.Background 切断请求树
下面的代码会让任务在请求取消后继续运行:
go audit(context.Background(), event)有时这正是需求,例如请求完成后仍需可靠写审计日志。但“脱离请求”必须伴随新的所有者、超时和停机管理。更可靠的做法通常是把任务交给进程级队列或 worker,而不是启动一个无人等待的 goroutine。
context.WithoutCancel 可以保留父 Context 的 value,同时移除取消、截止时间和错误;它同样会返回 nil Done。使用它时必须为新工作重新建立截止时间和所有权,否则只是更隐蔽地切断调用树。
错误传播不要丢失 Context 语义
底层调用返回 Context 错误时,包装可以补充操作信息:
if err := repository.Save(ctx, order); err != nil {
return fmt.Errorf("save order %q: %w", order.ID, err)
}上层仍然可以用 errors.Is(err, context.Canceled) 判断。不要把所有取消都记录成 error 日志:客户端主动断开、服务正常停机和真实下游超时的运维含义不同,应结合 cause 和请求状态分类。
Context 使用边界
- Context 传播取消,不负责强制杀死 goroutine;
- deadline 是上限,不是“这个操作一定能用这么久”;
- value 不是可选参数容器;
- cancel 是资源释放动作,不是等待动作;
- 脱离父 Context 的工作必须获得新的所有者。