suspend 的真身:CPS 变换与状态机
协程是 Kotlin 最成功的推销 —— 它让异步代码读起来像同步代码,于是所有人都用上了,而没有人被要求理解它。代价是:你会在某天遇到一个诡异的 bug(取消不掉、异常抓不到、结果回到了错误的线程),而你的心智模型里没有任何东西能解释它。这一章补上那个模型。suspend 不是魔法,它是一次编译期的代码变换:编译器把你的函数改写成一台状态机,把局部变量搬进堆里的一个对象,然后在挂起点把线程还回去。看懂这台机器,后面五章的所有「反直觉」都会变成「理所当然」。
先问一个你可能答不上来的问题
为什么能开十万个协程,而开十万个线程会 OOM?
标准答案是「协程很轻」。但「轻」在哪?一个线程的成本主要是它的栈(默认 512KB ~ 1MB,而且是预留的连续内存)。十万个线程 = 几十 GB 的栈空间,还没算内核的调度开销。
那协程的「栈」在哪? —— 它没有栈。它把「需要跨越挂起点保存的东西」存进了堆上的一个普通对象,那个对象通常只有几十字节。
这一句话就是整章的核心。下面这台机器把它演给你看,逐帧点过去(第 3 帧是关键):
三个必须建立的认知
① 每个 suspend 函数都多了一个隐藏参数
// 你写的 suspend fun getUser(id: String): User // 编译后(Java 视角) Object getUser(String id, Continuation<? super User> cont) // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 编译器塞进来的
Continuation 是什么?它就是「接下来该干什么」的句柄 —— 一个带着 resumeWith(result) 方法的回调对象。
这解释了两件事:为什么 suspend 函数只能被 suspend 函数(或协程构建器)调用(因为调用方必须有一个 Continuation 可以往下传);以及为什么 Java 侧调用 Kotlin 的 suspend 函数那么难看(你得手动构造一个 Continuation)。
「suspend 就是把回调藏进了编译器」 —— 这句话是真的,但只对了一半。它不只是藏起了回调,它还藏起了那台状态机。
② 挂起 ≠ 阻塞(这是全章最重要的一句)
看 demo 的第 3 帧:函数返回了(return COROUTINE_SUSPENDED),线程被彻底交还给线程池,去干别的活了。
阻塞(Thread.sleep、同步 IO) | 挂起(delay、suspend IO) | |
|---|---|---|
| 线程 | 被占着,什么也干不了 | 被交还,去执行别的协程 |
| 「栈帧」在哪 | 线程栈上(占着几 KB ~ 几百 KB) | 堆上的状态机对象里(几十字节) |
| 恢复靠什么 | 操作系统调度 | Continuation.resumeWith() 被调用 |
| 能同时有多少个 | 几千个(受内存和调度限制) | 几十万个 |
所以:在协程里调用 Thread.sleep(1000) 是一个严重错误 —— 它阻塞了底层线程,而那个线程本来可以在这一秒里跑几百个其他协程。(Dispatchers.Default 的线程数 = CPU 核心数,阻塞几个就废掉一大半算力。)
③ 恢复之后,你可能在另一个线程上
状态机被「重新调用」时,跑在哪个线程上,取决于是谁调用了 resumeWith 以及当前的调度器。同一个函数体的前半段和后半段,完全可能在两个线程上执行。
推论(都很实用):
- ThreadLocal 在协程里基本是坏的。挂起前存进去的东西,恢复后可能读不到(你换线程了)。需要「协程本地」的东西,用
CoroutineContext(或者ThreadContextElement做桥接)。 - 不要在协程里持有线程绑定的锁跨越挂起点 ——
synchronized块里不能有suspend调用(编译器会拦住你),因为锁是线程持有的,而挂起后线程都换了,解锁的会是另一个线程。协程里要互斥,用Mutex(它是挂起式的)。 - 「同一个协程内的代码是顺序执行的」仍然成立 —— 别被吓到。它只是可能换线程,不会并发执行。
suspend 把「函数的执行状态」从线程栈搬到了堆上,于是「等待」不再需要占着一个线程。
剩下的一切 —— 结构化并发、取消、Flow —— 都是建在这个搬迁之上的。
为什么 suspend 必须是个关键字
一个常见的疑问:既然是编译器变换,为什么不能自动推断?为什么非要我手写 suspend?
因为它改变了函数的调用契约:这个函数可能不会立刻返回,它可能把控制权交出去、过一会儿再回来。调用它的人必须知道这件事(因为他自己也得能被挂起)。
suspend 是一种颜色(业界那篇著名的 What Color is Your Function? 说的就是这件事:异步函数会「染色」整条调用链)。Kotlin 没有消灭这个染色问题,它只是让染色便宜且诚实:
- 诚实:签名上写着
suspend,你一眼知道这里可能会等。 - 便宜:函数体里的代码看起来和同步代码一模一样 —— 没有
.then()、没有await关键字撒得到处都是、没有回调嵌套。
对比一下 JS 的 async/await:那里每个异步调用点都要写 await,而 Kotlin 里挂起点是隐式的 —— api.getUser(id) 看起来就是个普通调用。这是个有争议的设计(有人认为挂起点应该显式可见),但它换来了「异步代码读起来像同步代码」这个最大卖点。
这个设计有个直接后果,会在第 15 章咬你:你看不出一段代码里哪里可能被取消。
suspend fun sync() {
val a = api.load() // ← 可能在这里被取消
db.save(a) // ← 也可能在这里
analytics.report() // ← 还可能在这里
}
每一个 suspend 调用都是一个潜在的取消点(因为取消检查发生在挂起点)。所以「这段代码要么全做完、要么全不做」这种假设是不成立的 —— 除非你显式用 withContext(NonCancellable) 保护它(第 15 章)。
那 runBlocking 呢
它是桥:把普通世界和协程世界连起来 —— 它阻塞当前线程,直到里面的协程跑完。
| 场景 | 判决 |
|---|---|
| 单元测试里 | ✓ 用 runTest(更好,它能跳过 delay,测试瞬间跑完) |
main() 函数里 | ✓ 这是它的正当用途 |
| Android 的任何生产代码里 | ✗ 几乎永远是错的。你在用一个「不阻塞线程」的框架,然后阻塞了一个线程。如果那个线程是主线程 —— ANR |
| 为了「在非 suspend 函数里调 suspend 函数」 | ✗ 这是最常见的滥用。正确做法是把调用方也变成 suspend,或者启动一个协程(viewModelScope.launch)。看到 runBlocking 就该问:我是不是在逃避 suspend 的染色? |
Java 走了完全不同的一条路:不改语言,改运行时。虚拟线程是由 JVM 调度的轻量级线程 —— 你照常写阻塞式代码(Thread.sleep、阻塞 IO),JVM 在底层把它「挂起」,把载体线程还回去。
| Kotlin 协程 | Java 虚拟线程 | |
|---|---|---|
| 实现层 | 编译器(CPS 变换) | JVM 运行时(栈的挂载/卸载) |
| 函数染色 | 有(suspend) | 无 —— 现有的阻塞代码直接受益,这是它最大的优势 |
| 结构化并发 | 内建(Job 树、自动取消,第 15 章) | 需要额外的 StructuredTaskScope API |
| 取消 | 一等公民、协作式、自动向下传播 | 靠 interrupt,仍然是老一套 |
| Android 可用性 | 是(协程是纯库 + 编译器) | 否(需要新版 JVM,Android 的 ART 没有) |
公平地说,虚拟线程在「无染色」这一点上确实更优雅 —— 它让几十年的阻塞式代码一夜之间变得可扩展。但它没有解决结构化并发和取消这两个真正难的问题(Java 正在补,但 API 还在预览)。而在 Android 上,这个对比是没有意义的:你只有协程。
1. 反编译一个 suspend 函数(第 2 章的开关)。找到那个 label 字段、那个 switch、那个 COROUTINE_SUSPENDED。亲眼看一次,这台机器就再也不神秘了。
2. 搜项目里的 runBlocking。生产代码里的每一处都要给出理由 —— 大概率是在逃避 suspend 染色,而它的代价是阻塞了一个线程。
3. 搜项目里协程内的 Thread.sleep、同步 IO(File.readText、老的 HttpURLConnection)、以及 synchronized 块。它们全都在阻塞而不是挂起。
4. 搜 ThreadLocal。如果它被跨越挂起点使用 —— 那是个正在等待复现的 bug。
下一章:这台状态机不是孤立运行的,它挂在一棵树上。Job 树 —— 以及为什么一个子协程失败会把兄弟们全拖死。