suspend 只是把回调藏起来了
这句话听起来很扫兴,而且它确实就是全部真相。但把「怎么藏」拆开看,你会顺便得到三个答案:协程为什么能被取消、为什么能跨线程接着跑、为什么局部变量必须从栈上搬到堆上。
先看一眼签名
这个函数:
suspend fun load(id: Int): User
编译之后的实际签名是:
fun load(id: Int, cont: Continuation<User>): Any?
两处变化,两件事全在这儿了:
- 多了一个参数
Continuation——「等我算完了,调用它」。这就是回调。 - 返回类型变成了
Any?——因为它现在有两种可能:真的算出了User,或者返回一个特殊标记COROUTINE_SUSPENDED,意思是「我挂起了,别等我,结果以后从cont给你」。
这种「把后续工作作为参数传进去」的变换,有个正式名字叫 CPS(Continuation-Passing Style)。
到这里,「suspend 就是回调」这句话已经讲完了。有意思的是下半段:函数体怎么办。
函数体被切成了一台状态机
问题是这样的:一个函数中间挂起了,它真的 return 出去了——栈帧没了,局部变量没了,「跑到第几行」这个信息也没了。等结果回来的时候,怎么接着往下跑?
答案:把函数体按挂起点切成几段,每段一个编号,用一个 when 分发;把跨段还要用的局部变量搬进一个对象的字段里。
下面这台变换器是真的:它会分析你给的代码,找出挂起点,做活跃变量分析算出要塞几个字段,然后生成状态机骨架。三个例子的结果不一样,请挨个切换:
第一个例子的结果:
suspend fun load(id: Int): User
挂起点 2 个
when 分支 3 个(0、1、2)
L$ 字段 2 个(保存 id 和 user)
为什么正好是这些数?看生成出来的代码,规律很清楚:
- n 个挂起点 → n+1 个 label。挂起点把函数体切成 n+1 段。
- L$ 字段的个数 = 需要跨过某个挂起点活下来的局部变量个数。
那两个 L$ 字段是怎么算出来的
这是这一章最值得记住的一点,因为它解释了「协程为什么会分配对象」。
规则:一个局部变量需要被搬进字段,当且仅当它在某个挂起点之前有值、在这个挂起点之后还要用。这在编译原理里叫活跃变量分析(liveness analysis)。
suspend fun load(id: Int): User {
val user: User = api.fetchUser(id) // 挂起点 1
val posts: List<Post> = api.fetchPosts(user.id) // 挂起点 2
return user.copy(posts = posts, from = id)
}
| 变量 | 挂起点 1 之前有值? | 之后还用? | 挂起点 2 之前有值? | 之后还用? | 要字段吗 |
|---|---|---|---|---|---|
id | 有(参数) | 用(最后一行) | 有 | 用 | 要 |
user | 还没有 | — | 有 | 用(最后一行) | 要 |
posts | 还没有 | — | 还没有 | — | 不用 |
切到 demo 的第三个例子(「中间夹着普通代码」),它有 4 个字段——因为中间那些普通计算产生的中间结果也得活下来。挂起点越靠后、跨得越多,状态机对象就越胖。
「挂起」= 这个函数真的 return 了,线程还给别人了。
生成代码里那一行就是全部:
if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
它没有阻塞任何线程、没有开新线程、没有做任何魔法。它只是提前返回,把「接下来该干什么」留在了那个状态机对象里。
等结果回来时,某个线程(不一定是原来那个)拿着状态机调用 resumeWith,函数被重新调用一次,靠 label 跳到该去的分支,从字段里把局部变量捞回来,接着往下走。
三个「原来如此」
为什么协程能跨线程接着跑
因为「跑到哪儿了」和「局部变量」全都在堆上的那个状态机对象里,不在任何线程的栈上。任何线程拿到这个对象都能接着跑。
这也解释了为什么 withContext(Dispatchers.IO) { } 前后你的代码看起来是连续的——它确实是连续的,只是中间换了个线程来执行下一段。
为什么协程比线程便宜
一个线程要一块栈(Android 上默认几百 KB 到 1 MB),而一个挂起的协程只是堆上的一个小对象——大小取决于它有几个 L$ 字段,通常几十字节。
所以「开十万个协程」是合理的,「开十万个线程」不是。这不是优化,是数据结构上的差别。
为什么挂起点是取消能生效的唯一地方
每次 resumeWith 被调用、准备接着往下跑之前,运行时会先看一眼这个协程被取消了没有。取消了就不再往下走,直接抛 CancellationException。
所以取消只能发生在挂起点上。一段没有任何挂起点的代码——比如一个纯计算的 while 循环——运行时根本没有插手的机会。这是下一章的主题,也是线上「取消了但没停」的全部原因。
不一定,而且大多数时候不会。
看生成代码里挂起点之后那两行:
val r = api.fetchUser(id, sm) if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED sm.result = r // ← 没挂起,直接往下走
如果被调用的函数当场就有结果(缓存命中、channel 里已经有值、delay(0)),它会直接返回值,连状态机都不用切。这叫快速路径。
这也是为什么 suspend 的开销经常低到测不出来:不挂起的时候,它和一次普通函数调用差不多。
suspend 函数不保证切线程。这句话很多人记反了:
// ❌ 以为加了 suspend 就不会卡主线程
suspend fun compressImage(bmp: Bitmap): ByteArray {
return heavyCompress(bmp) // 纯 CPU,没有任何挂起点
}
// 在 Main 上调用它 → 主线程被占满 → 掉帧
suspend 只是给了运行时「可以切」的机会,切不切要看你写没写 withContext。
这条规矩叫主线程安全:一个 suspend 函数应该保证「在任何线程上调用它都是安全的」,办法是它自己在内部 withContext 到合适的调度器,而不是要求调用方记得切。第 12 章会把这笔账量出来。
顺带看懂几个报错
理解了 CPS,几个常见的编译错误就变得很自然了:
| 报错 | 真正的原因 |
|---|---|
| Suspend function should be called only from a coroutine body | 调用它要传 Continuation。普通函数手上没有那个东西 |
在 forEach { } 里不能调用 suspend 函数 | forEach 的参数不是 suspend lambda。改用 for 循环,或者用 inline 的那些函数 |
Suspension functions can be called only within coroutine body(在 synchronized 块里) | 挂起会 return 出去,锁根本释放不掉。用 Mutex.withLock |
《坩埚》第 14 章把这套变换从字节码层面讲了一遍,还有 CoroutineContext 是怎么串起来的。《同时》第 14 章则把它和 Python 的 async/await、Go 的 goroutine 放在一起对比——三门语言,三种切法。
这本书往下走的方向不一样:我们只关心它在 Android UI 上的后果——什么时候会被取消、会不会卡住那一帧。
这一章的一句话
suspend 把函数改写成一台状态机:多一个 Continuation 参数、按挂起点切成几段、跨段的局部变量搬进字段。「挂起」就是提前 return,把线程还回去——所以取消只能在挂起点上生效。
下一章:单个协程搞清楚了,下一个问题是一堆协程之间的关系。结构化并发把它们组织成一棵树,而这棵树有一个很多人第一次见会觉得过分的性质——一个孩子失败,全家陪葬。