幕 III · 三种活法CH 14进度 14/25

Kotlin:suspend 是一台状态机

协程是 Kotlin 最成功的一次推销 —— 它让异步代码读起来像同步代码,于是所有人都用上了,而没有人被要求理解它。代价是:你会在某天遇到一个诡异的 bug(取消不掉、异常抓不到、结果回到了错误的线程),而你的心智模型里没有任何东西能解释它。这一章补上那个模型。suspend 不是魔法,它是一次编译期的代码变换:编译器把你的函数改写成一台状态机,把局部变量搬进堆里的一个对象,然后在挂起点把线程还回去。看懂这台机器,接下来所有的「反直觉」都会变成「理所当然」。

ContinuationCPS 变换状态机挂起 ≠ 阻塞Dispatchers

先问一个你可能答不上来的问题

为什么能开十万个协程,而开十万个线程会 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() 被调用
能同时有多少个几千个几十万个
⚠ 陷阱 · 在协程里调 Thread.sleep 是一个严重错误

阻塞了底层线程,而那个线程本来可以在这一秒里跑几百个其他协程。

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

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 章的第 ③ 格。)
◈ 三语 · Kotlin 协程 vs Java 虚拟线程

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 对并发世界最大的一个贡献。