Go context 与取消传播

AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。

Go 1.24

一个请求进来,可能牵动五层调用、三个 goroutine、两次数据库查询。要中途放弃它们,靠的就是 context——它只做一件事:把取消信号和截止时间沿调用链传下去。

context 能带什么

context.Context 提供四个能力:

  1. Done():返回一个 channel,取消时关闭;
  2. Err():取消原因,context.Canceled 或 context.DeadlineExceeded;
  3. Deadline():是否有截止时间;
  4. Value(key):携带请求级数据(trace id、用户身份)。

它不是「全局变量容器」,也不该用来传可选参数。

根 context 的选择

ctx := context.Background()   // 非 nil、永不过期,用于 main、初始化、测试
ctx := context.TODO()         // 占位:还没想好该用哪个,之后要替换

不要传 nil,编译能过但运行时会 panic。库函数拿不准就用 TODO(),让调用链上的人知道这里还没接好。

超时与截止

ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second)
defer cancel()                       // 必须调用,否则泄漏定时器

res, err := fetchUser(ctx, 42)
deadline := time.Now().Add(500 * time.Millisecond)
ctx, cancel := context.WithDeadline(context.Background(), deadline)
defer cancel()

区别只在于表达方式:WithTimeout 是相对时长,WithDeadline 是绝对时间点。已经有上游截止时间时,派生会自动取更早的那个。

defer cancel() 不是可选的:

  • 即使请求正常结束,也要释放内部定时器与子 context 引用。
  • go vet 会检查「创建了 cancel 却没调用」的明显情况。

向下传递

约定:ctx 永远是第一个参数,名字就叫 ctx。

func (s *Service) Load(ctx context.Context, id int) (*User, error) {
    row, err := s.db.QueryContext(ctx, "SELECT id, name FROM users WHERE id = ?", id)
    if err != nil {
        return nil, fmt.Errorf("查询用户 %d:%w", id, err)
    }
    defer row.Close()
    // ...
}

标准库所有带 I/O 的方法都有 ...Context 版本:QueryContext、ExecContext、http.NewRequestWithContext、time.AfterFunc 没有,但 time.NewTimer 配 select 可以实现。用了不带 ctx 的版本,取消就传不下去。

监听取消

goroutine 里做长任务,要定期检查:

func worker(ctx context.Context, jobs <-chan Job) error {
    for {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case job, ok := <-jobs:
            if !ok {
                return nil
            }
            if err := handle(ctx, job); err != nil {
                return err
            }
        }
    }
}

ctx.Done() 关闭后立即可读,select 会选中它。注意:如果 handle 本身阻塞且不接收 ctx,select 也救不了——阻塞点必须支持取消。

CPU 密集的循环里手动检查:

for i, item := range items {
    if i%100 == 0 {
        select {
        case <-ctx.Done():
            return ctx.Err()
        default:
        }
    }
    process(item)
}

取消的连锁反应

func handler(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()                     // 客户端断开时自动取消
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    data, err := service.Load(ctx, 1)
    if errors.Is(err, context.DeadlineExceeded) {
        http.Error(w, "处理超时", http.StatusGatewayTimeout)
        return
    }
    if errors.Is(err, context.Canceled) {
        return                             // 客户端已断开,无需响应
    }
    // ...
}

r.Context() 在客户端关闭连接或 handler 返回时取消,天然适合做请求级根节点。

多 goroutine 协作

func fetchAll(ctx context.Context, ids []int) ([]User, error) {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()                          // 任一出错时取消其他

    var (
        wg    sync.WaitGroup
        mu    sync.Mutex
        users []User
        once  sync.Once
        err   error
    )

    for _, id := range ids {
        wg.Add(1)
        go func(id int) {
            defer wg.Done()
            u, e := fetchUser(ctx, id)
            if e != nil {
                once.Do(func() { err = e; cancel() })
                return
            }
            mu.Lock()
            users = append(users, u)
            mu.Unlock()
        }(id)
    }

    wg.Wait()
    if err != nil {
        return nil, err
    }
    return users, nil
}

真实项目更推荐 golang.org/x/sync/errgroup,它把 WaitGroup + 错误收集 + 取消打包好了:

g, ctx := errgroup.WithContext(ctx)
for _, id := range ids {
    g.Go(func() error { return fetchAndStore(ctx, id) })
}
if err := g.Wait(); err != nil {
    return err
}

Value 的正确用法

type ctxKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, ctxKey{}, id)
}

func RequestID(ctx context.Context) string {
    id, _ := ctx.Value(ctxKey{}).(string)
    return id
}
  • key 用未导出的自定义类型,避免与其他包冲突。
  • 只放请求域的元数据(trace id、认证主体),不放数据库连接、配置、logger 之外的可变状态。
  • 每次取值都要做类型断言,滥用会让代码到处是 any。

不要存进结构体

// 错
type Service struct {
    ctx context.Context
}

// 对
func (s *Service) Do(ctx context.Context) error { ... }

context 表达的是一次调用的生命周期,存进结构体会让不同请求共享同一个取消信号,导致「一个请求超时,全部请求失败」。

常见错误与注意事项

1. 忘记 cancel()

go vet 会报 lostcancel。用 defer cancel(),或在函数返回前显式调用。

2. 以为 WithTimeout 会中断阻塞调用

它只关闭 Done() channel。阻塞中的 time.Sleep、无缓冲 channel 写入、不支持 ctx 的第三方调用都不会被打断。

3. 用 context.Background() 覆盖上游 ctx

// 错:丢掉了调用方的截止时间与取消
db.QueryContext(context.Background(), ...)

始终传递进来的 ctx。

4. 在 select 里只等结果不等取消

// 上游取消后这里永久阻塞
res := <-ch

写成 select { case res := <-ch: ...; case <-ctx.Done(): return ctx.Err() }。

5. 把 Err() 当成唯一错误

ctx.Err() 只说明取消原因;业务错误要自己包装返回,并用 errors.Is 区分。

6. 取消后继续使用结果

err != nil 时返回的 data 可能只写了一半,不能当有效数据用。

7. 超时设得过短

比下游 P99 还短的超时等于人为制造失败。按链路预算分配:网关 3s → 服务 2s → 数据库 1.5s。

小结

  • context 传取消信号、截止时间与请求域数据,ctx 是第一个参数。
  • 根节点用 Background(),不确定用 TODO(),永远不传 nil。
  • WithTimeout / WithDeadline 之后必须 defer cancel()。
  • 阻塞点要支持 ctx,否则取消无效;select 同时等结果与 Done()。
  • 不把 ctx 存进结构体,不覆盖上游 ctx,Value 只放元数据。

下一步