跳至内容
Context 实战:超时、取消、CancelCause 与错误传播

Context 实战:超时、取消、CancelCause 与错误传播

2026年6月27日·
yanlong

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.Canceledcontext.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 的工作必须获得新的所有者。

延伸阅读