Featured image of post Go context 不是万能取消器:超时、泄漏和传参边界

Go context 不是万能取消器:超时、泄漏和传参边界

记录 Go context 的几个使用边界:它传递取消和超时信号,但不会自动杀掉 goroutine,也不应该替代显式业务参数。

context.Context 是 Go 服务端代码里很常见的东西。HTTP 请求、数据库查询、RPC 调用、后台任务,基本都会带一个 ctx

但用久了会发现,context 不是万能取消器。它只是一个信号传递机制,真正停不停,还得看代码有没有响应它。

我现在看 context 代码,会先问一个问题:取消信号最后有没有传到真正阻塞的地方?如果只是函数签名里层层带了 ctx,但最里面的网络请求、数据库查询、channel 等待没有监听它,那这个 context 基本只是摆设。

超时要放在调用边界

比如一个 HTTP handler 里调用下游接口,我通常会在下游调用边界设置超时:

1
2
3
4
5
6
func (s *Service) FetchProfile(ctx context.Context, userID int64) (*Profile, error) {
    ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
    defer cancel()

    return s.client.GetProfile(ctx, userID)
}

这里的 defer cancel() 不是摆设。即使请求提前返回,也要释放 context 相关资源。

但超时不是越短越好。超时应该根据调用链来设计:入口总超时、数据库超时、外部 API 超时都要有层级关系。否则外层 3 秒,内层 5 秒,就会出现配置看起来有超时,但实际没意义的情况。

我一般会让超时从外到内逐层收紧:

1
2
3
4
HTTP request timeout:      5s
service orchestration:     4s
database query:            2s
external API call:         1500ms

这样做不是为了凑数字,而是避免内层还在等,外层已经没法返回。超时策略如果没有层级,排查时会非常乱。

goroutine 不会因为 ctx 取消自动退出

这是最容易误会的地方。

下面这段代码里,如果 goroutine 内部不监听 ctx.Done(),取消 context 也没用:

1
2
3
4
5
go func() {
    for {
        doSomething()
    }
}()

应该显式处理退出信号:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
go func() {
    ticker := time.NewTicker(time.Second)
    defer ticker.Stop()

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            doSomething()
        }
    }
}()

context 只是告诉你“该停了”,不是强制把 goroutine 杀掉。

不要把 context 存进结构体里

我见过有人把 context 放进 service struct:

1
2
3
type Service struct {
    ctx context.Context
}

这个写法很容易把请求生命周期搞乱。Service 往往是长生命周期对象,而 context 通常属于一次请求、一次任务或一次调用。

更推荐这样:

1
2
3
4
5
6
7
type Service struct {
    repo *Repo
}

func (s *Service) CreateOrder(ctx context.Context, req CreateOrderRequest) error {
    return s.repo.Insert(ctx, req)
}

context 应该作为第一个参数显式传递,而不是藏在对象状态里。

WithValue 要克制

context.WithValue 可以传跨调用链的信息,但不适合传普通业务参数。

适合放的东西一般是:

  • request id。
  • trace id。
  • auth token 的解析结果,前提是项目里有明确约定。
  • 日志字段里需要贯穿调用链的元信息。

不太适合放:

  • userID 这种业务函数必须显式依赖的参数。
  • 查询条件。
  • 配置项。
  • 可选开关。

如果一个函数没有 userID 就无法工作,那它应该出现在函数签名里,而不是从 context 里偷偷取。

select 里也要注意阻塞点

有时代码写了 ctx.Done(),但还是取消不及时。原因可能是实际阻塞发生在另一个调用里。

1
2
3
4
5
6
select {
case <-ctx.Done():
    return ctx.Err()
default:
    return slowCall()
}

这里一旦进入 slowCall(),如果 slowCall 自己不支持 context,外层就没法及时取消。

更好的方式是让阻塞调用本身接收 context:

1
return slowCall(ctx)

数据库、HTTP 客户端、RPC 客户端通常都有对应的 context 版本。项目里自己封装库时,也应该把 context 继续传下去。

还有一种容易漏的场景是 channel 发送:

1
2
3
4
5
6
select {
case jobs <- job:
    return nil
case <-ctx.Done():
    return ctx.Err()
}

如果直接 jobs <- job,队列满的时候调用方就卡住了,外层取消也救不了。context 能不能生效,通常就差这一个 select

我对 context 的理解越来越简单:它传递取消和超时信号,不负责强制停止 goroutine,不应该替代函数参数,应该从入口一路传到真正阻塞的调用点。

参考资料

本文采用 CC BY-NC-SA 4.0 协议,转载请注明出处。
记录工程实践和排障过程。
使用 Hugo 构建
主题 StackJimmy 设计