Skip to content

挂起的原理:CPS 状态机

源:Kotlin 协程底层规范

协程的魔力在编译期发生。编译器将含有 suspend 关键字的函数通过 CPS (Continuation-Passing-Style) 变换,改写为带有状态机的普通 Java 类。

签名变换

suspend 不是魔法,它只是改变了函数的签名。

kotlin
suspend fun fetch(id: String): Data

在字节码层面,它变成了:

java
// 1. 返回值变成了 Object (可能返回结果,也可能返回挂起标志)
// 2. 增加了一个 Continuation 参数 (类似 CallBack)
public Object fetch(String id, Continuation<Data> cont)

状态机 (State Machine)

编译器会将协程体内的代码切割成多个片段。每遇到一个挂起点,就切一刀。

kotlin
suspend fun demo() {
    print("A")
    delay(1000) // 挂起点 1
    print("B") 
    delay(1000) // 挂起点 2
    print("C")
}

伪代码模拟生成的逻辑:

java
class DemoStateMachine extends ContinuationImpl {
    int label = 0; // 状态指针

    void invokeSuspend(Object result) {
        switch(label) {
            case 0:
                print("A");
                label = 1;
                // 如果 delay 返回 COROUTINE_SUSPENDED,则直接 return,释放线程
                if (delay(1000, this) == COROUTINE_SUSPENDED) return; 
                // 否则(假如 delay 不挂起),直接流转到 case 1
                break;
            case 1:
                print("B");
                label = 2;
                if (delay(1000, this) == COROUTINE_SUSPENDED) return;
                break;
            case 2:
                print("C");
                return;
        }
    }
}

核心常量:COROUTINE_SUSPENDED

这是协程挂起的“信号弹”。

  • 当一个挂起函数(如 withContext)决定挂起时,它返回 COROUTINE_SUSPENDED
  • 上层状态机收到这个返回值,立即 return,从而释放当前的栈帧和线程
  • 当异步操作完成,框架调用 continuation.resume(),状态机重新再次被调用,根据 label 跳转到下一阶段。

现场保存:Locals Spilling

局部变量如何跨越挂起点存活? 答案是:溢出到堆上。 编译器会将跨越挂起点的局部变量生成为 Continuation 实现类的成员字段

kotlin
suspend fun test() {
    val a = 100 // a 在挂起点后还需要使用
    delay(1000)
    println(a) 
}

生成的类:

java
class TestContinuation {
    int I$0; // 用于保存变量 a
    
    // ... 在挂起前保存: this.I$0 = a;
    // ... 在恢复后读取: int a = this.I$0;
}