一个续延,三种面孔
上一章你用 call/cc 亲手造出了 return、异常、生成器。这一章把它们并排摆好,左边是 Racket 的手工版,右边是你的语言里那个「一个关键字就搞定」的成品。你会看到:异常、生成器、协程 —— 这三个你以为毫不相干、各自要单独学的东西,是同一块金属的三个切面。而这一章的终点,是把 Kotlin 的 suspend 彻底拆开 —— 那个凭空冒出来的 Continuation<T> 参数,你会知道它就是你上一章手里那个 k。
三张脸,并排看
下面这台机器,左边是 Racket 用 call/cc 手工造的,右边是 Kotlin / JS 里语言替你造好的成品。切换那三个标签,你会发现它们是同一件事。
玩过三个标签之后,我们把这条主线收紧。
它们的共同结构
三张脸,其实是「你对续延做了什么」的三个答案:
| 面孔 | 对续延做了什么 | 方向 | 次数 |
|---|---|---|---|
| 异常 / return | 丢弃当前续延,跳到一个更外层的 | 只往外 | 一次性 |
| 生成器 / yield | 保存当前续延,交出一个值,之后恢复它 | 来回跳 | 多次 |
| 协程 / await | 保存续延交给调度器,自己让出线程,结果到了再恢复 | 来回跳(跨线程) | 一次(每个挂起点) |
看第二列。它们都是「丢弃 / 保存 / 恢复 续延」的组合。没有第四种操作 —— 你能对「接下来要做的事」做的事,就这么几种。
因为你的语言给了它们三套完全不同的语法和名字:try/catch、function*/yield、suspend/await。你是分三次学的它们,每次都当成一个新东西。
但在续延这个层面,它们是一个东西。Racket 不给它们三套语法 —— 它只给你 call/cc,然后你发现「诶,用它能做出异常」「诶,也能做出生成器」。
统一的视角不是为了炫耀简洁。它的实际价值是:当你彻底理解了其中一个,你就理解了全部三个。而大多数人学了三遍,还是觉得它们是三个东西。
正面拆解:Kotlin 的 suspend
现在兑现这一卷开头的承诺。第 1 章题 6:suspend 函数编译后,签名多了一个 Continuation<T>。我们把这件事从头到尾看清楚。
你写的:
suspend fun loadUser(id: String): User {
val json = api.fetch(id) // ← 挂起点 1
val user = parse(json)
return user
}
编译器做的第一件事,是给它加一个参数:
fun loadUser(id: String, cont: Continuation<User>): Any // ↑ 凭空多出来的。它就是这个函数的续延。
这个 cont,就是上一章你手里那个 k。它代表「loadUser 算完之后,接下来要做的一切」。这不是类比 —— Continuation 这个类型的名字,字面翻译就是「续延」,JetBrains 没有藏着掖着。
Continuation 接口长这样,朴素得惊人:
interface Continuation<in T> {
val context: CoroutineContext
fun resumeWith(result: Result<T>) // ← 「恢复这个续延」= 「调用 k」
}
resumeWith 就是「调用续延」。你上一章写 (k value),Kotlin 写 cont.resumeWith(Result.success(value)) —— 一模一样的动作。
那台状态机:被优化过的 CPS
如果每个挂起点都真的分配一个闭包(纯 CPS 的做法),会很费。Kotlin 编译器用了一个聪明的优化:把整个函数压成一台状态机。
// loadUser 编译后(大幅简化的示意)
fun loadUser(id: String, cont: Continuation<User>): Any {
val sm = cont as? LoadUserSM ?: LoadUserSM(cont)
when (sm.label) {
0 -> {
sm.label = 1 // 记下「下次从第 1 步继续」
return api.fetch(id, sm) // 把自己(sm)作为续延传下去,然后 return 让出线程
}
1 -> {
val json = sm.result as String // 恢复时,结果在这里
val user = parse(json)
sm.cont.resumeWith(Result.success(user)) // 调用原来的续延
return user
}
}
}
① label 就是「走到哪了」。纯 CPS 用一层层嵌套的闭包记录进度(第 13 章那条闭包链);状态机把它压成了一个整数。第 0 步是挂起前,第 1 步是恢复后。「续延」在这里被优化成了「一个 label + 一些保存的局部变量」。
② 挂起 = return。看第 0 步的 return api.fetch(id, sm) —— 函数真的返回了,线程被还回去了。它没有阻塞等待。这就是「非阻塞」的字节码真相:挂起就是提前 return。
③ 恢复 = 带着 label 再进来一次。结果到了,调度器再次调用 loadUser,但这次 sm.label 是 1,于是直接跳到第 1 步,从 val json = … 继续。「从挂起处恢复」= 「用一个不同的 label 重新进函数」。
这台状态机,就是《坩埚》第 14 章那台「suspend 状态机透视」demo 画的东西。如果你读过那本书,这里是同一台机器的另一个视角 —— 那边讲的是「它长什么样」,这边讲的是「它为什么长这样」:因为它是被优化过的 CPS,而 CPS 是续延的显式化。
连起来:从 call/cc 到你每天的异步代码
续延(第 13 章:「接下来要做的事」)
↓ 显式地传来传去
CPS(第 13 章:每个函数多收一个 k;你见过它,它叫回调地狱)
↓ 把 k 当一等值,能存、能跳
call/cc(第 14 章:手工造 return / 异常 / 生成器)
↓ 编译器把它约束成「有界、一次性」并自动管理
suspend / async-await(本章:Continuation + 状态机)
↓ 你每天写的
val user = api.loadUser(id) —— 看起来像同步,底下是续延在跳。
你每天写的那行朴素的挂起调用,底下压着这一整条血脉。而现在,你能一路读到底了。
另一条路:Java 的虚拟线程(Loom)
值得对照一下,因为它揭示了一个深刻的设计岔路。Kotlin 协程和 Java 的虚拟线程(Project Loom,Java 21 转正),解决的是同一个问题 —— 「让一大堆并发任务不必各占一个昂贵的系统线程」—— 但走了相反的两条路。
| Kotlin 协程 | Java 虚拟线程(Loom) | |
|---|---|---|
| 续延在哪 | 编译器把它编进状态机(本章那台) | 运行时把整个调用栈搬到堆上 |
| 你要标注吗 | 要 —— suspend 关键字(「函数染色」) | 不要 —— 普通阻塞代码原样就能跑 |
| 挂起的粒度 | 编译期确定的挂起点 | 运行时任意阻塞调用处 |
| 代价 | 「函数颜色」传染(suspend 会往上蔓延) | 需要 JVM 层面的深度支持(花了近十年) |
Kotlin 的 suspend 有个著名的痛点,叫「函数颜色」(function coloring):一个函数一旦是 suspend 的,调用它的函数往往也得是 suspend,这个「颜色」会沿调用链往上传染。这正是本章开头那条规则的后果 —— 因为 suspend 函数需要一个 Continuation 参数,而这个参数只能由另一个 suspend 上下文提供。
Loom 的赌注是:干脆不染色。它不在编译期做 CPS 变换,而是让 JVM 在运行时,当一个虚拟线程阻塞时,把它的整个栈从系统线程上卸下来、搬到堆上,腾出系统线程去跑别的虚拟线程。这本质上就是本书讲的续延 —— 只不过是在运行时、由 VM 捕获整个栈,而不是编译期由编译器编成状态机。代价是它需要 JVM 底层的深度改造(所以 Loom 做了将近十年),回报是你的老代码、老库一行不改就能变高并发。
同一个概念(续延),两种实现哲学(编译期 vs 运行时),各有得失。Go 的 goroutine 走的是和 Loom 类似的路(运行时管理栈,无染色)。知道它们底下是同一个东西,你评估「协程 vs 虚拟线程 vs goroutine」时就不会被表面语法迷惑 —— 你会直接问那三个关键问题:续延在哪捕获?谁来恢复它?染不染色?
回流
协程有一堆规则,大多数人靠背。但每一条都是「续延」的直接推论 —— 你现在能自己推出来:
| 那条要背的规则 | 它的续延解释 |
|---|---|
suspend 函数只能在 suspend 函数或协程里调用 | 因为它需要一个 Continuation 参数,而只有这些上下文能提供 |
普通 lambda 里不能调 suspend(除非 suspend lambda) | 普通 lambda 的签名里没有那个多出来的续延参数 |
suspend 函数「返回」可能在另一个线程 | 恢复它的是调度器,调度器爱在哪个线程恢复就在哪个 |
协程取消时,挂起点会抛 CancellationException | 取消 = 不再恢复那个续延,而是用异常「丢弃」它(异常也是一种续延操作!) |
withContext 切线程为什么「便宜」 | 它只是让恢复发生在另一个 dispatcher 上,没有真的阻塞任何线程 |
为什么 finally 里的挂起调用可能不执行 | 取消时续延被「异常式丢弃」,和第 14 章说的 finally 与续延丢弃的关系一样 |
这就是这整卷的回报:六条要背的规则,坍缩成一个要懂的概念。
还有一个直接的实践建议:调试协程时,盯住「续延在哪、什么时候被恢复、被谁恢复」。大多数协程 bug(更新已销毁的 UI、上下文丢失、泄漏的协程)本质都是「一个续延在不该被恢复的时候/地方被恢复了」。这个视角比盯着 Job、Scope 这些表面概念更接近病根。
下一章
卷 IV 还剩一章,处理一个我们一直在回避的东西。
第 14 章那个计数器、这一章那个生成器,都用了 set! —— 可变状态。而这本书到目前为止,一直在悄悄地避免它。
下一章正面谈它:set!、box,以及「不可变」到底是一种道德洁癖,还是一笔能算清楚的账。这会直接连到你在 Compose、React、协程里天天面对的问题。