简单来说,本质是每一个函数都是一个状态机。 更严谨一点的说法是:Kotlin 编译器会将每一个 suspend 函数(挂起函数)编译成一个状态机(Finite State Machine),通过 label 字段记录执行位置,通过字段保存局部变量,从而实现"挂起"和"恢复"的逻辑。
为了让你理解得更透彻,我把这个底层实现拆解为三个核心步骤来解释:
1. 核心理论:CPS(续体传递风格)
在编译阶段,Kotlin 编译器会对 suspend 函数的签名(Signature)进行修改。这被称为 Continuation-Passing Style (CPS) 变换。
源代码:
kotlin
suspend fun getUser(userId: String): User { ... }
编译后的代码(伪代码):
java
// 返回值变成了 Object(可能是真正的结果,也可能是挂起标志)
// 参数列表末尾多了一个 Continuation 参数
Object getUser(String userId, Continuation<User> cont) { ... }
这个 Continuation 就是所谓的"续体",你可以把它理解为一个回调接口,它主要包含两个方法:resumeWith(恢复执行)和 context(上下文)。
2. 核心实现:状态机(State Machine)
这是你提到的"控制状态"的部分。编译器不仅改了签名,还把函数体内部的代码进行重组,生成了一个匿名内部类(或者复用现有的类),这个类就是一个状态机。
假设我们有这样一个挂起函数:
kotlin
suspend fun logic() {
print("Start")
val data = networkRequest() // 挂起点 1
print(data)
val processed = process(data) // 挂起点 2
print(processed)
}
编译器会把它改写成类似下面这样的 Java 伪代码(简化逻辑以便理解):
java
// 这个类就是生成的"状态机"
class LogicStateMachine extends ContinuationImpl {
int label = 0; // 状态指针(当前执行到哪一步了)
Object result; // 上一步挂起函数的返回结果
Object data; // 用来保存局部变量 'data',防止挂起后丢失
@Override
public Object invokeSuspend(Object result) {
this.result = result;
switch (this.label) {
case 0: // 初始状态
print("Start");
this.label = 1; // 准备进入下一个状态
// 调用挂起函数,把"自己"(this)传进去作为回调
// 如果 networkRequest 返回 COROUTINE_SUSPENDED,说明真的挂起了,
// 此时直接 return,当前线程被释放!
if (networkRequest(this) == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
// 如果没有挂起(比如直接返回了缓存),由于没 return,会自然流转到 case 1
case 1: // 从挂起点 1 恢复
// 这里 result 就是 networkRequest 的返回值
this.data = result; // 将结果保存到成员变量,相当于恢复了局部变量
print(this.data);
this.label = 2; // 准备进入下一个状态
if (process(this.data, this) == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
case 2: // 从挂起点 2 恢复
this.processed = result;
print(this.processed);
return Unit.INSTANCE; // 函数执行结束
}
}
}
3. 如何"控制状态"和"执行下一个"?
通过上面的伪代码,我们可以验证你的理解:
-
每一个函数都是状态机?
- 准确说是:每一个
suspend函数 在编译后都会生成一个对应的状态机类(继承自ContinuationImpl)。普通函数不会。
- 准确说是:每一个
-
执行的时候都会控制状态?
- 是的,通过
label字段。每次遇到挂起点(suspend point),label就会加 1。 - 关键点: 局部变量(Local Variables)会从"栈(Stack)"挪到"堆(Heap)"上。
- 在普通函数中,变量
val data存在于栈帧里,函数结束或挂起(线程切换)如果栈销毁了,变量就没了。 - 在协程中,为了能在挂起恢复后还能找到
data,编译器把它变成了LogicStateMachine类的一个成员字段(Field)。只要这个对象还在堆内存里,变量就不会丢。
- 在普通函数中,变量
- 是的,通过
-
以及执行下一个?
- 是的。当异步操作(比如网络请求)完成时,会调用
continuation.resume(result)。 - 这个
resume方法内部实际上会再次调用invokeSuspend方法。 - 由于此时
label已经变成了 1 或 2,switch语句就会直接跳转到对应的 case,从而"执行下一个"逻辑片段。
- 是的。当异步操作(比如网络请求)完成时,会调用
总结
你的理解非常正确。Kotlin 协程在 JVM 上的本质就是:
- 编译期重写:利用 CPS 变换,将线性代码拆解。
- 状态机模式 :用
switch(label)分割执行块。 - 堆内存保存上下文:将局部变量"升级"为成员变量,确保在线程切换(栈帧弹出)后数据依然存在。
这种实现方式最大的好处是不需要操作系统线程的上下文切换(Context Switch),只是在用户态通过代码逻辑跳转,所以它非常轻量高效。