Go 为什么不把 Cancel 放进 Context 接口:观察权和控制权必须分开
Context 能被取消,却没有自己的 Cancel 方法——这是故意的。WithCancel 返回两个东西:观察用的 Context 和控制用的 CancelFunc。子操作不能取消父操作,调用链才不会乱。
上篇讲了 Go 为什么宁可让你多传一个 ctx 参数。
这篇往下走一步:既然 context 已经显式传进来了,为什么它自己没有 Cancel()?
这件事看起来很别扭。Context 明明能被取消,Done() 明明会关闭,Err() 明明会返回 context.Canceled,可你就是不能这样写:
| |
Go 偏要你这样写:
| |
多返回一个函数,多背一个变量,多写一次 defer。
很多人第一次看到这里,会把它理解成 Go 一贯的“不够优雅”。但这不是语法品味问题,而是权力问题:谁有资格结束一条调用链?
如果这个问题没想明白,context 后面几个最容易用歪的地方,都会跟着歪。
Context 没有 Cancel,是故意不给下游开关
先把调用链想成一个真实请求。
Handler 收到请求,创建一个带超时的 ctx。它调用 Service,Service 调用 Repo,Repo 再去访问数据库或下游服务。
每一层都拿到了 ctx。
如果 Context 接口上真的有 Cancel(),那意味着什么?
意味着任何下游函数,只要拿到这个 ctx,都能结束整个请求。
一个 repo 因为一次查询失败,可能直接取消上层 handler;一个 helper 因为本地判断不想继续,可能把兄弟 goroutine 也一起关掉;一个很深的内部函数,拥有了它不该拥有的生命周期控制权。
调用树会乱。
Go Blog 里有一句很关键的话:接收取消信号的一方,通常不是发送取消信号的一方。父操作可以取消子操作,但子操作不应该能反过来取消父操作。
这就是 CancelFunc 被单独返回的原因。
Context 给下游。
CancelFunc 留给创建这段生命周期的人。
CancelFunc 不放进接口,是为了不让下游拿到上游的开关。
这句话比“Go 喜欢显式”更重要。
显式只是表面。真正的设计是:观察权和控制权分开。
取消不是强杀,是通知
还有一个常见误解:调用了 cancel(),是不是正在跑的 goroutine 就会停?
不会。
标准库文档说得很直白:CancelFunc does not wait for the work to stop。
它做的事情更像发信号:关闭 Done channel,记录取消错误,释放相关 timer 和 parent 对 child 的引用。至于 goroutine 什么时候退,取决于你的代码有没有配合检查。
典型写法是这样:
| |
这里没有魔法。
Go 不会突然中断你的函数,不会偷偷把 goroutine 杀掉,也不会帮你回滚业务状态。
它只是说:信号已经到了,你该自己收尾。
Context 不是取消按钮,是取消信号的观察口。
这也是为什么 Done() 是一个 receive-only channel。下游可以等它关闭,但不能自己关它。它能看到红灯亮了,不能伸手把总闸拉掉。
这一点听起来克制,实际很工程。
强杀当然爽。问题是强杀之后,锁释放了吗?临时文件清了吗?半写入的消息怎么办?数据库事务谁回滚?
Go 选择把这些收尾动作交还给业务代码。
取消只负责通知,不负责替你处理后果。
这个边界在真实代码里很容易被忽略。
比如你启动了一个后台查询:
| |
QueryContext 能感知 ctx,但它只能让数据库调用尽快返回。后面的 rows.Close()、错误处理、指标上报、临时状态清理,仍然要你自己写。
所以不要把 cancel() 理解成“我已经把事情处理完了”。
它只是开始收尾,不是完成收尾。
这也是为什么 Go 的设计宁可显得笨一点。它不想给你一个看起来很强的 ctx.Cancel(),让你误以为调用这个方法就等于结束了一切。生命周期结束这件事,必须回到创建者和业务代码手里。
WithValue 不是 map,是一条窄门
讲完 Cancel,再看 WithValue,很多误用就更清楚了。
它的 API 太诱人:
| |
深层函数再取出来:
| |
这不就是“隐式参数”吗?
是,也不是。
Go 官方文档给 WithValue 画了边界:只用于跨 API 和进程边界的 request-scoped data,不用于给函数传可选参数。
这句话很容易被读过去,但它其实是在提醒你:WithValue 不是给你藏业务依赖的。
request id、trace id、auth metadata,这些东西有一个共同点:它们跟“一次请求”绑定,很多横切能力都要读,但它们通常不是业务函数的核心输入。
但如果你把这些也塞进去:
userIDdbloggerfeatureFlaglimitconfig
函数签名是干净了,依赖关系也脏了。
以后别人读函数,只看到一个 ctx context.Context,却不知道它偷偷依赖了哪些 key。测试要猜,review 要猜,线上问题来了还要猜。
源码层面也能看出这种克制。WithValue 不是往共享 map 里写值,而是包一层新的 context:
| |
查值时,先看当前层 key 是否匹配;不匹配,再沿 parent 链往上找。
这不是一个舒服的通用参数袋。
它故意不舒服。
WithValue 不是参数袋,是一条窄得故意不舒服的后门。
你当然能从这条后门搬很多东西进去,但那不是它的设计目的。
小接口为什么能扛住后来的演进
Context 进入标准库是在 Go 1.7。
它的接口一直很小:
| |
到后来,真实工程又冒出新需求。
比如只看 ctx.Err(),你通常只能知道两类结果:context.Canceled 或 context.DeadlineExceeded。但很多时候你想知道更具体的取消原因。
Go 1.20 增加了 WithCancelCause 和 Cause。
注意,它没有把接口改成这样:
| |
它选择用包级函数:
| |
Go 1.21 又补了 WithoutCancel、AfterFunc、WithDeadlineCause、WithTimeoutCause。
但 Context 接口还是那四个方法。
这件事很值得看。
很多 API 的衰老,不是因为一开始能力太少,而是因为一开始承诺太多。接口一旦变大,所有实现者都要跟着背负它;接口一旦放进了不该放的权力,后面再想拿出来就很难。
Context 的小,不是简陋。
它是在给未来留空间。
如果当年接口里塞进 Cancel()、Cause()、AfterFunc(),今天看起来也许更“面向对象”,但代价会很重:每一个自定义 Context 实现都要跟着变,每一段只想观察生命周期的代码都要暴露更多能力,所有依赖这个接口的包也会被迫理解更多语义。
这就是 API 设计里最难的一点。
少不是目的,少承诺才是目的。
小接口能活得久,靠的不是少做事,而是不乱承诺。
Go 后来不是没加功能,而是尽量把新能力放在派生函数、包级函数和具体实现里,不轻易改最底层的契约。
这跟 CancelFunc 分离是同一种思路:能不扩大接口权力,就不扩大。
你可以直接拿走的自查清单
如果你现在维护 Go 服务,别只搜代码里有没有 context.Context。
那太粗了。
更应该问这几个问题。
第一,谁创建了派生 context,谁负责调用 cancel?
看到 context.WithCancel、WithTimeout、WithDeadline,就顺手往下看:cancel 有没有在所有路径上被调用?简单场景里 defer cancel() 最稳;复杂分支里,成功、失败、提前返回都要有归宿。
第二,下游函数有没有试图拥有不该拥有的控制权?
正常情况下,下游只应该接收 ctx,检查 Done(),返回 Err(),让上游决定怎么收尾。不要把“局部失败”写成“全局关停”。
第三,WithValue 里到底放了什么?
如果是 request id、trace id、auth metadata,通常还说得过去。如果是业务参数、数据库连接、配置、开关、分页参数,就要停下来。
你可能不是在传上下文。
你是在绕过函数签名。
第四,有没有在 struct 里长期保存 context?
普通业务代码里,这通常会把两种生命周期混在一起:对象的生命周期,和一次调用的生命周期。一次请求结束了,对象还在;对象还在,不代表那次请求的 ctx 还该被继续使用。
第五,哪些地方用了 context.Background()?
Background() 不是万能兜底。它经常意味着你主动切断了上游取消和超时。服务内部调用外部系统时,如果随手新建 Background(),排查链路超时会很痛苦。
第六,WithValue 的 key 有没有独立类型?
如果你直接用字符串当 key,跨包冲突的风险会变高。更稳的写法是定义一个不导出的 key 类型,把读写方法封装在同一个包里。这样别人不会随便猜 key,也不会把你的 request metadata 写坏。
| |
这不是形式主义。它是在承认 WithValue 有隐式依赖风险,所以尽量把入口收窄。
这几个问题比“有没有传 ctx”更有价值。
因为 context 的核心不是把参数传来传去,而是把生命周期边界放到能被看见、能被审查的位置。
这一组文章,其实都在讲同一件事
到这里,Go context 的几件“别扭设计”就能串起来了。
它要求你显式传 ctx,是为了让生命周期进入函数签名。
它不把 Cancel() 放进 Context,是为了拆开观察权和控制权。
它保留 WithValue,但把边界收得很窄,是为了允许请求级元数据传播,同时不鼓励你把 context 当参数袋。
它后来增加 Cause、AfterFunc、WithoutCancel,却不扩大四方法接口,是为了让小接口继续稳定。
这些设计没有哪一个是免费的。
你会多写参数,多写 defer cancel(),多写 select,还要忍受 WithValue 不像 map 那么顺手。
但它换来一件长期维护时更值钱的东西:权责边界可查。
上篇我们讲的是:Go 为什么宁可让你多传一个 ctx 参数。
这一篇讲的是:Go 为什么不把取消权也塞进 Context 接口。
把两篇放在一起看,答案其实很一致。
Go context 不是为了让代码更漂亮。
它是为了让调用链里那些会超时、会取消、会释放资源的东西,不要躲在暗处。
这也是我觉得 context 值得反复拆的原因。它不是一个“会用就行”的工具包,而是 Go 把工程协作写进 API 的一个样本:调用者要暴露生命周期,被调用者要尊重生命周期,库作者要克制接口承诺,业务代码要负责自己的收尾。
你越往后维护大型 Go 服务,越会发现这种啰嗦不是噪音,而是线索。
如果你还想继续看这个系列,后面可以顺着几个更具体的问题往下拆:WithValue 的链式查找到底怎么工作,cancelCtx 怎么把取消传播给 child,以及线上 goroutine 泄漏时,怎么沿着 context 找断点。
关注我,后面继续把 Go 这些“看起来啰嗦、实际很有工程味”的设计,一篇篇拆开讲。