引言
你已经学会了启动协程、取消协程、划定协程疆域。你写出了优雅的异步代码,UI 丝滑流畅,再也没有 ANR 的困扰。
但内心深处,有一个问题如鲠在喉:
delay(2000)的那两秒钟,线程到底在干什么?编译器凭什么能让一段代码暂停、再恢复?我写的suspend关键字,最后变成了什么东西?
这并非钻牛角尖。不理解挂起的本质,你就无法真正信任协程。当堆栈追踪里出现陌生的 Continuation 时,你会手足无措;当同事问起“为什么挂起函数不阻塞线程”时,你只能含糊其辞。
今天,我们将踏入炼气境的终极关卡——原理熔炉。这不是 API 的浮光掠影,而是一场深入编译器和运行时底层的硬核内视。你将亲眼看到:
- 你写的每一个
suspend fun,在编译后被切片成了什么模样。 - 那台叫做 CPS(Continuation Passing Style) 的编译器机器是如何工作的。
- 为什么
delay可以让出线程,而Thread.sleep不行。
系好安全带。我们要潜入字节码层,直视挂起函数的“源代码”。
操千曲而后晓声,观千剑而后识器。虐它千百遍方能通晓其真意。
先看现象:挂起时线程到底在做什么?
在深入原理之前,我们用一段可观测的代码,再次确认那个核心现象。
import kotlinx.coroutines.*
fun main() = runBlocking {
println("🧵 主线程:${Thread.currentThread().name}")
val job = launch(Dispatchers.Default) {
println("🚀 协程启动,线程:${Thread.currentThread().name}")
delay(2000)
println("✅ 协程恢复,线程:${Thread.currentThread().name}")
}
// 在 delay 期间,主线程并没有被阻塞,继续执行
println("📢 主线程继续执行其他任务...")
delay(500)
println("📢 主线程还在跑...")
delay(500)
println("📢 主线程依然活跃...")
job.join()
println("🏁 全部完成")
}
运行这段代码,你会发现:
- 协程启动时在某一线程(如
DefaultDispatcher-worker-1)。 delay(2000)期间,主线程继续执行,打印了三行日志。- 2 秒后,协程恢复,可能运行在另一个线程上(也可能是同一个)。
关键事实:delay 期间,执行协程的那个线程被释放了。 它没有被阻塞在 delay 内部,而是返回了线程池,去执行其他任务。2 秒后,系统通过回调唤醒协程,调度器再为它分配一个线程继续执行。
sequenceDiagram
participant Caller as 🧵 调用方线程
participant Coroutine as ⚡ 协程
participant Dispatcher as 🎛️ 调度器
participant Timer as ⏲️ 系统定时器
rect rgb(227, 242, 253)
Caller->>Coroutine: 调用挂起函数
Coroutine->>Dispatcher: 请求挂起
Dispatcher->>Timer: 注册定时器
Dispatcher-->>Caller: 立即返回 COROUTINE_SUSPENDED
Note over Caller: 线程被释放,去执行其他任务
end
rect rgb(255, 243, 224)
Timer-->>Dispatcher: 2秒后,回调触发
Dispatcher->>Dispatcher: 将续体包装成任务
Dispatcher->>Caller: 任务放入线程队列
Caller->>Coroutine: 恢复执行,从挂起点之后继续
end
现在,我们要追问:编译器是如何实现这种“暂停-恢复”能力的?
CPS 续体传球:用接力赛理解编译器的魔法
一个通俗的类比
想象你正在参加一场 4×100 米接力赛。
- 传统函数调用:就像一个人跑完全程。他起跑、冲刺、到达终点。如果他中途需要喝水(阻塞),整个赛道就被他堵死了。
- 挂起函数:就像一场接力赛。第一棒选手跑到中途某个点(挂起点),把接力棒(续体
Continuation)交给下一棒,然后自己退出赛道(释放线程)。第二棒选手接过棒子,从那个点继续跑。
Kotlin 编译器做的事情,就是把你的挂起函数切分成若干段,每一段对应接力赛的一棒。这个“接力棒”,在字节码层面叫做 Continuation。
什么是 CPS?
CPS(Continuation Passing Style,续体传递风格) 是一种编程范式:函数不通过
return返回值,而是将结果传递给一个回调函数(Continuation)。Kotlin 协程的挂起函数在编译后,会被自动转换为 CPS 风格。
在 CPS 的世界里,一个挂起函数 suspend fun foo(): String 编译后的签名大致等价于:
// 编译后的伪代码
Object foo(Continuation<? super String> completion);
- 多出来的
Continuation参数,就是那个“接力棒”。 - 返回的
Object,要么是真正的结果"Hello",要么是一个特殊的标记COROUTINE_SUSPENDED,表示“我还没跑完,线程你先拿去用”。
flowchart LR
subgraph 源码层["📝 你写的 Kotlin 代码"]
SF["suspend fun fetchData(): String"]
end
subgraph 编译层["⚙️ 编译器转换 CPS"]
CF1["Object fetchData(Continuation c)"]
CF2["内部生成状态机"]
end
subgraph 运行时["🔄 运行时行为"]
RT1["挂起时返回 COROUTINE_SUSPENDED"]
RT2["恢复时调用 c.resumeWith(result)"]
end
SF --> CF1
CF1 --> CF2
CF2 --> RT1
CF2 --> RT2
style 源码层 fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px
style 编译层 fill:#fff3e0,stroke:#f57c00,stroke-width:2px
style 运行时 fill:#e3f2fd,stroke:#1976d2,stroke-width:2px
style SF fill:#c8e6c9,stroke:#388e3c,stroke-width:2px
style CF1 fill:#ffb74d,stroke:#e65100,stroke-width:2px
style CF2 fill:#ffb74d,stroke:#e65100,stroke-width:2px
style RT1 fill:#90caf9,stroke:#1565c0,stroke-width:2px
style RT2 fill:#90caf9,stroke:#1565c0,stroke-width:2px
状态机:协程的“剧本”
CPS 只是改变了函数签名。真正实现“暂停-恢复”的,是编译器为每个挂起函数生成的状态机。
极简挂起函数的反编译
我们准备一个最简单的挂起函数:
suspend fun simpleDelay(): String {
delay(1000)
return "Done"
}
编译后,再用工具反编译成 Java 字节码(或直接看 Kotlin 编译器生成的 IR),它的逻辑大致等价于以下伪代码:
// 编译器生成的状态机伪代码(简化版)
class SimpleDelayStateMachine(
private val completion: Continuation<String>
) : Continuation<Unit> {
// 状态标签:记录执行到哪一步了
var label = 0
// 保存局部变量(如果有的话)
lateinit var result: String
override fun resumeWith(data: Result<Unit>) {
// 被唤醒时,继续执行状态机
invokeSuspend()
}
fun invokeSuspend(): Any? {
when (label) {
0 -> {
// 第一幕:初始化
label = 1
val delayResult = delay(1000, this)
if (delayResult == COROUTINE_SUSPENDED) {
return COROUTINE_SUSPENDED // 挂起!线程释放
}
}
1 -> {
// 第二幕:从挂起点恢复,直接执行后面的代码
// 注意:这里不会再次调用 delay
}
}
// 最终结果
return "Done"
}
}
状态机执行流程
stateDiagram-v2
[*] --> 首次调用
首次调用 --> 执行初始化
执行初始化 --> 设置label为1
设置label为1 --> 调用delay函数
调用delay函数 --> 挂起判断
挂起判断 --> 返回Suspended : 返回COROUTINE_SUSPENDED
返回Suspended --> 等待唤醒 : 线程释放
挂起判断 --> 立即完成 : 不挂起直接返回
等待唤醒 --> 系统回调
系统回调 --> 恢复执行label1
恢复执行label1 --> 跳过delay
跳过delay --> 返回结果
立即完成 --> 返回结果
返回结果 --> [*]
这张图揭示了挂起函数最核心的秘密:delay 的调用实际上被分成了两次。第一次调用发生在 label=0 时,它启动了定时器并返回 COROUTINE_SUSPENDED。第二次“调用”(恢复)直接从 label=1 开始,跳过了 delay 的函数体,直接执行后续的 return "Done"。
这就是为什么协程能在挂起点“暂停”,然后在同一位置“恢复”——状态机的 label 变量记住了进度。
字节码爆破:直视 Continuation 的真身
如果你还觉得不够过瘾,我们来看一段真实的 Java 字节码反编译结果。
准备工作
将以下 Kotlin 代码编译成 class 文件:
// Test.kt
suspend fun fetchUser(): String {
delay(1000)
return "User"
}
使用 javap -c -p TestKt.class 查看字节码,你会看到类似以下的输出(已简化并添加注释):
public final class TestKt {
public static final java.lang.Object fetchUser(
kotlin.coroutines.Continuation<? super java.lang.String> completion
);
Code:
// 检查是否已经有状态机,如果没有则创建
// ...
// 状态机的 switch 分支
tableswitch { // switch on label
0: L0
1: L1
default: ...
}
L0:
// label=0 时的代码
// 设置 label = 1
// 调用 delay(1000, this)
// 检查返回值是否为 COROUTINE_SUSPENDED
L1:
// label=1 时的代码
// 直接跳到返回 "User"
}
Continuation 接口
Continuation 是 Kotlin 标准库中定义的接口,它是整个挂起机制的核心:
public interface Continuation<in T> {
// 恢复执行所需的上下文
public val context: CoroutineContext
// 恢复协程,传入结果(成功或异常)
public fun resumeWith(result: Result<T>)
}
当你调用 delay(1000) 时,编译器实际上调用了 delay(1000, this),其中 this 就是当前协程的状态机对象(它实现了 Continuation)。delay 函数内部会:
- 向系统注册一个定时器,回调时调用
continuation.resumeWith(Result.success(Unit))。 - 返回
COROUTINE_SUSPENDED。
这样,协程的控制权就归还给了调度器,线程得以释放。
为何挂起不阻塞线程:调度器的角色
CPS 和状态机解释了“暂停-恢复”的逻辑,但还有一个问题:挂起期间,协程的状态保存在哪里?谁来负责唤醒它?
答案是:调度器(Dispatcher) 和 续体(Continuation)。
当你调用 delay 时:
delay函数捕获当前的Continuation(即状态机对象)。- 它向底层定时器注册一个回调。
- 它向调度器返回
COROUTINE_SUSPENDED。 - 调度器收到这个特殊标记后,知道协程暂时不需要线程了,于是把它从当前线程的执行队列中移除,转而去执行其他等待中的协程。
- 2 秒后,定时器回调触发,调度器将那个
Continuation重新放入某个线程的执行队列。 - 当线程轮到这个任务时,调用
Continuation.resumeWith(Result.success(Unit)),状态机从label=1继续执行。
整个过程中,线程从未被“卡住”。它一直在调度器的指挥下,马不停蹄地处理各个协程的任务片段。
sequenceDiagram
participant Thread as 🧵 工作线程
participant Dispatcher as 🎛️ 调度器
participant StateMachine as 📟 状态机(Continuation)
participant Delay as ⏲️ delay函数
rect rgb(232, 245, 233)
Thread->>StateMachine: 执行 invokeSuspend (label=0)
StateMachine->>Delay: delay(1000, this)
Delay->>Delay: 注册系统定时器
Delay-->>StateMachine: 返回 COROUTINE_SUSPENDED
StateMachine-->>Dispatcher: 返回 COROUTINE_SUSPENDED
Dispatcher->>Thread: 线程释放,去执行其他协程
end
Note over Thread: 线程处理其他任务...
rect rgb(255, 243, 224)
Delay-->>Dispatcher: 定时器回调
Dispatcher->>Dispatcher: 将 Continuation 放入队列
Dispatcher->>Thread: 分配 Continuation 任务
Thread->>StateMachine: resumeWith(Result.success)
StateMachine->>StateMachine: invokeSuspend (label=1)
StateMachine->>Thread: 返回 "Done"
end
传统方案对比:协程的轻量从何而来?
理解了挂起的原理,我们就能真正理解为什么协程比线程“轻量”。
| 对比维度 | 线程 (Thread) | 协程 (Coroutine) |
|---|---|---|
| 挂起/阻塞机制 | Thread.sleep 让线程进入休眠状态,操作系统层面阻塞,线程无法做任何事。 | delay 返回 COROUTINE_SUSPENDED,线程被释放去执行其他协程。 |
| 状态保存 | 线程栈(通常 1MB 左右)。 | 状态机对象(通常几十到几百字节)。 |
| 上下文切换 | 操作系统内核态切换,成本高。 | 用户态调度,由调度器在 JVM 层面完成,成本极低。 |
| 并发数量 | 受限于内存和 OS 限制,数千个线程已是极限。 | 可以轻松创建数十万个协程。 |
这就是为什么协程被称为“轻量级线程”。它不是真正的线程,而是跑在线程上的任务片段。挂起机制让它能够在线程上协作式地让出执行权,而不是被操作系统抢占式地剥夺。
graph TD
subgraph ThreadModel["🐌 传统线程模型"]
T1[线程1 - 1MB 栈]
T2[线程2 - 1MB 栈]
T3[线程3 - 1MB 栈]
end
ThreadModel -->|操作系统调度<br>上下文切换昂贵| OS[操作系统内核]
style ThreadModel fill:#ffcdd2,stroke:#b71c1c,stroke-width:2px
style T1 fill:#ef9a9a,stroke:#c62828
style T2 fill:#ef9a9a,stroke:#c62828
style T3 fill:#ef9a9a,stroke:#c62828
style OS fill:#ffb74d,stroke:#e65100
graph TD
subgraph CoroutineModel["🚀 协程模型"]
C1[协程1 - 几百字节]
C2[协程2 - 几百字节]
C3[协程3 - 几百字节]
C4[协程4 - 几百字节]
C5[协程5 - 几百字节]
Thread[底层线程池 2-4 个线程]
end
CoroutineModel -->|用户态调度<br>上下文切换极轻| JVM[JVM 用户空间]
style CoroutineModel fill:#c8e6c9,stroke:#1b5e20,stroke-width:2px
style C1 fill:#a5d6a7,stroke:#2e7d32
style C2 fill:#a5d6a7,stroke:#2e7d32
style C3 fill:#a5d6a7,stroke:#2e7d32
style C4 fill:#a5d6a7,stroke:#2e7d32
style C5 fill:#a5d6a7,stroke:#2e7d32
style Thread fill:#81c784,stroke:#1b5e20,stroke-width:2px
style JVM fill:#64b5f6,stroke:#1565c0
常见误区与避坑指南
误区 1:认为 suspend 关键字本身就能让函数异步
// ❌ 这个函数虽然是 suspend,但它没有挂起点,不会释放线程
suspend fun calculate(): Int {
var result = 0
for (i in 1..1000000) {
result += i
}
return result
}
suspend 只是允许函数被挂起,但真正的挂起发生在调用另一个挂起函数(如 delay)并且它返回 COROUTINE_SUSPENDED 时。纯计算的 suspend 函数会一直占用线程直到完成。
误区 2:认为挂起函数一定运行在子线程
// ✅ 这段代码运行在主线程,但不会阻塞主线程
lifecycleScope.launch {
delay(1000) // 挂起,主线程被释放
updateUI() // 恢复后仍在主线程执行
}
挂起函数本身不决定运行在哪个线程。调度器(Dispatcher) 决定协程运行在哪个线程。delay 只是暂停协程,恢复时调度器会将它放回原来的线程(或指定的线程)。
误区 3:混淆挂起与阻塞
很多初学者以为 delay 是 Thread.sleep 的替代品,功能一样只是更“高级”。但两者的本质完全不同:
Thread.sleep:阻塞,线程进入休眠,CPU 时间片被让出,但线程依然被占用。delay:挂起,协程状态机保存现场,线程归还线程池,可以立刻处理其他任务。
最佳实践:写出可被取消的挂起函数
基于对原理的理解,编写自定义挂起函数时有两个黄金法则:
-
在循环或长计算中,主动检查
isActive或调用yield()。suspend fun longComputation(): Int { var result = 0 for (i in 1..1000000) { // 每 1000 次迭代检查一次取消状态 if (i % 1000 == 0) yield() result += i } return result } -
使用
suspendCancellableCoroutine将回调式 API 转换为挂起函数。suspend fun LocationManager.awaitCurrentLocation(): Location = suspendCancellableCoroutine { continuation -> val listener = LocationListener { location -> continuation.resume(location) } requestLocationUpdates(provider, 0, 0f, listener) continuation.invokeOnCancellation { removeUpdates(listener) // 取消时清理资源 } }
总结与下回预告
恭喜,你成功渡过了炼气境的原理天劫。
本讲核心收获:
- 挂起函数的本质是编译器自动生成的状态机,通过
label变量记录执行进度。 - CPS 续体传递是编译器使用的变换技术,每个挂起函数都被加了一个
Continuation参数。 - 挂起时,协程向调度器返回
COROUTINE_SUSPENDED,线程被释放去执行其他协程;恢复时,调度器通过回调调用Continuation.resumeWith。 - 协程的轻量级来源于用户态调度和极小状态保存。
现在,你已经拥有了看懂任何协程堆栈的理论基础。那些曾经让你困惑的 Continuation、invokeSuspend,再也不会成为拦路虎。
但还有一个巨大的问题悬而未决:
当我们在
viewModelScope中启动多个协程,其中一个子协程发生了未捕获的异常,会发生什么?是只有它自己崩溃,还是整个 Scope 都会跟着遭殃?supervisorScope又是如何隔离子协程异常的?
这正是协程的异常处理与监督机制,也是我们下一境——筑基境·初阶——要啃下的硬骨头。我们将深入 Job 的父子树结构,彻底理解结构化并发的取消传播与异常传播双法则。
准备好踏入筑基境了吗?
【当前境界修为面板】
- 当前境界:
[炼气境 · 巅峰]✅ 炼气境大圆满 - 下一突破:
[筑基境 · 初阶](需领悟:结构化并发的根本大法——父子 Job 树、取消传播、异常传播、supervisorScope) - 修炼进度:
[████████░░░░░░░░░░░░] 44% - 本讲获得法器:
状态机内视术、CPS 续体真解、调度器底层窥探镜
【本讲思考题】
1、表象题:以下挂起函数编译后,状态机有几个 label 分支?
suspend fun example() {
delay(100)
delay(200)
delay(300)
}
2、场景题:你需要在协程中调用一个不支持取消的阻塞式第三方 SDK 方法(如 Socket.read())。如何让这个协程能够响应外部的 cancel() 调用?
3、原理题:COROUTINE_SUSPENDED 是一个单例对象。为什么协程调度器能通过判断返回值是否等于它,来决定是否释放线程?如果挂起函数返回了其他值,意味着什么?
道友,炼气境已全部通关。你在协程的世界里已经不再是懵懂稚童,而是能够内视丹田、感知挂起的修炼者。下一讲,我们将正式踏入筑基境,去领悟那道让协程“形散神聚”的根本大法——结构化并发。
我们筑基境见。
欢迎一键四连(
关注+点赞+收藏+评论)