技术博客

await 协程的状态机实现:函数暂停时栈帧存在哪

第 84 / 149 篇 · 2025-10-27 · godot · GDScript协程源码分析

GDScript 的 await 看起来是让函数"等一会儿",实现上却是一整套状态搬运。编译管线那篇提过结论:它是无栈协程,栈帧被复制到堆上。这篇把完整的状态机走一遍——从 await 执行的那一刻,到信号触发 resume(),中间对象的所有权在谁手里、什么时候释放。

暂停:一次主动的栈搬迁

函数执行到 await some_signal 时,VM 执行 OPCODE_AWAIT。这个指令做三件事:

// modules/gdscript/gdscript_vm.cpp, 4.2(结构性删减)
case OPCODE_AWAIT: {
    ip += 2;
    Variant *target = ...;                    // await 的操作数

    GDScriptFunctionState *gdfs = memnew(GDScriptFunctionState);
    gdfs->function = this;
    gdfs->state.stack_size = used_stack;      // 当前占了多少槽位
    // 把栈上从基址到栈顶的 Variant 逐个拷贝进 state.stack
    for (int i = 0; i < used_stack; i++) {
        gdfs->state.stack[i] = stack[i];
    }
    gdfs->state.ip = ip;                      // 断点:下一条待执行指令
    gdfs->state.instance = instance;
    gdfs->state.script = script;
    gdfs->state.defers = defers;
    // ... 130 多个字段逐项搬家 ...

    if (target 是 Signal) {
        // 给自己连一个一次性连接,回调是 gdfs->resume
    }
    r_ret = gdfs;                             // 函数"返回"了这个句柄
    return gdfs;                              // 整个 call() 就此返回
}

注意最后两行:执行到 await 的函数当场从 call() 返回,返回值不是业务值而是 GDScriptFunctionState*。调用方拿到的就是这个对象——如果调用方也是个协程且自己也在 await 这个调用,那么它手里的 Variant 装的是这个句柄;如果调用方是普通函数,它就拿着句柄继续跑,被调函数的剩余部分处于冷冻状态。

搬家的粒度是整个栈帧:CallState.stack 是一块 Vector<uint8_t> 原始字节缓冲,按槽位强转成 Variant 逐槽拷贝。局部变量、临时表达式结果、for 循环的迭代器状态,全在栈上,所以全都被搬走——这就是为什么 await 之后局部变量还能用。而 CallState 里那一百多个字段(instance、脚本引用、defers 列表、行号、迭代器状态位……)逐项复制的写法很笨拙,但可靠:它保证恢复现场时 VM 不需要知道"哪些字段重要",全量还原即可。

等待:所有权交给事件源

暂停后的状态机只有两条出路,取决于 await 的操作数类型:

  • await 信号:VM 调 signal.connect(callable(gdfs, "resume"), CONNECT_ONE_SHOT|CONNECT_DEFERRED) 一类的一次性连接,然后什么都不用做——GDScriptFunctionState 是 RefCounted 派生(这个选择下面细说),信号连接持有它的引用,不会被 GC 掉。信号发射时,连接的 callable 被调用,resume() 进 VM。
  • await 另一个协程/立即值:操作数不是信号而是已经完成的值,await 直接放行;是未完成的协程句柄,就把自己的 resume 挂到对方的完成回调上——协程可以成链,await a(); await b() 嵌套 await 就是靠这条链串起来的。

CONNECT_DEFERRED 这个细节有实际意义:信号在物理步里发射时,resume 不会立刻在信号发射的调用栈里执行,而是进消息队列等当前阶段结束。所以"信号触发 → 协程恢复"之间永远隔着一个 flush 边界,await 过后的代码不会在物理求解进行到一半时插进去。时序安全性是白拿的。

恢复:从堆上重新进 VM

信号触发 GDScriptFunctionState::resume(arg):

  1. 把 CallState 里存的栈字节缓冲拷回 VM 的当前栈顶,重建栈帧布局;
  2. call(function, ..., &state) 带着 p_state != nullptr 重新进入 GDScriptFunction::call()——这就是骨架代码里 int ip = p_state ? p_state->ip : 0 那条分支存在的意义:恢复时不做参数校验、不建空帧,直接从断点 ip 继续取指;
  3. await 表达式的值 = 信号的参数(或 resume(arg) 传入的 arg),VM 把它写进 OPCODE_AWAIT_RESUME 指定的目标槽位,函数体继续执行。

OPCODE_AWAIT_RESUME 是成对指令的另一半:暂停点在分支处,恢复点独立编码,保证恢复后的控制流不经过任何重复初始化的路径。有暂停点就必须有恢复点,这两个 opcode 在字节码里成对出现,compiler 保证间距一致。

整个生命周期画出来是这样:

await 协程的状态机:GDScriptFunctionState 的完整生命周期 RUNNING call() 内正常执行 SAVING OPCODE_AWAIT:栈帧搬入堆 AWAITING 句柄挂信号一次性连接 RESUMING resume() 栈帧拷回、从 ip 续跑 FINISHED 函数真正返回,句柄释放 信号发射 / resume(arg) 函数跑完 恢复后续跑又遇 await 所有权与存活 RefCounted 句柄:调用方 + 信号连接 双引用保活 由此推出的使用规则 1. 无栈限制:await 之上的调用者当场返回,暂停不能跨函数透明传播,await 会传染 2. 永不释放的保险:对象销毁时其信号上的挂起连接会把句柄置为无效,恢复即安全退出

RefCounted 选择与悬挂防护

GDScriptFunctionState 继承 RefCounted 是个深思熟虑的决定。暂停的协程不占调用栈,唯一让它活着的东西是引用——调用方的局部变量持有一个,信号连接的可调用对象持有一个。两个都是 RefCounted 语义,所以协程的安全期是精确的:调用方放弃句柄且信号发射完毕,状态机对象自然归零,栈缓冲随之释放,没有泄漏也没有悬停。

反过来还有一个方向的问题:协程挂着的时候,它 await 的那个对象(比如一个节点)被 queue_free 了怎么办?答案在连接销毁路径里——对象析构时清掉自己的信号连接表,被清的连接如果是挂起协程的连接,会触发句柄的"无效化"标记。resume() 进来先查这个标记,无效就直接把函数推到最后,让它以 null 恢复并尽快走到返回。你看到的"await 的对象没了之后协程安静地结束",背后是这个检查点在兜底。

成本与边界

每次 await 的代价清晰可算:一次堆分配(CallState 字节缓冲)、整帧栈的 memcpy、一百多个字段逐项复制、一次信号连接 + 断开。微秒级,单个无所谓;每帧几百个实体各自协程化,暂停和恢复的总量就开始可见。这也是实体多时代码里"用状态机代替 await"建议的由来——不是 await 不对,是它的成本模型是常数而状态机是零。

边界上再强调一遍无栈推论:await 只能写在"允许返回"的位置。顶层表达式中间不能拆成两个 await,for 循环体里 await 意味着迭代器状态也必须进 CallState(它确实进了——这就是那 130 个字段里要逐项保存迭代器信息的原因)。理解了"暂停 = 整帧序列化",这些限制都从"语言设计怎么这么怪"变成"必然如此"。

本文代码引自 Godot 4.2.2-stable 的 modules/gdscript/gdscript_vm.cpp 与 modules/gdscript/gdscript_function.h,均有结构性删减;OPCODE_AWAIT_RESUME 的成对编码见 gdscript_byte_codegen.cpp。