为什么需要 context

在 Go 里,一个请求往往会派生出多个 goroutine:查数据库、调下游服务、读缓存……这些任务共享同一个生命周期——上游超时了,下游也应该跟着停。如果只靠 channel 手动广播取消信号,代码会迅速变成一团乱麻。context 就是 Go 官方给出的标准答案:它把「截止时间、取消信号、请求域数据」打包成一个可以沿调用链传递的值。

context 的三种派生方式

所有 context 都从 context.Background()context.TODO() 起步,再按需派生:

  • WithCancel:手动触发取消,适合「用户断开连接就停」的场景。
  • WithTimeout / WithDeadline:到时间自动取消,适合「调用下游最多等 200ms」。
  • WithValue:挂载请求域数据,谨慎使用。
ctx, cancel := context.WithTimeout(context.Background(), 200*time.Millisecond)
defer cancel() // 务必调用,释放定时器资源

实战一:给下游调用加超时

最常见也最容易出错的,是 HTTP 客户端忘了传 context,导致 goroutine 和连接泄漏。正确做法是把 ctx 塞进 http.NewRequestWithContext

func fetchUser(ctx context.Context, id string) (*User, error) {
    req, err := http.NewRequestWithContext(ctx, http.MethodGet,
        "https://api.example.com/users/"+id, nil)
    if err != nil {
        return nil, err
    }
    resp, err := http.DefaultClient.Do(req)
    if err != nil {
        return nil, err // ctx 超时或取消时,这里会立即返回
    }
    defer resp.Body.Close()
    // ... 解析 resp
}

一旦 ctx 超时,Do 会立刻返回错误,调用方不会卡死。注意:defer resp.Body.Close() 必须放在确认 resp != nil 之后,否则在超时报错时 resp 为 nil 会直接 panic。

实战二:跨 goroutine 的取消传播

父 context 取消后,所有由它派生的子 context 都会收到信号。利用这一点,可以干净地实现「一停全停」:

func crawlAll(ctx context.Context, urls []string) error {
    ctx, cancel := context.WithCancel(ctx)
    defer cancel()

    errCh := make(chan error, len(urls))
    for _, u := range urls {
        go func(u string) {
            if err := fetchUser(ctx, u); err != nil {
                cancel() // 任意一个失败,取消其余任务
                errCh <- err
            }
        }(u)
    }
    for range urls {
        select {
        case <-ctx.Done():
            return ctx.Err()
        case err := <-errCh:
            return err
        }
    }
    return nil
}

经验法则:凡是接受 ctx 的函数,都应该把 ctx 作为第一个参数,并且「只向下传递、不向上存储」。

四个常见误用

context 用错比不用更危险,下面四个坑尤其高频:

误用后果正确做法
把 ctx 存进 struct 字段生命周期混乱,难以测试作为函数第一个参数显式传入
用 WithValue 传普通业务参数类型不安全、可读性差用普通参数显式传递
派生后忘记调用 canceltimer / goroutine 泄漏defer cancel() 紧跟 WithXXX
用 TODO() 混过编译取消信号断链显式传入上游 ctx

其中「忘记 cancel」最隐蔽。WithTimeout 内部会启一个定时器,不调用 cancel 它就不会被释放,高并发下会缓慢泄漏内存和 goroutine。记住一个固定句式:ctx, cancel := ... 之后立刻 defer cancel()

小结

context 不是银弹,它管的是「取消与截止」,不该承担普通参数传递的职责。掌握 WithCancel / WithTimeout 的派生关系,坚持 ctx 作为首参、defer cancel、不滥用 WithValue,你的 Go 服务在超时控制和并发取消上就会稳得多。

评论

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注