卷 V · 并发CH 20深度 20/24

context:取消如何穿过整个调用树

本章卸下手动传 done channel线程本地存储 ThreadLocal

上一章的 done channel 模式,手动传起来很累——一个请求扇出十个 goroutine、每个又调好几层,你得把 done 一路传下去。Go 把这件事标准化成了 context:一棵取消信号的树。取消最上面那个,信号顺着树往下传,扇出的所有下游 goroutine 同时收到、各自收工。这一章讲清它怎么工作,附一台取消传播树,你会发现它和 Kotlin 的 Job 树几乎是一回事。

context取消传播WithTimeoutDone()对照 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 能带值,但别拿它当参数袋子

context 还有个 WithValue(ctx, key, val),能往里塞键值对,顺着树传下去(有点像 ThreadLocal)。但它很容易被滥用。官方的明确建议:context.Value 只用来传请求作用域的元数据(比如 request ID、trace ID、认证 token 这类「贯穿整个请求、又不属于业务参数」的东西),绝不要拿它传函数本该显式接收的业务参数。因为 Value 是无类型的(key/val 都是 any)、不上签名、编译器查不了 —— 拿它传业务参数,等于把明明白白的函数参数藏进一个黑箱里,正好违背 Go「摊到明面上」的哲学。ctx 传取消,不传业务数据。

Kotlin 对照

⇄ context 树 vs CoroutineScope / Job 树

这一章你会有强烈的既视感 —— 因为 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」。