协程suspend 如何被编译成“状态机”

1) 大图景:CPS 改写 + 堆上栈(stackless)

  • 思想 :suspend 会被编译器做 Continuation-Passing Style (CPS) 转换------

    原本"直接返回"的函数,被改写成"把结果回调给一个 Continuation"。

  • 结果 :每个含挂起点的 suspend 函数/λ 会被"状态机化":

    • 局部变量、下一步要从哪行继续(label)都放进生成的对象里(堆上保存"栈帧")。

    • 真正"继续执行"靠调用 Continuation.resumeWith(result),像是把程序计数器跳回 switch(label) 的某一分支。

Continuation(简化):

kotlin 复制代码
interface Continuation<in T> {
    val context: CoroutineContext
    fun resumeWith(result: Result<T>)
}

2) 签名改写:隐式多了一个 Continuation 参数

示例(源代码):

kotlin 复制代码
suspend fun foo(a: Int): String {
    delay(100)
    return a.toString()
}

编译后(概念化伪代码):

javascript 复制代码
Object foo(int a, Continuation<? super String> cont) {
    // 可能立即返回值,也可能返回 COROUTINE_SUSPENDED
}
  • 返回类型被抹成 Object,用来承载 真实结果COROUTINE_SUSPENDED
  • 没有挂起点 时,仍会多一个 Continuation 形参,但实现通常是直返(不建状态机)。

3) 生成的"状态机类":ContinuationImpl/SuspendLambda

两种常见形态:

  1. 挂起 λ(suspend {}) :生成类继承 SuspendLambda,实现 FunctionN,核心逻辑在 invokeSuspend()。

  2. 挂起函数 :编译器会为它生成一个内部 ContinuationImpl 子类,封装局部变量、label、异常等;继续执行时也走 invokeSuspend()。

典型骨架(伪代码):

typescript 复制代码
final class Foo$1 extends ContinuationImpl {
    int label;           // 状态机的程序计数器
    Object L$0, L$1;     // 若干个槽保存局部变量/参数
    Object result;       // 上一次 resumeWith 传入的结果/异常

    Foo$1(Continuation parent) { super(parent); }

    public Object invokeSuspend(Object param) {
        this.result = param;
        switch (label) {
            case 0: {
                // 初次进入
                // 保存局部,设置下一状态
                label = 1;
                Object r = delay(100, this);
                if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
                // fallthrough 到状态1逻辑
            }
            case 1: {
                // 从 delay 返回后继续
                int a = (int) L$0;
                return a + ""; // 返回最终结果
            }
            default:
                throw new IllegalStateException("call to 'resume' before 'invoke'");
        }
    }
}

要点

  • 每遇到一个挂起点 ,就把需要的活跃局部写入 L$* 槽,设置 label = 下一状态,然后调用下游挂起函数。
  • 如果下游返回 COROUTINE_SUSPENDED,就立刻把 COROUTINE_SUSPENDED 原样向上返回;真正的恢复发生在以后某个 resumeWith。

4) 挂起点如何"切片"

以 delay(100) 为例(简化):

ini 复制代码
label = 1
L$0 = a
val r = delay(100, this)
if (r == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED
// 否则继续向下执行(说明立刻完成,没有真的挂起)

恢复时序

  1. 定时器到点 → 调度器拿到原 Continuation → 调 resumeWith(Result.success(Unit));
  2. 跳回 invokeSuspend(),result = Unit,switch(label) 命中 case 1;
  3. 从 L$0 里把 a 读回,继续执行。

5)COROUTINE_SUSPENDED与resumeWith

  • COROUTINE_SUSPENDED 是个哨兵对象:表示"我挂起了,别往下跑了,等我 resumeWith 再说"。

  • resumeWith(Result) 里封装成功值异常

    • 成功:Result.success(value) → 下一次 invokeSuspend(param=value);
    • 失败:Result.failure(e) → 下一次 invokeSuspend(param=Failure(e)),随后 throw 出来或在 try/catch 中处理。

6) 调度器/拦截器:ContinuationInterceptor

  • Continuation 在恢复前会经 intercepted() 包一层(例如 DispatchedContinuation),由 ContinuationInterceptor (如 Dispatchers.Main/IO)控制在哪个线程恢复。

  • 构建器 launch/async 会创建一个起始 Continuation (如 ScopeCoroutine/StandaloneCoroutine),并把 block 的 Continuation 链接起来形成"堆上调用栈"。

启动路径(概念):

scss 复制代码
block.startCoroutine(receiver, completion)                 // 或
block.startCoroutineUninterceptedOrReturn(completion)      // 内联优化路径

7) 取消/异常如何穿透状态机

  • 取消本质是向上游 抛出 CancellationException ,所有挂起点都是可取消的"安全点"。

  • 你的 try/finally 会被拆成状态机的多个 label,从而保证 finally 一定运行(无论正常返回、异常还是取消)。

示例(保证释放):

kotlin 复制代码
suspend fun use() {
    val r = acquire()
    try {
        delay(1000)   // 这里若取消,会在恢复路径里跳到 finally
    } finally {
        r.close()
    }
}

8) inline/无挂起点/尾调用 等细节

  • inline suspend 函数 :主体会内联到调用处;若内部仍有挂起点,状态机依旧存在,但"归属"到调用处的那一套 Continuation。
  • 无挂起点的 suspend :虽然签名多了 Continuation,但不会生成状态机,通常直接计算后返回结果。
  • 尾部挂起优化:编译器/标准库会尽量通过 startCoroutineUninterceptedOrReturn 走更少包装,减少切换成本。
  • label | Int.MIN_VALUE 位标记 :反编译常见到把最高位 OR 上,表示"这是恢复路径"(debug/probe 用),理解为恢复态标识即可。

9) 与 Flow/Channel 的衔接(心智模型)

  • suspend 是基本指令;Flow 运算符本身也是一堆 suspend 挂起点串起来的状态机。
  • Channel 在 send/receive 处会挂起;因此它们都依赖上述"保存现场→返回 COROUTINE_SUSPENDED→稍后 resumeWith"的流程。

10) 一个完整示例(源代码 → 伪反编译)

源:

kotlin 复制代码
suspend fun twoDelays(a: Int): String {
    delay(10)
    val x = a + 1
    delay(20)
    return "v=$x"
}

伪反编译(高度简化):

typescript 复制代码
Object twoDelays(int a, Continuation<? super String> cont) {
    class SM extends ContinuationImpl {
        int label;
        Object result;
        int L$0;  // 保存 a
        int L$1;  // 保存 x
        SM(Continuation parent) { super(parent); }
        public Object invokeSuspend(Object param) {
            result = param;
            switch (label) {
                case 0: {
                    label = 1;
                    L$0 = a;
                    if (delay(10, this) == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
                }
                case 1: {
                    int x = L$0 + 1;
                    L$1 = x;
                    label = 2;
                    if (delay(20, this) == COROUTINE_SUSPENDED) return COROUTINE_SUSPENDED;
                }
                case 2: {
                    return "v=" + L$1;
                }
            }
            throw new IllegalStateException();
        }
    }
    SM sm = (cont instanceof SM) ? (SM) cont : new SM(cont);
    return sm.invokeSuspend(Unit.INSTANCE);
}

观察点:两个挂起点 → 三段代码块 → 两次设置 label 与保存局部。


11) 实战调试/读懂状态机的小贴士

  • 反编译看 invokeSuspend 的 switch(label):每个 case 即一个"继续点"。
  • 查看是否存在 COROUTINE_SUSPENDED 的快速返回;它就是"真挂起"的判定。
  • L$* 槽里是被拆散的局部;label 是当下所在阶段。
  • 若 finally 没执行,大概率是你在挂起函数外侧做了资源管理(应把清理放到 suspend/withContext 封闭的作用域里)。

一句话总结

suspend = 把"同步栈"拆成"堆上状态机 + Continuation 回调链"

进入挂起点前存档(局部变量 + 下一步的 label),返回 COROUTINE_SUSPENDED;

等到外界 resumeWith,再按 label 读档,在正确的线程由调度器继续执行。

相关推荐
Lee川9 小时前
优雅进化的JavaScript:从ES6+新特性看现代前端开发范式
javascript·面试
Lee川12 小时前
从异步迷雾到优雅流程:JavaScript异步编程与内存管理的现代化之旅
javascript·面试
晴殇i14 小时前
揭秘JavaScript中那些“不冒泡”的DOM事件
前端·javascript·面试
绝无仅有15 小时前
Redis过期删除与内存淘汰策略详解
后端·面试·架构
绝无仅有15 小时前
Redis大Key问题排查与解决方案全解析
后端·面试·架构
AAA梅狸猫16 小时前
Looper.loop() 循环机制
面试
AAA梅狸猫16 小时前
Handler基本概念
面试
Wect16 小时前
浏览器缓存机制
前端·面试·浏览器
掘金安东尼17 小时前
Fun with TypeScript Generics:玩转 TS 泛型
前端·javascript·面试
掘金安东尼17 小时前
Next.js 企业级落地
前端·javascript·面试