卷 IV · 续延CH 15深度 15/25

一个续延,三种面孔

上一章你用 call/cc 亲手造出了 return、异常、生成器。这一章把它们并排摆好,左边是 Racket 的手工版,右边是你的语言里那个「一个关键字就搞定」的成品。你会看到:异常、生成器、协程 —— 这三个你以为毫不相干、各自要单独学的东西,是同一块金属的三个切面。而这一章的终点,是把 Kotlin 的 suspend 彻底拆开 —— 那个凭空冒出来的 Continuation<T> 参数,你会知道它就是你上一章手里那个 k

异常生成器协程suspendCPS 变换

三张脸,并排看

下面这台机器,左边是 Racket 用 call/cc 手工造的,右边是 Kotlin / JS 里语言替你造好的成品。切换那三个标签,你会发现它们是同一件事。

玩过三个标签之后,我们把这条主线收紧。

它们的共同结构

三张脸,其实是「你对续延做了什么」的三个答案:

面孔对续延做了什么方向次数
异常 / return丢弃当前续延,跳到一个更外层的只往外一次性
生成器 / yield保存当前续延,交出一个值,之后恢复来回跳多次
协程 / await保存续延交给调度器,自己让出线程,结果到了再恢复来回跳(跨线程)一次(每个挂起点)

看第二列。它们都是「丢弃 / 保存 / 恢复 续延」的组合。没有第四种操作 —— 你能对「接下来要做的事」做的事,就这么几种。

◆ 为什么这三个东西「感觉」如此不同

因为你的语言给了它们三套完全不同的语法和名字:try/catchfunction*/yieldsuspend/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 层面的深度支持(花了近十年)
◆ 「函数染色」问题,和 Loom 的赌注

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、上下文丢失、泄漏的协程)本质都是「一个续延在不该被恢复的时候/地方被恢复了」。这个视角比盯着 JobScope 这些表面概念更接近病根。

下一章

卷 IV 还剩一章,处理一个我们一直在回避的东西。

第 14 章那个计数器、这一章那个生成器,都用了 set! —— 可变状态。而这本书到目前为止,一直在悄悄地避免它。

下一章正面谈它:set!box,以及「不可变」到底是一种道德洁癖,还是一笔能算清楚的账。这会直接连到你在 Compose、React、协程里天天面对的问题。