2005 字
约 6 分钟
16
S09-A16 · context 的传递约定:谁传、谁存、谁取消
2024-12-31
2026-11-10

一个 Go 服务的 handler 里写了 ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)。功能一切正常,直到某天上游取消了请求——服务继续跑完了三秒,写入了一条用户已经不再等待的记录。

context.Context 在 Go 里不是"可选的额外参数",而是请求生命周期的载体。它的三条约定——谁创建、谁传递、谁取消——只要有一条被破坏,取消能力就会在某一段链路上消失,而这段链路通常不会被测试覆盖。


1. 三条约定的完整形态

约定 规则
创建 只有入口创建(handler、main、测试)
传递 一路向下传,不存储、不新建
取消 创建者负责 cancel,被调用方只监听

第三条约定里有个细节常被忽略:被调用方不应该主动 cancel 别人的 ctx。它只能监听,或者从传入的 ctx 派生一个更短的。一旦它 cancel 了传入的 ctx,创建者的超时逻辑就被篡改了。

第二项的"不存储"最容易被违反:把 ctx 存进结构体字段,看起来方便,实际让生命周期脱离了创建者——结构体的存活时间往往长于一次请求。

// 反例:ctx 存进结构体,生命周期失控
type Service struct{ ctx context.Context }
func New(ctx context.Context) *Service { return &Service{ctx: ctx} }

// 正例:ctx 作为方法第一个参数,逐次传递
type Service struct{ db *sql.DB }
func (s *Service) Get(ctx context.Context, id string) (*Order, error) { ... }

2. 三种"忘记传"的写法

写法 后果
context.Background() 取消链断裂
context.TODO() 留在生产 同上,且装作"以后再说"
新建带超时的 ctx 覆盖父 ctx 父被取消时子仍在跑

两种写法的差别可以用一句话记住:WithTimeout 的第一个参数应当是"父",而不是 Background。评审时只看这一处就能拦住大部分取消链断裂。

第三种最隐蔽:子 ctx 的超时是相对于自己的起点算的,父 ctx 的取消信号仍会传递——但如果用了 WithTimeout(context.Background(), ...),父的关系就断了。

// 反例:父 ctx 被丢弃,取消信号无法到达下游
func handler(w http.ResponseWriter, r *http.Request) {
    ctx := context.Background()
    ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
    defer cancel()
    s.Get(ctx, id)          // r.Context() 的取消永远到不了这里
}

// 正例:从 r.Context() 派生
func handler(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 3*time.Second)
    defer cancel()
    s.Get(ctx, id)
}

3. 取消的两种传播

来源 传播方式
上游取消(客户端断开) ctx.Done() 关闭
超时 ctx.Done() 关闭 + ctx.Err() = DeadlineExceeded

被调用方要做的只有两件事:监听 Done(),并把 ctx 继续往下传。它不应该自己决定"要不要取消",那是创建者的权力。

func (s *Service) Get(ctx context.Context, id string) (*Order, error) {
    row := s.db.QueryRowContext(ctx, "SELECT ...", id)   // 继续往下传
    if err := row.Scan(&o); err != nil {
        if errors.Is(err, context.Canceled) { return nil, err }   // 原样返回,不要包装成业务错
        return nil, fmt.Errorf("get order: %w", err)
    }
    return &o, nil
}

与之配套的是"错误判定",而不是字符串比较:

// 正确判定:用 errors.Is,而不是看错误文本
if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) {
    return err            // 让上层知道这是取消/超时,不是业务失败
}
// 反例:strings.Contains(err.Error(), "context canceled")  → 包装后就失配

注意 context.Canceled 要原样返回:如果把它包装成"订单查询失败",上层会以为这是业务问题而去重试,而实际上整体已经放弃了。

4. 值传递的边界

该放 ctx 的值 不该放的值
trace_id、request_id 业务对象(用户、订单)
认证主体(当前用户身份) 配置项
少量跨层元数据 可变状态

认证主体是少数例外:它确实跨很多层,且每层都需要,作为参数逐层传会让签名膨胀。折中做法是用强类型的 key,并提供取值函数,避免调用方自己拼 key 字符串。

// 强类型 key:避免字符串 key 冲突,取值集中在一处
type ctxKey struct{}
func WithUser(ctx context.Context, u *User) context.Context {
    return context.WithValue(ctx, ctxKey{}, u)
}
func UserFrom(ctx context.Context) (*User, bool) {
    u, ok := ctx.Value(ctxKey{}).(*User); return u, ok
}

第二项与第三项被放进 ctx 是"隐式依赖"的来源:函数的签名看不出它需要用户对象,于是测试与复用都变难。

// 反例:业务对象塞进 ctx
ctx = context.WithValue(ctx, "user", u)     // 上层忘了放 → 运行时空指针
// 正例:作为显式参数
func (s *Service) Create(ctx context.Context, u *User, req CreateReq) error

5. 超时的层次

层 超时来源
入口 客户端期望(或网关)
服务内部 按入口预算分配
单次下游调用 小于剩余预算

第三项有个硬规则:下游超时必须小于上游剩余预算,否则等待没有意义。常见实现是在每次调用前用 context.WithTimeout(ctx, remaining)。

// 按剩余预算派生:下游超时永远小于上游
if dl, ok := ctx.Deadline(); ok {
    remaining := time.Until(dl)
    callCtx, cancel := context.WithTimeout(ctx, min(remaining, 800*time.Millisecond))
    defer cancel()
    _ = downstream(callCtx)
}

6. 排错:五种"取消没生效"

现象 成因 处理方向
客户端断开后仍写库 用了 Background() 从入口 ctx 派生
超时后仍继续跑 下游没收到 ctx 一路传下去
重试次数异常 context.Canceled 被当业务错误 原样返回
单元测试无法模拟取消 ctx 存在结构体里 改为参数传递
日志里没有 trace_id 值没放 ctx 或没取 统一在入口注入

还有一个排查手段很直接:在关键路径上记录 ctx.Err()。如果日志里从来没见过 context canceled,说明取消信号根本没有到达——这不是"没有取消发生",而是"取消传不过来"。

第二项可以从指标上看出来:"请求已取消但仍在执行的 goroutine 数" 是一个很有效的健康度指标,它比看延迟更早暴露问题。

7. 边界:什么情况不必严格遵循

场景 为什么 做法
一次性命令行工具 无取消需求 用 Background() 也无可厚非
后台常驻任务 生命周期由进程决定 用根 ctx,但仍需优雅退出
纯计算函数 不做 IO 不必传 ctx
极短生命周期的内部函数 无取消意义 传或不传都行

还有一条与库设计有关的边界:对外暴露的 API 应当接受 ctx,即使当前实现用不到。否则将来需要取消时,就要改所有调用方——这类"预留"在 Go 生态里已经是共识,不是过度设计。

判断口径一句话:这段代码会不会在"用户已经不要这个结果"之后继续运行。会,就必须有 ctx 且一路传下去;不会,它只是形式。


8. 自测

  1. context 的三条约定分别是什么?"不存储"这一条被违反时会失去什么能力?
  2. context.Background() 出现在 handler 里,切断的是哪条链路?
  3. 为什么 context.Canceled 要原样返回而不是包装成业务错误?
  4. 业务对象放进 ctx 会带来什么具体问题?两种替代写法是什么?
  5. 下游超时为什么必须小于上游剩余预算?实现上怎么做?
  6. 判断"是否需要严格传 ctx"的那个问题是什么?
S09-A16 · context 的传递约定:谁传、谁存、谁取消
https://www.clxhxhhr.top/posts/6444/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。