context.Context 是 Go 服务端代码里很常见的东西。HTTP 请求、数据库查询、RPC 调用、后台任务,基本都会带一个 ctx。
但用久了会发现,context 不是万能取消器。它只是一个信号传递机制,真正停不停,还得看代码有没有响应它。
我现在看 context 代码,会先问一个问题:取消信号最后有没有传到真正阻塞的地方?如果只是函数签名里层层带了 ctx,但最里面的网络请求、数据库查询、channel 等待没有监听它,那这个 context 基本只是摆设。
超时要放在调用边界
比如一个 HTTP handler 里调用下游接口,我通常会在下游调用边界设置超时:
这里的 defer cancel() 不是摆设。即使请求提前返回,也要释放 context 相关资源。
但超时不是越短越好。超时应该根据调用链来设计:入口总超时、数据库超时、外部 API 超时都要有层级关系。否则外层 3 秒,内层 5 秒,就会出现配置看起来有超时,但实际没意义的情况。
我一般会让超时从外到内逐层收紧:
这样做不是为了凑数字,而是避免内层还在等,外层已经没法返回。超时策略如果没有层级,排查时会非常乱。
goroutine 不会因为 ctx 取消自动退出
这是最容易误会的地方。
下面这段代码里,如果 goroutine 内部不监听 ctx.Done(),取消 context 也没用:
应该显式处理退出信号:
context 只是告诉你“该停了”,不是强制把 goroutine 杀掉。
不要把 context 存进结构体里
我见过有人把 context 放进 service struct:
这个写法很容易把请求生命周期搞乱。Service 往往是长生命周期对象,而 context 通常属于一次请求、一次任务或一次调用。
更推荐这样:
context 应该作为第一个参数显式传递,而不是藏在对象状态里。
WithValue 要克制
context.WithValue 可以传跨调用链的信息,但不适合传普通业务参数。
适合放的东西一般是:
- request id。
- trace id。
- auth token 的解析结果,前提是项目里有明确约定。
- 日志字段里需要贯穿调用链的元信息。
不太适合放:
- userID 这种业务函数必须显式依赖的参数。
- 查询条件。
- 配置项。
- 可选开关。
如果一个函数没有 userID 就无法工作,那它应该出现在函数签名里,而不是从 context 里偷偷取。
select 里也要注意阻塞点
有时代码写了 ctx.Done(),但还是取消不及时。原因可能是实际阻塞发生在另一个调用里。
这里一旦进入 slowCall(),如果 slowCall 自己不支持 context,外层就没法及时取消。
更好的方式是让阻塞调用本身接收 context:
| |
数据库、HTTP 客户端、RPC 客户端通常都有对应的 context 版本。项目里自己封装库时,也应该把 context 继续传下去。
还有一种容易漏的场景是 channel 发送:
如果直接 jobs <- job,队列满的时候调用方就卡住了,外层取消也救不了。context 能不能生效,通常就差这一个 select。
我对 context 的理解越来越简单:它传递取消和超时信号,不负责强制停止 goroutine,不应该替代函数参数,应该从入口一路传到真正阻塞的调用点。