技术博客

call_deferred 与消息队列:延迟调用的实现与 flush 时机

第 36 / 149 篇 · 2025-10-18 · godot · 场景树源码分析多线程

在节点回调里直接 add_child()、queue_free() 同帧改树的代码经常出问题:子节点 _ready 时父节点状态还没准备好、遍历数组时被移除元素。call_deferred 是解决这类问题的标准姿势,但它背后的 MessageQueue 是个相当朴素的结构,理解了 flush 时机才能预测延迟调用到底在哪一帧执行。

消息队列的本质:一条全局 FIFO

Object::call_deferred(callable)(core/object/object.cpp)做的事只有一件——把 Callable 和参数打包,推进一条进程级的消息队列:

// core/object/object.cpp(Godot 4.2.2,有删减)
Error Object::call_deferred(const StringName &p_method, ...) {
    Callable callable = Callable(this, p_method);
    MessageQueue::get_singleton()->push_callable(callable, args, argc);
    return OK;
}

队列本体在 core/object/message_queue.cpp:MessageQueue 维护一块连续内存(默认容量 4096 条,由 message_queue_size_mb 项目设置控制),每条消息存 Callable + 序列化后的参数(参数用 Variant 数组编码进同一缓冲)。它完全是数据结构课上的循环队列,没有优先级、没有按对象分组——所有节点的延迟调用挤在同一条队列里,先进先出。

这条队列取代了 3.x 里的 MessageQueue 单例,4.x 里改名 ObjectMessageQueue 并改为按需增长的缓冲块,但语义没变。

flush 在帧循环的哪几个点

关键在"什么时候取出来执行"。答案不在消息队列自己,而在 SceneTree 的主循环里。Main::iteration() 驱动 SceneTree::iteration()(物理)和 SceneTree::process()(逻辑),延迟调用的 flush 卡在这两个阶段的收尾(scene/main/scene_tree.cpp,示意):

void SceneTree::physics_process(...) {
    _physics_process(delta);
    flush_deferred(); // 物理帧内 call_deferred 的回调在这里跑
}

void SceneTree::process(...) {
    // 输入 → _process → 动画/物理后处理
    flush_deferred(); // 逻辑帧的延迟调用在这里跑
}

所以时间线是:call_deferred 入队当帧,同一帧的末尾 flush 阶段执行,而不是下一帧。队列在 flush 时会先整体取出再执行——执行回调期间新入队的消息属于下一批,不会在本次 flush 里递归执行(避免了回调里再 call_deferred 造成的无限展开,也避免了修改正在遍历的队列)。

这个设计解决的核心问题是遍历安全:_process 里遍历 get_children() 时 queue_free() 兄弟节点是安全的(queue_free 本身也是走队列),但立刻 remove_child 不安全;换成 remove_child.call_deferred(node),回调跑到时帧内的所有遍历早已结束。

队列满会直接崩,这是它最硬的限制

MessageQueue::push_callable 在缓冲耗尽时不是阻塞等待,而是 ERR_PRINT 后直接返回失败——批量 defer 的场景(一帧里几千个回调)会无声丢消息或触发断言。引擎自己的内部消息(如 queue_free、NOTIFICATION 延迟派发)也走同一条队列,所以"消息队列溢出"的报错经常不是你自己的 defer 引起的,而是某个信号风暴把队列打满了。排查方向:谁在一帧内 emit 了高频信号、谁的回调里又连锁 defer。

和 await / signal 的分工

call_deferred 是"同帧晚点执行",signal 是"状态变化时执行",await 是"异步点之后恢复"。三者都绕开了立即调用,但保证级别不同:deferred 只保证晚于当前调用栈,signal 保证晚于状态变化,await 保证跨帧后函数状态完整。想在"下一帧 _process 之前"做点清理,只有 call_deferred 这个粒度。

一帧的时间线 物理步进 _physics_process 输入处理 _unhandled_input _process 脚本逐帧逻辑 flush 消息队列 call_deferred 回调在此执行 _process 里 call_deferred(f) f 在右侧 flush 阶段执行,同帧内晚于当前调用栈 物理帧的 call_deferred 在物理 flush 执行;逻辑帧的在帧尾 flush 执行;队列全局共享,按入队顺序出队

本文代码引自 Godot 4.2.2-stable 的 core/object/object.cpp、core/object/message_queue.cpp、scene/main/scene_tree.cpp,均有删减。