context:取消如何穿过整个调用树
上一章的 done channel 模式,手动传起来很累——一个请求扇出十个 goroutine、每个又调好几层,你得把 done 一路传下去。Go 把这件事标准化成了 context:一棵取消信号的树。取消最上面那个,信号顺着树往下传,扇出的所有下游 goroutine 同时收到、各自收工。这一章讲清它怎么工作,附一台取消传播树,你会发现它和 Kotlin 的 Job 树几乎是一回事。
先看这棵树
先玩下面这台 Demo,你会秒懂 context 是什么。它是一棵 context 树:根 → A = WithCancel → 下面挂着一个 goroutine w1 和一个 B = WithTimeout(3),B 下面又挂着 w2。试试点「cancel A」,再试试「时钟 +1」推到超时:
你看到的两种取消:点「cancel A」,A 和它下面的一切(w1、B、w2)一起熄灭 —— 取消顺着树往下传染了整棵子树。点「时钟 +1」到 3,B 因为是 WithTimeout(3) 自己超时了(Err 变成 deadline exceeded),只带走了它下面的 w2,而 A 和 w1 安然无恙。这就是 context 的全部直觉:取消从一个节点出发,向下传播到所有后代,但不向上影响祖先。
context 是什么
context.Context 是一个接口,你几乎只用它的一个方法:Done(),它返回一个 channel。这个 channel 平时啥也没有,一旦 context 被取消,它就被关闭 —— 而你已经知道(第 18、19 章),关闭一个 channel 会让所有 <-Done() 的等待者同时收到信号。所以 context 本质上就是上一章那个 done channel 模式的标准化封装,加上了「能组成树、自动向下传播」的能力。
// 从根 context 派生一个可取消的 ctx, cancel := context.WithCancel(context.Background()) defer cancel() // 别忘了 —— 不调 cancel 会泄漏 context 资源 // 把 ctx 传给 goroutine go worker(ctx) // 想取消时 cancel() // Done() 被关闭,所有监听 ctx 的 goroutine 收到信号
那个 goroutine 里,用 select 同时等「活」和「取消信号」:
func worker(ctx context.Context) {
for {
select {
case <-ctx.Done(): // context 被取消了
fmt.Println("收到取消,收工:", ctx.Err())
return // 干净退出,不泄漏
case job := <-jobs:
process(job)
}
}
}
看出来了吗 —— 这和第 19 章的 done channel 模式一模一样,只是把 done 换成了 ctx.Done()。context 的价值在于:它是标准的、能派生成树的、自动传播的那个 done。
派生:树是怎么长出来的
context 从一个根(context.Background())出发,通过一系列 WithXxx 函数派生出子 context,形成树。每个派生都返回一个新的子 context:
| 派生函数 | 什么时候取消 |
|---|---|
WithCancel(parent) | 你手动调 cancel() 时 |
WithTimeout(parent, 5s) | 5 秒后自动取消(或你手动 cancel) |
WithDeadline(parent, t) | 到达时刻 t 自动取消 |
取消一个 context,会自动取消它的所有子孙 context(Demo 里 cancel A 带走整棵子树)。但反过来不成立:取消一个子 context,不影响它的父和兄弟(Demo 里 B 超时只带走 w2,A 没事)。
这个「向下传播、不向上影响」的规则,正好匹配真实需求。想象一个 HTTP 请求:它扇出去查数据库、调三个下游服务、每个又起几个 goroutine。用户断开连接 → 取消这个请求的根 context → 底下所有还在跑的查询、调用、goroutine 全部收到取消信号、立刻收手,不再白白消耗资源。这就是 context 存在的头号理由:把「取消一整棵操作树」变成一次 cancel() 调用。
约定:ctx 作为第一个参数一路传
context 有一条铁打的约定:凡是可能长时间运行、或会扇出 goroutine 的函数,把 ctx context.Context 作为第一个参数接收,并一路往下传:
func handleRequest(ctx context.Context, req Request) error {
user, err := fetchUser(ctx, req.UserID) // 把 ctx 传下去
if err != nil { return err }
return processOrder(ctx, user) // 继续传
}
func fetchUser(ctx context.Context, id int) (*User, error) {
// 数据库查询也接 ctx —— 取消时查询会被中断
return db.QueryContext(ctx, "...", id)
}
这样,ctx 顺着调用链一路传到最底层(数据库驱动、HTTP 客户端都接受 context),整条链上任何一环都能响应取消。标准库的 IO、数据库、HTTP 全都支持传 context。你会觉得「到处都在传 ctx」有点啰嗦 —— 但这正是 Go 的一贯风格:把「这个操作可以被取消」这件事,显式地写在每个函数的签名里,而不是藏在某个隐式的线程上下文里。
context 还有个 WithValue(ctx, key, val),能往里塞键值对,顺着树传下去(有点像 ThreadLocal)。但它很容易被滥用。官方的明确建议:context.Value 只用来传请求作用域的元数据(比如 request ID、trace ID、认证 token 这类「贯穿整个请求、又不属于业务参数」的东西),绝不要拿它传函数本该显式接收的业务参数。因为 Value 是无类型的(key/val 都是 any)、不上签名、编译器查不了 —— 拿它传业务参数,等于把明明白白的函数参数藏进一个黑箱里,正好违背 Go「摊到明面上」的哲学。ctx 传取消,不传业务数据。
Kotlin 对照
这一章你会有强烈的既视感 —— 因为 context 树和 Kotlin 的 Job 树几乎是同一个东西。Kotlin 的结构化并发里,CoroutineScope 有一个 Job,子协程的 Job 挂在父 Job 下形成树;取消父 Job → 所有子 Job 取消;子协程用 isActive / ensureActive() / 挂起点响应取消。把这段话里的 Job 换成 context、isActive 换成 ctx.Done(),就是 Go。「取消向下传播整棵树」这个核心机制,两家是一致的。
但有个决定性的差别:谁替你传。Kotlin 的结构化并发是内建、隐式的 —— 你 launch { } 一个协程,它自动成为当前 scope 的子节点,取消传播自动发生,你几乎不用手动传 Job。Go 的 context 是外挂、显式的 —— goroutine 之间没有内建的父子关系(第 17 章说过),所以你必须手动把 ctx 作为参数一路传下去,取消才能传到。Kotlin 用语言 / 库替你织好了这张取消网,Go 把织网的线(context)交给你,让你显式地穿针引线。
迁移心法:你在 Kotlin 里靠 scope「免费」得到的取消传播,在 Go 里要靠「每个函数第一个参数收 ctx、并往下传」手动实现。一开始你会嫌这个 ctx 到处传很烦,但它的好处是完全显式 —— 看函数签名就知道它能不能被取消、取消信号从哪来。习惯它,你就掌握了 Go 写「可取消、不泄漏」的并发服务的正道。
这一章的一句话
context 是一棵取消信号的树:WithCancel/WithTimeout 派生子 context,取消一个节点会顺着 Done() 关闭传播到所有后代、但不影响祖先,专治「取消一个请求连带取消它扇出的所有下游」;约定是把 ctx 作为第一个参数一路显式传(别拿 Value 传业务参数)——它和 Kotlin 的 Job 树机制一致,区别只在 Kotlin 自动织网、Go 要你手动穿线。
并发卷还剩最后一块拼图。前面一直说「用通信代替共享内存」,但有些场合共享内存 + 锁反而更简单直接。下一章我们讲 sync 包、竞态,以及「什么时候该用锁、什么时候该用 channel」。