Go 为什么不把 Cancel 放进 Context 接口:观察权和控制权必须分开

Context 能被取消,却没有自己的 Cancel 方法——这是故意的。WithCancel 返回两个东西:观察用的 Context 和控制用的 CancelFunc。子操作不能取消父操作,调用链才不会乱。

上篇讲了 Go 为什么宁可让你多传一个 ctx 参数。

这篇往下走一步:既然 context 已经显式传进来了,为什么它自己没有 Cancel()

这件事看起来很别扭。Context 明明能被取消,Done() 明明会关闭,Err() 明明会返回 context.Canceled,可你就是不能这样写:

1
ctx.Cancel() // Go 没有这个 API

Go 偏要你这样写:

1
2
ctx, cancel := context.WithCancel(parent)
defer cancel()

多返回一个函数,多背一个变量,多写一次 defer

很多人第一次看到这里,会把它理解成 Go 一贯的“不够优雅”。但这不是语法品味问题,而是权力问题:谁有资格结束一条调用链?

如果这个问题没想明白,context 后面几个最容易用歪的地方,都会跟着歪。

Context 没有 Cancel,是故意不给下游开关

先把调用链想成一个真实请求。

Handler 收到请求,创建一个带超时的 ctx。它调用 ServiceService 调用 RepoRepo 再去访问数据库或下游服务。

每一层都拿到了 ctx

如果 Context 接口上真的有 Cancel(),那意味着什么?

意味着任何下游函数,只要拿到这个 ctx,都能结束整个请求。

一个 repo 因为一次查询失败,可能直接取消上层 handler;一个 helper 因为本地判断不想继续,可能把兄弟 goroutine 也一起关掉;一个很深的内部函数,拥有了它不该拥有的生命周期控制权。

调用树会乱。

Go Blog 里有一句很关键的话:接收取消信号的一方,通常不是发送取消信号的一方。父操作可以取消子操作,但子操作不应该能反过来取消父操作。

这就是 CancelFunc 被单独返回的原因。

Context 给下游。

CancelFunc 留给创建这段生命周期的人。

CancelFunc 不放进接口,是为了不让下游拿到上游的开关。

这句话比“Go 喜欢显式”更重要。

显式只是表面。真正的设计是:观察权和控制权分开。

Context 与 CancelFunc 的权责分离

取消不是强杀,是通知

还有一个常见误解:调用了 cancel(),是不是正在跑的 goroutine 就会停?

不会。

标准库文档说得很直白:CancelFunc does not wait for the work to stop

它做的事情更像发信号:关闭 Done channel,记录取消错误,释放相关 timer 和 parent 对 child 的引用。至于 goroutine 什么时候退,取决于你的代码有没有配合检查。

典型写法是这样:

1
2
3
4
5
6
select {
case <-ctx.Done():
    return ctx.Err()
case item := <-work:
    return handle(item)
}

这里没有魔法。

Go 不会突然中断你的函数,不会偷偷把 goroutine 杀掉,也不会帮你回滚业务状态。

它只是说:信号已经到了,你该自己收尾。

Context 不是取消按钮,是取消信号的观察口。

这也是为什么 Done() 是一个 receive-only channel。下游可以等它关闭,但不能自己关它。它能看到红灯亮了,不能伸手把总闸拉掉。

这一点听起来克制,实际很工程。

强杀当然爽。问题是强杀之后,锁释放了吗?临时文件清了吗?半写入的消息怎么办?数据库事务谁回滚?

Go 选择把这些收尾动作交还给业务代码。

取消只负责通知,不负责替你处理后果。

这个边界在真实代码里很容易被忽略。

比如你启动了一个后台查询:

1
2
3
4
5
6
7
8
go func() {
    rows, err := db.QueryContext(ctx, sql)
    if err != nil {
        return
    }
    defer rows.Close()
    // consume rows
}()

QueryContext 能感知 ctx,但它只能让数据库调用尽快返回。后面的 rows.Close()、错误处理、指标上报、临时状态清理,仍然要你自己写。

所以不要把 cancel() 理解成“我已经把事情处理完了”。

它只是开始收尾,不是完成收尾。

这也是为什么 Go 的设计宁可显得笨一点。它不想给你一个看起来很强的 ctx.Cancel(),让你误以为调用这个方法就等于结束了一切。生命周期结束这件事,必须回到创建者和业务代码手里。

WithValue 不是 map,是一条窄门

讲完 Cancel,再看 WithValue,很多误用就更清楚了。

它的 API 太诱人:

1
ctx = context.WithValue(ctx, requestIDKey{}, "req-123")

深层函数再取出来:

1
requestID, _ := ctx.Value(requestIDKey{}).(string)

这不就是“隐式参数”吗?

是,也不是。

Go 官方文档给 WithValue 画了边界:只用于跨 API 和进程边界的 request-scoped data,不用于给函数传可选参数。

这句话很容易被读过去,但它其实是在提醒你:WithValue 不是给你藏业务依赖的。

request id、trace id、auth metadata,这些东西有一个共同点:它们跟“一次请求”绑定,很多横切能力都要读,但它们通常不是业务函数的核心输入。

但如果你把这些也塞进去:

  • userID
  • db
  • logger
  • featureFlag
  • limit
  • config

函数签名是干净了,依赖关系也脏了。

以后别人读函数,只看到一个 ctx context.Context,却不知道它偷偷依赖了哪些 key。测试要猜,review 要猜,线上问题来了还要猜。

源码层面也能看出这种克制。WithValue 不是往共享 map 里写值,而是包一层新的 context:

1
2
3
4
type valueCtx struct {
    Context
    key, val any
}

查值时,先看当前层 key 是否匹配;不匹配,再沿 parent 链往上找。

这不是一个舒服的通用参数袋。

它故意不舒服。

WithValue 不是参数袋,是一条窄得故意不舒服的后门。

你当然能从这条后门搬很多东西进去,但那不是它的设计目的。

小接口为什么能扛住后来的演进

Context 进入标准库是在 Go 1.7。

它的接口一直很小:

1
2
3
4
5
6
type Context interface {
    Deadline() (deadline time.Time, ok bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
}

到后来,真实工程又冒出新需求。

比如只看 ctx.Err(),你通常只能知道两类结果:context.Canceledcontext.DeadlineExceeded。但很多时候你想知道更具体的取消原因。

Go 1.20 增加了 WithCancelCauseCause

注意,它没有把接口改成这样:

1
2
3
4
5
6
7
type Context interface {
    Deadline() (time.Time, bool)
    Done() <-chan struct{}
    Err() error
    Value(key any) any
    Cause() error // Go 没这么改
}

它选择用包级函数:

1
2
3
ctx, cancel := context.WithCancelCause(parent)
cancel(errors.New("quota exceeded"))
err := context.Cause(ctx)

Go 1.21 又补了 WithoutCancelAfterFuncWithDeadlineCauseWithTimeoutCause

Context 接口还是那四个方法。

这件事很值得看。

很多 API 的衰老,不是因为一开始能力太少,而是因为一开始承诺太多。接口一旦变大,所有实现者都要跟着背负它;接口一旦放进了不该放的权力,后面再想拿出来就很难。

Context 的小,不是简陋。

它是在给未来留空间。

如果当年接口里塞进 Cancel()Cause()AfterFunc(),今天看起来也许更“面向对象”,但代价会很重:每一个自定义 Context 实现都要跟着变,每一段只想观察生命周期的代码都要暴露更多能力,所有依赖这个接口的包也会被迫理解更多语义。

这就是 API 设计里最难的一点。

少不是目的,少承诺才是目的。

小接口能活得久,靠的不是少做事,而是不乱承诺。

Go 后来不是没加功能,而是尽量把新能力放在派生函数、包级函数和具体实现里,不轻易改最底层的契约。

这跟 CancelFunc 分离是同一种思路:能不扩大接口权力,就不扩大。

你可以直接拿走的自查清单

如果你现在维护 Go 服务,别只搜代码里有没有 context.Context

那太粗了。

更应该问这几个问题。

第一,谁创建了派生 context,谁负责调用 cancel

看到 context.WithCancelWithTimeoutWithDeadline,就顺手往下看: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 写坏。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
type requestIDKey struct{}

func WithRequestID(ctx context.Context, id string) context.Context {
    return context.WithValue(ctx, requestIDKey{}, id)
}

func RequestID(ctx context.Context) (string, bool) {
    v, ok := ctx.Value(requestIDKey{}).(string)
    return v, ok
}

这不是形式主义。它是在承认 WithValue 有隐式依赖风险,所以尽量把入口收窄。

这几个问题比“有没有传 ctx”更有价值。

因为 context 的核心不是把参数传来传去,而是把生命周期边界放到能被看见、能被审查的位置。

这一组文章,其实都在讲同一件事

到这里,Go context 的几件“别扭设计”就能串起来了。

它要求你显式传 ctx,是为了让生命周期进入函数签名。

它不把 Cancel() 放进 Context,是为了拆开观察权和控制权。

它保留 WithValue,但把边界收得很窄,是为了允许请求级元数据传播,同时不鼓励你把 context 当参数袋。

它后来增加 CauseAfterFuncWithoutCancel,却不扩大四方法接口,是为了让小接口继续稳定。

这些设计没有哪一个是免费的。

你会多写参数,多写 defer cancel(),多写 select,还要忍受 WithValue 不像 map 那么顺手。

但它换来一件长期维护时更值钱的东西:权责边界可查。

上篇我们讲的是:Go 为什么宁可让你多传一个 ctx 参数。

这一篇讲的是:Go 为什么不把取消权也塞进 Context 接口。

把两篇放在一起看,答案其实很一致。

Go context 不是为了让代码更漂亮。

它是为了让调用链里那些会超时、会取消、会释放资源的东西,不要躲在暗处。

这也是我觉得 context 值得反复拆的原因。它不是一个“会用就行”的工具包,而是 Go 把工程协作写进 API 的一个样本:调用者要暴露生命周期,被调用者要尊重生命周期,库作者要克制接口承诺,业务代码要负责自己的收尾。

你越往后维护大型 Go 服务,越会发现这种啰嗦不是噪音,而是线索。

如果你还想继续看这个系列,后面可以顺着几个更具体的问题往下拆:WithValue 的链式查找到底怎么工作,cancelCtx 怎么把取消传播给 child,以及线上 goroutine 泄漏时,怎么沿着 context 找断点。

关注我,后面继续把 Go 这些“看起来啰嗦、实际很有工程味”的设计,一篇篇拆开讲。