卷 III · 挂起CH 09深度 9/24

suspend 只是把回调藏起来了

这句话听起来很扫兴,而且它确实就是全部真相。但把「怎么藏」拆开看,你会顺便得到三个答案:协程为什么能被取消、为什么能跨线程接着跑、为什么局部变量必须从栈上搬到堆上。

CPSContinuation状态机活跃变量

先看一眼签名

这个函数:

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 循环——运行时根本没有插手的机会。这是下一章的主题,也是线上「取消了但没停」的全部原因。

✎ 那 suspend 函数一定会挂起吗

不一定,而且大多数时候不会。

看生成代码里挂起点之后那两行:

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

这一章的一句话

suspend 把函数改写成一台状态机:多一个 Continuation 参数、按挂起点切成几段、跨段的局部变量搬进字段。「挂起」就是提前 return,把线程还回去——所以取消只能在挂起点上生效。

下一章:单个协程搞清楚了,下一个问题是一堆协程之间的关系。结构化并发把它们组织成一棵树,而这棵树有一个很多人第一次见会觉得过分的性质——一个孩子失败,全家陪葬