Go context 与取消传播
AI 辅助生成本文由 AI 起草并经人工核查,示例均标明软件版本;发现错误请以官方文档为准。
一个请求进来,可能牵动五层调用、三个 goroutine、两次数据库查询。要中途放弃它们,靠的就是 context——它只做一件事:把取消信号和截止时间沿调用链传下去。
context 能带什么
context.Context 提供四个能力:
Done():返回一个 channel,取消时关闭;Err():取消原因,context.Canceled或context.DeadlineExceeded;Deadline():是否有截止时间;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只放元数据。