1) 本质:要“挂起再恢复”,就必须保存执行现场→ 自然变成“状态机”
协程允许你在函数中途挂起,稍后在同一源位置继续往下跑。
这等价于把一次线性执行拆成多段:“跑到挂起点 → 存档 → 稍后读档续跑”。
存档需要保存两类信息:
-
程序计数器 PC:下一步应该从哪一行继续(即“状态编号”/label);
-
活跃局部变量:继续时要用到的参数/临时值(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) 一眼看懂的“为什么”清单
- 挂起/恢复需要“读档续关” → 需要“保存 PC + 局部变量” → 状态机是最直接的程序化表示。
- JVM 无第一等 continuation/栈操作 → 只能用 CPS + 状态机类在堆上模拟“栈帧”。
- 可维护性:你写同步风格,编译器自动生成“回调地狱”的底层实现。
- 语义一致:异常、取消、finally、顺序都通过 label 推进与恢复逻辑严格保证。
- 性价比:比线程轻得多,只保存必要状态;跨平台可实现、可优化。
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 与调度器,在无需自定义栈的前提下,提供像同步一样的可读性与强语义保证,
-
同时获得远低于线程的创建/切换成本和良好的跨平台可移植性。