为什么协程要用状态机机制

5 阅读2分钟

1) 本质:要“挂起再恢复”,就必须保存执行现场→ 自然变成“状态机”

协程允许你在函数中途挂起,稍后在同一源位置继续往下跑。

这等价于把一次线性执行拆成多段:“跑到挂起点 → 存档 → 稍后读档续跑”。

存档需要保存两类信息:

  • 程序计数器 PC:下一步应该从哪一行继续(即“状态编号”/label);

  • 活跃局部变量:继续时要用到的参数/临时值(L0,L0, L1 …)。

把“状态 + 转移”编码出来,就是有限状态机(FSM)

每个挂起点对应一个“状态”,恢复时根据 label 分支跳转到正确的代码块继续执行。

直白心智模型:suspend = “把同步栈拆成堆上的状态机对象 + 一条 resumeWith 回调链”。


2) 平台约束:JVM/原生栈不可随意“冻结/解冻”,只能用CPS + 状态机变换

  • JVM 不支持把当前线程调用栈拷贝起来、过会儿再塞回去(没有第一等continuation、也不能热切换栈帧)。

  • 因此 Kotlin 选择编译期把 suspend 做 Continuation-Passing Style(CPS) 改写:

    • 每个 suspend fun f(x): R 变成 f(x, cont: Continuation): Any;
    • 返回值要么是真实结果,要么是 COROUTINE_SUSPENDED(哨兵,表示“已挂起,等恢复”);
    • 编译器合成一个继承 ContinuationImpl 的状态机类,里面有 label 和 L$* 槽位保存“执行现场”。
  • 这套方案无需修改 JVM无需自定义线程栈,能在现有平台与调度器(Dispatchers)上稳定运行。

对照类比:C# async/await、Babel 编译的 JS async 也都会编成状态机或基于 generator 的状态推进器。


3) 可维护性:直写同步代码,编译器替你生成“回调地狱”的底层机器

如果没有协程,你要手写一坨嵌套回调/Promise,然后在每一层回调里手搬“下一步该干嘛”和“上下文变量”。

协程把这件事全部下沉到编译产物的状态机里:

  • 你用同步风格写 try/catch/finally、for/if;

  • 编译器把每个挂起点切片,生成 switch(label) 的恢复分支

  • 局部变量自动“外提”到状态机对象的字段,不需要你手抄

结果是可读性错误可控性大幅提升,但机器层仍旧是经典的事件驱动/回调模型。


4) 语义保障:用状态机精确还原异常、取消、finally 必达、顺序一致性

协程要保持与同步代码一致的语义,这需要编译器在状态机里小心处理控制流:

  • 异常/取消的传播:取消本质上以 CancellationException 触发,状态机在下一次恢复时把异常送回你写的 try/catch。
  • try/finally 保证执行:编译器会把 try/finally 拆成多个状态,恢复时跳到承诺的“finally 分支”,从而实现资源必达释放——这是手写回调最容易出错的地方。
  • 结构化并发:父/子协程取消与异常的可预期传播依赖这套恢复机制;状态机把“挂起点是可取消的安全点”形式化落地。
  • 顺序一致性:await/receive 之后的代码必定在其后执行;状态机 label 的推进保证了这种程序次序语义

5) 性能权衡:比线程便宜得多,且只保存“活跃局部”,避免保存整栈

  • OS 线程/绿色线程切换需要保存寄存器+整个调用栈帧;协程只保存必要局部 + 下一个label更轻量

  • 一个挂起点相当于一次“堆上小栈帧”分配 + 一次恢复的调度(ContinuationInterceptor 决定在哪个线程 resume)。

  • 你得到的是:百万级协程可行(受限于业务与内存),而百万级线程不可行。

  • 代价是:

    • 需要创建/维护状态机对象(少量分配/字段读写);
    • 调试反编译会看到 label | Int.MIN_VALUE 等略显“魔法”的位标记;
    • 深层 suspend 链条会形成较长的回调恢复链(调度器已做了大量“直通/内联”优化,如 startCoroutineUninterceptedOrReturn 以减少包装)。

6) 为什么不是别的方案?

  • 真正的栈满协程(stackful coroutine)/用户态线程:需要自管栈或 VM 支持(如早年的 Lua、Go 的 g0/goroutine 栈、Rust 早期试验),跨平台成本高;Kotlin 要跑在 JVM/JS/Native 多平台上,状态机是最兼容的工程化解法。
  • 编译器不做变换,交给库/运行时:很难给出“像同步一样”的语义(尤其 try/finally/取消传播),也难以优化。
  • 继续用回调/Promise:开发体验与错误率劣于协程;跨越多个异步步骤的异常边界资源释放尤难处理。

7) 一眼看懂的“为什么”清单

  1. 挂起/恢复需要“读档续关” → 需要“保存 PC + 局部变量” → 状态机是最直接的程序化表示。
  2. JVM 无第一等 continuation/栈操作 → 只能用 CPS + 状态机类在堆上模拟“栈帧”。
  3. 可维护性:你写同步风格,编译器自动生成“回调地狱”的底层实现。
  4. 语义一致:异常、取消、finally、顺序都通过 label 推进与恢复逻辑严格保证。
  5. 性价比:比线程轻得多,只保存必要状态;跨平台可实现、可优化。

8) 一个 6 行例子感受“状态 → 恢复”

源代码(两个挂起点):

suspend fun demo(a: Int): String {
    delay(10)          // 状态0 -> 1
    val x = a + 1
    delay(20)          // 状态1 -> 2
    return "v=$x"
}

编译后(概念化)就是:

  • label=0:保存 a,调用 delay(10,this);若挂起返回 COROUTINE_SUSPENDED;

  • 恢复 → label=1:算出 x,保存 x,再 delay(20,this);

  • 再次恢复 → label=2:拼字符串返回。

这就是状态机


小结

协程之所以采用状态机机制,不是“恰好如此”,而是挂起/恢复的本质需求 + JVM 等平台约束 + 语义与可维护性的工程化取舍共同决定的:

  • 用编译期把同步代码转换成“堆上可恢复的状态机”,

  • 借由 Continuation 与调度器,在无需自定义栈的前提下,提供像同步一样的可读性与强语义保证

  • 同时获得远低于线程的创建/切换成本和良好的跨平台可移植性