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):
- 把
CallState里存的栈字节缓冲拷回 VM 的当前栈顶,重建栈帧布局; call(function, ..., &state)带着p_state != nullptr重新进入GDScriptFunction::call()——这就是骨架代码里int ip = p_state ? p_state->ip : 0那条分支存在的意义:恢复时不做参数校验、不建空帧,直接从断点ip继续取指;await表达式的值 = 信号的参数(或resume(arg)传入的 arg),VM 把它写进OPCODE_AWAIT_RESUME指定的目标槽位,函数体继续执行。
OPCODE_AWAIT_RESUME 是成对指令的另一半:暂停点在分支处,恢复点独立编码,保证恢复后的控制流不经过任何重复初始化的路径。有暂停点就必须有恢复点,这两个 opcode 在字节码里成对出现,compiler 保证间距一致。
整个生命周期画出来是这样:
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。