续体与状态机 —— suspend 函数的编译秘密

编译器在背后做了什么?从 5 行 Kotlin 代码反编译出 50 行 Java,逐行拆解 Continuation 状态机的一切。

suspend Continuation 状态机 CPS 编译

目录导航(点击跳转)

一、挂起函数的本质:CPS(续体传递风格)变换

1什么是 CPS 变换

挂起函数的实现本质上是编译器对代码的 CPS(Continuation-Passing Style,续体传递风格)变换。在编译期,编译器会以每个挂起点为分界,将协程体拆分成若干状态机片段,每个片段对应一个回调续体。

用人话说就是:你写的一段连续代码,编译器把它切成几段,每段执行完后记录"我执行到哪了",然后挂起等待;等条件满足后,通过之前保存的"续体"恢复执行下一段。

suspend 函数
(连续代码)
编译
CPS 变换
(以挂起点切分)
状态机
(label + switch)
每个挂起点
对应一个状态

协程执行时,在挂起点之前只是正常执行并返回挂起标记,不会触发任何回调;只有在挂起任务完成、通过续体的 resume 恢复执行时,才会触发一次回调,进入下一个状态继续执行。

2从一个最简单的例子出发

要理解续体和状态机,最简单的方法就是写一个挂起函数,在里面制造一个挂起点。比如:

suspend fun myTest() {
    println("A")
    delay(1000)
    println("B")
}

五行代码,两个打印,中间夹一个 delay 挂起点。然后使用 IntelliJ 的 Tools → Kotlin → Show Kotlin Bytecode → Decompile 就能看到编译器把它变成什么了。

二、反编译看真相:从 5 行到 50 行

1反编译后的完整代码

上面 5 行 Kotlin 反编译成 Java 后变成了这样:

@Metadata(
   mv = {2, 0, 0},
   k = 2,
   xi = 48,
   d1 = {"..."},
   d2 = {"myTest", "", "(Lkotlin/coroutines/Continuation;)Ljava/lang/Object;", "..."}
)
public final class CoroutineKt {
   @Nullable
   public static final Object myTest(@NotNull Continuation $completion) {
      Continuation $continuation;
      label20: {
         if ($completion instanceof ) {
            $continuation = ()$completion;
            if (($continuation.label & Integer.MIN_VALUE) != 0) {
               $continuation.label -= Integer.MIN_VALUE;
               break label20;
            }
         }

         $continuation = new ContinuationImpl($completion) {
            // $FF: synthetic field
            Object result;
            int label;

            @Nullable
            public final Object invokeSuspend(@NotNull Object $result) {
               this.result = $result;
               this.label |= Integer.MIN_VALUE;
               return CoroutineKt.myTest((Continuation)this);
            }
         };
      }

      Object $result = $continuation.result;
      Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
      switch ($continuation.label) {
         case 0:
            ResultKt.throwOnFailure($result);
            System.out.println("A");
            $continuation.label = 1;
            if (DelayKt.delay(1000L, $continuation) == var3) {
               return var3;
            }
            break;
         case 1:
            ResultKt.throwOnFailure($result);
            break;
         default:
            throw new IllegalStateException("call to 'resume' before 'invoke' with coroutine");
      }

      System.out.println("B");
      return Unit.INSTANCE;
   }
}

短短 5 行 Kotlin,反编译后膨胀为近 50 行 Java。这些多出来的代码就是编译器自动生成的状态机骨架

2一眼看清结构:三块核心区域

反编译代码虽然长,但可以分为三块来理解:

01

方法签名变化

原来的 myTest() 变成了 myTest(Continuation $completion),接收调用者的续体作为参数。

02

label20 块:续体复用

检查是否为恢复调用。是则复用已有续体;否则创建新的 ContinuationImpl 匿名内部类。

03

switch 状态机

根据 label 值跳转到对应 case,执行当前状态的代码,然后更新 label 指向下一个状态。

下面逐个深入这三块区域。

3第一块:方法签名变化 —— 为什么多了 Continuation 参数

原来的 suspend fun myTest() 编译后变成了:

public static final Object myTest(@NotNull Continuation $completion)

这个 $completion 是调用者的 Continuation。它自己没有 label 等状态——它只是被当作"上下文"传入,编译器会基于它生成内部真正的状态机

返回类型也从 Unit 变成了 Object。这是因为挂起函数有两个可能的返回值:

  • COROUTINE_SUSPENDED — 表示"我还没执行完,先挂起了"
  • Unit.INSTANCE — 表示"我执行完了,这是最终结果"

一句话:suspend 函数编译后等价于一个接收 Continuation、返回 Object 的回调风格函数。调用者通过传入自己的 Continuation 来接收执行结果。

三、Continuation 体系:从接口到状态机基类

1Continuation 的继承体系

编译器生成的匿名内部类 ContinuationImpl 实际上继承自 BaseContinuationImpl。我们来追踪继承链:

  • Kotlin Suspend 函数编译产物
    • Continuation(接口)
      • BaseContinuationImpl
        • ContinuationImpl
          • 匿名内部类(编译器生成的状态机)

Continuation 是一个接口,其中定义了核心的 resumeWith() 方法。而 BaseContinuationImpl 则是所有状态机实例的公共基类,它实现了 resumeWith() 的通用逻辑。

2BaseContinuationImpl.resumeWith:状态机的调度引擎

下面是 BaseContinuationImpl.resumeWith() 的核心源码:

public final override fun resumeWith(result: Result<Any?>) {
    // 通过循环展开递归,让恢复调用链的栈更短、更清晰
    var current = this
    var param = result
    while (true) {
        probeCoroutineResumed(current)
        with(current) {
            val completion = completion!! // 快速失败:没有 completion 直接抛
            val outcome: Result<Any?> =
                try {
                    val outcome = invokeSuspend(param)
                    if (outcome === COROUTINE_SUSPENDED) return
                    Result.success(outcome)
                } catch (exception: Throwable) {
                    Result.failure(exception)
                }
            releaseIntercepted() // 当前状态机实例结束
            if (completion is BaseContinuationImpl) {
                // 通过循环展开递归
                current = completion
                param = outcome
            } else {
                // 到达顶层 completion,调用并返回
                completion.resumeWith(outcome)
                return
            }
        }
    }
}

这个方法就是续体恢复的调度引擎。它通过 while(true) 循环和 invokeSuspend 调用不断推进状态机,直到所有 Continuation 链执行完毕。

3一图看清调用链

无论是首次启动还是挂起后恢复,最终都是同一条路径:

1
外部触发
resume / resumeWith / launch
2
resumeWith(result)
BaseContinuationImpl 调度入口
3
invokeSuspend(param)
编译器生成的匿名内部类方法
4
myTest(this Continuation)
进入状态机,根据 label 跳转

核心结论:无论协程是首次启动还是从挂起恢复,最终都会通过 resumeWith → invokeSuspend → myTest 这条链进入状态机。区别只在于进入时 label 的值不同。

四、状态机的灵魂:label 与恢复标记

1label20 块:区分"首次调用"与"恢复调用"

反编译代码中的 label20 是整个状态机的"门卫",负责判断当前是首次调用还是挂起后恢复:

label20: {
   if ($completion instanceof ) {
      $continuation = ()$completion;
      if (($continuation.label & Integer.MIN_VALUE) != 0) {
         $continuation.label -= Integer.MIN_VALUE; // 清除恢复标记
         break label20; // 直接复用已有的 $continuation,跳过新建
      }
   }

   // 首次调用:创建新的状态机实例
   $continuation = new ContinuationImpl($completion) {
      Object result;
      int label;

      public final Object invokeSuspend(@NotNull Object $result) {
         this.result = $result;
         this.label |= Integer.MIN_VALUE;
         return CoroutineKt.myTest((Continuation)this);
      }
   };
}

这里的 <undefinedtype> 实际上是编译器生成的内部类 MyFunction$myTest$1——当前 suspend 函数专属的状态机类名

首次调用

  • $completion 不是本函数的状态机实例
  • 创建一个新的 ContinuationImpl 匿名内部类
  • label 初始值为 0(int 默认值)

恢复调用

  • $completion 是本函数的状态机实例(instanceof 通过)
  • label 最高位为 1(携带 Integer.MIN_VALUE 标记)
  • 清除标记后 break label20,复用已有实例
2Integer.MIN_VALUE 标记位的巧妙用法

在恢复调用时,invokeSuspend 会设置:

this.label |= Integer.MIN_VALUE;

这是把 label 的 最高位置 1,作为"这是一个恢复调用"的暗号。

回到 myTest 入口处,label20 块检测:

if (($continuation.label & Integer.MIN_VALUE) != 0) {
   $continuation.label -= Integer.MIN_VALUE; // 清除标记
   break label20;
}

检测到这个标记后,清除它(恢复 label 的真实值),然后直接 break label20,跳过创建新对象的逻辑,复用已有的 $continuation

invokeSuspend
label |= 0x80000000
myTest 入口
label & 0x80000000 ≠ 0?
是:清除标记
复用续体
进入 switch
跳到对应 case

为什么不用 boolean 标志位,而要用 int 的最高位?因为 label 字段同时承担了两个职责:低 31 位存储状态编号最高位作为恢复标记。这在有限的字段中压缩了更多信息,也避免了引入额外的 boolean 字段。

3switch 状态机:label 驱动执行跳转

进入状态机的核心逻辑:

Object $result = $continuation.result;
Object var3 = IntrinsicsKt.getCOROUTINE_SUSPENDED();
switch ($continuation.label) {
   case 0:
      ResultKt.throwOnFailure($result);
      System.out.println("A");            // 打印 A
      $continuation.label = 1;            // 更新状态为 "下一段"
      if (DelayKt.delay(1000L, $continuation) == var3) {
         return var3;                     // 返回 COROUTINE_SUSPENDED
      }
      break;
   case 1:
      ResultKt.throwOnFailure($result);
      break;                              // 跳出 switch
   default:
      throw new IllegalStateException(...);
}

System.out.println("B");                  // 打印 B
return Unit.INSTANCE;                     // 正常结束

关键观察:

  • $continuation.label 就是状态机的程序计数器
  • 每次进入 myTest,根据 label 值决定执行哪个 case
  • 挂起前(case 0 中),label 被设为 1,下次恢复时就会跳到 case 1
  • 挂起时返回 COROUTINE_SUSPENDED,case 0 的 break 和后面的 println("B") 都不会执行

理解状态机的核心:suspend 函数编译后,方法体被切成多个 case 块。label 记录"上次执行到哪了",每次进入方法时根据 label 跳到对应的位置继续。这就是 CPS 变换的底层实现。

4状态机图解:label 的流转
label = 0
case 0: println("A") delay(1000) label = 1
挂起
挂起中...
返回 COROUTINE_SUSPENDED 控制权交回事件循环 等待 1000ms
恢复
label = 1
case 1: break println("B") return Unit.INSTANCE

五、完整执行链路:两次调用的全貌推演

1第一次调用:启动 → 打印 A → delay 挂起

当协程启动,第一次调用 myTest 时的完整时序:

协程启动方
1. launch { myTest() }
6. 收到 COROUTINE_SUSPENDED
7. resumeWith 直接 return,控制权交回
BaseContinuationImpl
2. resumeWith 被调用
5. invokeSuspend 返回 COROUTINE_SUSPENDED
匿名 ContinuationImpl
3. invokeSuspend: label |= MIN_VALUE
4. 调用 myTest(this)
myTest 状态机
3. label20: instanceof 不通过,新建 $continuation
4. switch label=0: 打印 A,label 设为 1
5. delay 返回 COROUTINE_SUSPENDED,myTest return

关键细节拆解:

步骤 3:label20 块$completion 不是 MyFunction$myTest$1 的实例,所以 instanceof 不通过。创建一个新的匿名 ContinuationImpl,其 label 默认为 0。

步骤 4:case 0 — 打印 "A",将 $continuation.label = 1。然后调用 delay(1000, $continuation),延迟函数返回 COROUTINE_SUSPENDED(即 var3),于是 myTest 执行 return var3case 0 的 break 和下面的 println("B") 都没有被执行到。

步骤 5-7:传递挂起信号invokeSuspendCOROUTINE_SUSPENDED 返回给 resumeWithresumeWith 看到这个特殊值就直接 return,控制权交回事件循环,协程进入挂起状态。

2第二次调用:恢复 → 打印 B → 协程结束

1000ms 后,delay 的定时器触发,调用 $continuation.resumeWith(Unit)

delay 定时器
1. 1000ms 到期,触发回调
2. $continuation.resumeWith(Unit)
BaseContinuationImpl
3. resumeWith(Unit) 被调用
7. invokeSuspend 返回 Unit,包装为 Result.success
8. releaseIntercepted(),当前状态机结束
匿名 ContinuationImpl
4. invokeSuspend: label |= MIN_VALUE
5. 调用 myTest(this)
myTest 状态机
5. label20: instanceof 通过,检测标记,清除后 break
6. switch label=1: break 跳出 switch
6. 打印 B,return Unit.INSTANCE

步骤 5:再次进入 label20 — 这次 $completion 就是上次创建的那个状态机实例,instanceof 通过。同时 label 携带了 Integer.MIN_VALUE 标记。清除标记后,break label20 跳过创建新对象,直接复用已有实例。此时 label 的真实值为 1。

步骤 6:case 1 — 只是 break 跳出 switch,没有挂起点。然后继续执行 switch 下面的代码:打印 "B",返回 Unit.INSTANCE

步骤 7-8:正常结束invokeSuspend 得到 Unit.INSTANCE(不再是 COROUTINE_SUSPENDED),resumeWith 将它包装成 Result.success,调用 releaseIntercepted(),当前状态机实例终止。

如果调用链上有父 Continuation,resumeWith 的 while 循环会继续向上"冒泡",将 result 传递给父续体。当最终到达顶层 completion(不是 BaseContinuationImpl 的实例)时,调用它的 resumeWith,整个协程链正式结束。

3两张图的对比:看到一个关键区别

把两次调用放在一起对比:

首次调用(label = 0)

  • label20 块创建新状态机
  • 执行到第一个挂起点 (delay)
  • 返回 COROUTINE_SUSPENDED
  • 协程挂起,控制权交回
  • println("B") 没有被执行

恢复调用(label = 1)

  • label20 块复用已有状态机
  • 从 case 1 开始,直接跳过 delay
  • 执行完所有剩余代码
  • 返回 Unit.INSTANCE
  • 协程正常结束

精髓在于:首次调用和恢复调用执行的是同一个方法,但 label 的不同使得它们走了完全不同的分支。这就是状态机模式的威力——它把一段"看起来连续"的代码,变成了可以被任意中断和恢复的有限状态机。

4执行流总结表:一图看清两次调用

把上面两次调用的关键信息浓缩成一张表:

步骤调用入口label执行内容结果
1 myTest(调用者Cont) 0 创建状态机 → case 0:打印 A,改 label=1,调 delay → 返回 COROUTINE_SUSPENDED 挂起
2 resumeWith(Unit) → invokeSuspend → myTest(状态机) 1 复用状态机 → case 1:检查结果,break → 打印 B,返回 Unit 完成

关键点:状态机的执行上下文(局部变量、label、result)全部保存在 $continuation 对象里。每次进入 myTest,不管是不是恢复,都通过同一个 $continuation.label 决定从哪个 case 开始执行。

5为什么 switch 代码块能被"暂停"?—— 核心洞见

这是理解状态机最关键的一个问题:switch 本身没有暂停能力,那为什么协程可以在 switch 中间"停住"?

答案不是 switch 有特殊能力,而是整个函数在某个 case 中通过 return 提前退出,把"下一步"记录在 label 里,下次函数重新进入时用 switch(label) 跳到紧跟着的那个位置。

第一次进入 myTest
label = 0
case 0: println("A")
label = 1
return COROUTINE_SUSPENDED
函数提前退出
println("B") 没执行
第二次进入 myTest
label = 1
case 1: break
跳出 switch
继续执行
println("B")
return Unit

第一次 case 0 做了 label = 1,然后 return 了,所以 case 0 之后的代码(包括 println("B"))没有被执行。恢复时 label 是 1,直接走 case 1break 后恰好就落到了 println("B"),完美接续。

一句话:switch 代码块的"暂停"是假象——实质是函数在某个 case 中通过 return 提前退出,把"下一步"存在 label 里。下次函数重新进入时,switch(label) 直接跳到对应的 case,从断点之后继续执行。这就是 CPS 变换的核心魔法。

六、核心思想总结

核心要点

一句话总结

挂起函数的本质不是"线程挂起",而是编译器将你的连续代码变换为一个由 label 驱动的有限状态机——Continuation 持有状态,resumeWith 推进状态,每个 case 对应一段"从上一个挂起点之后到下一个挂起点"的代码片段。

延伸思考

理解续体和状态机后,很多协程行为就变得透明了:

  • suspendCoroutine 为什么能"把回调变成挂起"?因为它手动管理了 Continuation——在回调到达时调用它的 resume,从而推进状态机。
  • 为什么挂起函数不会阻塞线程?因为真正的挂起只是返回 COROUTINE_SUSPENDED。线程上的调用栈退出了,线程可以去干别的事。等异步任务完成,resumeWith 被调用,状态机从上次断点继续——但这次可能在另一个线程上。
  • 为什么同一个协程内的代码看起来像同步的?因为状态机帮你记住了"执行到哪了",恢复时直接跳到对应的 case,程序员不需要手动管理回调嵌套。

编译器为我们做了太多太多的事情。下次写 delay 时,可以想想背后那个默默工作的 label 状态机。