Kotlin:suspend 是一台状态机
协程是 Kotlin 最成功的一次推销 —— 它让异步代码读起来像同步代码,于是所有人都用上了,而没有人被要求理解它。代价是:你会在某天遇到一个诡异的 bug(取消不掉、异常抓不到、结果回到了错误的线程),而你的心智模型里没有任何东西能解释它。这一章补上那个模型。suspend 不是魔法,它是一次编译期的代码变换:编译器把你的函数改写成一台状态机,把局部变量搬进堆里的一个对象,然后在挂起点把线程还回去。看懂这台机器,接下来所有的「反直觉」都会变成「理所当然」。
先问一个你可能答不上来的问题
为什么能开十万个协程,而开十万个线程会 OOM?
标准答案是「协程很轻」。但「轻」在哪?
回到第 7 章:一个线程的成本主要是它的栈(1MB ~ 8MB,而且是预留的连续内存)。十万个线程 = 几十 GB 的栈空间,还没算内核的调度开销。
那协程的「栈」在哪?
—— 它没有栈。它把「需要跨越挂起点保存的东西」存进了堆上的一个普通对象,那个对象通常只有几十字节。
这一句话就是整章的核心。下面这台机器把它演给你看,逐帧点过去,第 ③ 帧是关键:
三个必须建立的认知
① 每个 suspend 函数都多了一个隐藏参数
// 你写的 suspend fun getUser(id: String): User // 编译后(Java 视角) Object getUser(String id, Continuation<? super User> cont) // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 编译器塞进来的
Continuation 是什么?它就是「接下来该干什么」的句柄 —— 一个带着 resumeWith(result) 方法的回调对象。
这解释了两件事:
- 为什么 suspend 函数只能被 suspend 函数(或协程构建器)调用 —— 因为调用方必须有一个
Continuation可以往下传。这就是第 2 章说的「染色」。 - 为什么 Java 侧调用 Kotlin 的 suspend 函数那么难看 —— 你得手动构造一个
Continuation。
你可能听过「suspend 就是把回调藏进了编译器」。这句话是真的,但只对了一半:它不只是藏起了回调,它还藏起了那台状态机。
② 挂起 ≠ 阻塞(这是全章最重要的一句)
看 demo 的第 ③ 帧:函数返回了(return COROUTINE_SUSPENDED),线程被彻底交还给线程池,去干别的活了。
阻塞(Thread.sleep、同步 IO) | 挂起(delay、suspend IO) | |
|---|---|---|
| 线程 | 被占着,什么也干不了 | 被交还,去执行别的协程 |
| 「栈帧」在哪 | 线程栈上(占着几 KB ~ 几百 KB) | 堆上的状态机对象里(几十字节) |
| 恢复靠什么 | 操作系统调度 | Continuation.resumeWith() 被调用 |
| 能同时有多少个 | 几千个 | 几十万个 |
它阻塞了底层线程,而那个线程本来可以在这一秒里跑几百个其他协程。
Dispatchers.Default 的线程数 = CPU 核心数(最少 2 个)。你在 8 核机器上阻塞 8 个协程,整个 Default 调度器就废了 —— 所有其他协程排队等死。
同类犯规(都会阻塞线程,而不是挂起):
Thread.sleep()→ 用delay()- OkHttp 的同步
call.execute()→ 用await()(Retrofit 的 suspend 函数) - JDBC 查询(JDBC 天生是阻塞的,没有异步版本)→ 只能包
withContext(Dispatchers.IO) File.readText()→ 包withContext(Dispatchers.IO)synchronized块里的等待 → 用Mutex(挂起式的)- CPU 密集的 for 循环 →
withContext(Dispatchers.Default)(并且时不时yield())
③ 恢复之后,你可能在另一个线程上
状态机被「重新调用」时,跑在哪个线程上,取决于是谁调用了 resumeWith 以及当前的调度器。同一个函数体的前半段和后半段,完全可能在两个线程上执行。
推论(都很实用):
ThreadLocal在协程里基本是坏的。挂起前存进去的东西,恢复后可能读不到(你换线程了)。需要「协程本地」的东西,用CoroutineContext(或者ThreadContextElement做桥接 —— MDC 日志上下文就是这么做的)。- 不要在协程里持有线程绑定的锁跨越挂起点。
synchronized块里不能有suspend调用(编译器会拦住你),因为锁是线程持有的,而挂起后线程都换了,解锁的会是另一个线程。协程里要互斥,用Mutex。 - 但「同一个协程内的代码是顺序执行的」仍然成立 —— 别被吓到。它只是可能换线程,不会并发执行。
suspend 把「函数的执行状态」从线程栈搬到了堆上,于是「等待」不再需要占着一个线程。
剩下的一切 —— 结构化并发、取消、Flow —— 都是建在这个搬迁之上的。
调度器:那三个(其实是四个)
调度器决定「这个协程恢复时,在哪个线程上跑」。就这么简单。
| Dispatcher | 线程数 | 用来干什么 |
|---|---|---|
Dispatchers.Default |
= CPU 核数(最少 2) | CPU 密集:JSON 解析、排序、图像处理、加密。 这是没指定调度器时的默认值。 |
Dispatchers.IO |
64(可配,且会按需增长) | 阻塞式 IO:JDBC、文件读写、老的同步 SDK。 关键:它和 Default 共享同一个线程池!( IO 只是给了一个更大的「并行度许可」。)所以 withContext(IO) ↔ withContext(Default) 之间的切换是零成本的 —— 通常不会真的换线程。 |
Dispatchers.Main |
1(UI 线程) | Android / Swing / JavaFX 的 UI 更新。Main.immediate:如果已经在主线程上了,就直接执行,不再 post 一次(省一帧)。 |
Dispatchers.Unconfined |
— | 不要用。它「在调用它的那个线程上直接跑」,行为极其反直觉。它是给库作者做优化用的。 |
suspend 函数应该「自己管好自己的线程」,而不是让调用方去包 withContext。
// ❌ 不好:把线程管理的责任推给调用方
suspend fun loadUser(id: String): User {
return db.query(id) // 阻塞的!调用方必须记得包 withContext(IO)
}
// 调用方:withContext(Dispatchers.IO) { loadUser(id) } ← 总有人会忘
// ✅ 好:自己负责
suspend fun loadUser(id: String): User = withContext(Dispatchers.IO) {
db.query(id)
}
// 调用方:loadUser(id) ← 在任何调度器上调都是安全的
这条约定叫「main-safety」:任何 suspend 函数,都应该能安全地从主线程调用。Android 官方的架构指南把它写成了硬性建议。
为什么 suspend 必须是个关键字
一个常见的疑问:既然是编译器变换,为什么不能自动推断?为什么非要我手写 suspend?
因为它改变了函数的调用契约:这个函数可能不会立刻返回,它可能把控制权交出去、过一会儿再回来。调用它的人必须知道这件事(因为他自己也得能被挂起)。
Kotlin 没有消灭「函数染色」这个问题,它只是让染色便宜且诚实:
- 诚实:签名上写着
suspend,你一眼知道这里可能会等。 - 便宜:函数体里的代码看起来和同步代码一模一样 —— 没有
.then()、没有满屏的await、没有回调嵌套。
对比 Python:那里每个异步调用点都必须写 await,而 Kotlin 里挂起点是隐式的 —— api.getUser(id) 看起来就是个普通调用。
这个设计有个直接后果,会在下一章咬你:你看不出一段代码里哪里可能被取消。
suspend fun sync() {
val a = api.load() // ← 可能在这里被取消
db.save(a) // ← 也可能在这里
analytics.report() // ← 还可能在这里
}
每一个 suspend 调用都是一个潜在的取消点(因为取消检查发生在挂起点)。
所以「这段代码要么全做完、要么全不做」这种假设是不成立的 —— 除非你显式用 withContext(NonCancellable) 保护它(第 22 章)。
而 Python 至少让你看得见 await 在哪里。这是 Kotlin 为「代码好看」付出的一个真实代价。
那 runBlocking 呢
它是桥:把普通世界和协程世界连起来 —— 它阻塞当前线程,直到里面的协程跑完。
| 场景 | 判决 |
|---|---|
| 单元测试里 | ✓ 用 runTest(更好,它能跳过 delay,测试瞬间跑完) |
main() 函数里 | ✓ 这是它的正当用途 |
| Android / 服务端的生产代码里 | ✗ 几乎永远是错的。你在用一个「不阻塞线程」的框架,然后阻塞了一个线程。如果那个线程是主线程 —— ANR。 |
| 为了「在非 suspend 函数里调 suspend 函数」 | ✗ 这是最常见的滥用。正确做法是把调用方也变成 suspend,或者启动一个协程(viewModelScope.launch)。看到 runBlocking 就该问:我是不是在逃避 suspend 的染色?(第 3 章的第 ③ 格。) |
Java 走了完全不同的一条路:不改语言,改运行时(第 7 章)。虚拟线程是由 JVM 调度的轻量级线程 —— 你照常写阻塞式代码,JVM 在底层把它「挂起」,把载体线程还回去。
| Kotlin 协程 | Java 虚拟线程 | |
|---|---|---|
| 实现层 | 编译器(CPS 变换) | JVM 运行时(栈的挂载/卸载) |
| 函数染色 | 有(suspend) | 无 —— 现有的阻塞代码直接受益,这是它最大的优势 |
| 结构化并发 | 内建(Job 树、自动取消,下一章) | 需要 StructuredTaskScope(JDK 25 里仍是预览) |
| 取消 | 一等公民、协作式、自动向下传播 | 靠 interrupt,还是老一套 |
| Android | 能用(协程是纯库 + 编译器插件) | 不能(ART 不是 HotSpot) |
公平地说,虚拟线程在「无染色」这一点上确实更优雅。但它没有解决结构化并发和取消这两个真正难的问题(Java 正在补,但 API 还在预览)。而在 Android 上,这个对比是没有意义的:你只有协程。
1. 反编译一个 suspend 函数。(IntelliJ:Tools → Kotlin → Show Kotlin Bytecode → Decompile。)找到那个 label 字段、那个 switch、那个 COROUTINE_SUSPENDED。亲眼看一次,这台机器就再也不神秘了。
2. 搜项目里的 runBlocking。生产代码里的每一处都要给出理由 —— 大概率是在逃避 suspend 染色,而它的代价是阻塞了一个线程。
3. 搜协程内的 Thread.sleep、同步 IO、以及 synchronized 块。它们全都在阻塞而不是挂起。
4. 搜 ThreadLocal。如果它被跨越挂起点使用 —— 那是个正在等待复现的 bug。
下一章:这台状态机不是孤立运行的,它挂在一棵树上。Job 树 —— 以及为什么一个子协程失败会把兄弟们全拖死。这是 Kotlin 对并发世界最大的一个贡献。