引擎里到处能看到 MutexLock lock(mutex); 这样的 RAII 守卫,但这层封装不只是换个名字——它服务于一个激进的设计:锁可以在编译期被完全消除。这篇讲 core/os/mutex.h/rw_lock.h 的分层设计,THREAD_SAFE 宏的开关效果,以及 PhysicsServer/RenderingServer 的线程模式怎么吃到这个红利。源码引自 4.2.2-stable。
封装的三层结构
core/os/mutex.h 的核心:
#ifdef THREADS_ENABLED
class MutexImpl {
std::recursive_mutex mutex; // 真实现
};
#else
class MutexImpl {
// 空实现:lock()/unlock() 是内联空函数
};
#endif
template <class MutexT>
class MutexLock {
const MutexT &lock;
public:
explicit MutexLock(const MutexT &p_lock) : lock(p_lock) { lock.lock(); }
~MutexLock() { lock.unlock(); }
};
using Mutex = MutexImpl; // 常规互斥锁
using BinaryMutex = ...; // 非递归版,lock 两次直接死锁
三层分工:
- MutexImpl:编译期分野。定义了
THREADS_ENABLED(SCons 里threads=yes,全平台默认)时是真锁,否则所有方法内联为空——单线程构建里锁的开销精确到零,连函数调用都不剩; - Mutex vs BinaryMutex:Godot 的默认 Mutex 是递归锁(同线程可重入),BinaryMutex 不是。选递归做默认是因为引擎代码调用层级深,"函数 A 持锁调函数 B,B 也要这把锁"的场景太多,非递归锁会把这种代码全部变成死锁。代价是递归锁略贵(要记录持锁线程和计数),且掩盖了"这锁其实不该重入"的设计缺陷——性能敏感的域(如 RenderingDevice 的提交路径)会显式用 BinaryMutex 逼自己想清楚;
- MutexLock / MutexWriteLock / MutexReadLock:RAII 守卫。引擎禁止手工
lock()/unlock()配对,review 时手工配对是必打回的——异常安全的理由(虽然引擎不用异常,但 early return 遍地都是)。
RWLock:读写分离但偏袒写者
core/os/rw_lock.h 的 RWLock 是基于 Mutex + 条件变量自卷的读写锁,不是 std::shared_mutex。行为特征:
- 读锁可多人同时持;
- 写锁独占,且写者优先:有写者在等时,新的读请求排队,防止写饿死。
为什么自卷而不是用标准库?std::shared_mutex 的读写策略实现相关(有的偏读有的偏写),而引擎的读写场景是"渲染线程高频读场景数据、主线程低频写",写饿死在这种比例下是真问题。自卷实现的语义是确定的。
成本意识:读锁不是免费的,一次 RWLock::read_lock() 内部是 mutex lock + 计数 + 可能的条件变量等待,比 Mutex 更贵。这就是为什么引擎里 RWLock 只用在"读写比例极端悬殊"的地方(如 ClassDB 的方法表:启动时写,之后纯读),普通场景宁可用 Mutex。
Semaphore 与条件变量的位置
锁家族里还有 Semaphore(core/os/semaphore.h),语义是计数信号量:worker 池的唤醒、渲染线程的帧同步都靠它。引擎对条件变量的使用很克制——Semaphore 覆盖了"等待事件"的绝大多数场景,且语义比条件变量简单(没有谓词丢失问题)。自定义模块需要"等一个异步结果"时,用 Semaphore 而不是手工 Mutex + 轮询。
死锁的调试手段
真撞上死锁时的工具链:
- dev 构建的断言:
DEV_ENABLED构建里 BinaryMutex 的重入会直接断言报错(同线程第二次 lock),把一类死锁提前变成崩溃加明确栈; - 线程命名:引擎给所有内部线程起了名字(
Thread::set_name),macOS 的 lldb / Instruments、Linux 的top -H里都能看到WorkerThreadPool的 worker 名,定位"哪个线程卡在哪个锁"时先按名字过滤; --verbose的帧日志:服务器线程模式下每帧的命令队列长度有 verbose 输出,队列单调增长说明消费端(渲染/物理线程)已经卡住。
排查顺序建议:先看是不是自己脚本里 Mutex 的锁序问题(业务层最常见),再看是不是在 worker 任务里同步调用了必须主线程的 API(第三类最常见,表现为偶发死锁),最后才怀疑引擎。真怀疑引擎时,带上 dev 构建的断言输出和 thread apply all bt(lldb/gdb 的全线程栈 dump)去开 issue,光说"卡死了"没法定位。
服务器线程模式:锁的用量是配置出来的
真正的巧思在 servers 层。PhysicsServer3D 和 RenderingServer 都有线程模式设置(physics/common/physics_server_3d.h 等):
physics/3d/run_on_separate_thread = false # 项目设置,默认关
配合引擎级的单线程/多线程分野,实际形成三档:
- 完全单线程:
THREADS_ENABLED未定义(嵌入式裁剪构建),所有锁编译期消失,服务器 API 直接执行; - 多线程但服务器同步执行(默认):锁存在,物理/渲染在主线程步进,锁主要用于"后台资源加载线程 vs 主线程"的交界;
- 服务器独立线程:物理步进跑在专属线程,SceneTree 的
_physics_process和物理引擎之间隔一个命令缓冲;渲染在 4.x 的rendering/driver/threads相关设置下也可分离。
第二档是默认的原因:服务器独立线程的收益是物理和渲染逻辑并行,代价是所有跨边界调用要过命令队列或加锁,且 SceneTree 侧读到的是上一物理帧的状态。对物理负载轻的游戏,多线程模式是纯开销——命令队列的打包/解包成本可能超过并行省下的时间。项目设置里那个开关是给"物理重负载 + 逻辑轻"的项目准备的(比如大量刚体的沙盒游戏)。
线程安全宏的全貌
THREADS_ENABLED 的影响面不止 Mutex:
SafeNumeric<T>(core/templates/safe_refcount.h):原子计数,无线程时退化为普通 int;SafeRefCount:RefCounted 的引用计数,同理;CowData的引用计数:前面容器篇提过,无线程时非原子;- 各服务器的
CommandQueue:单线程构建直接内联调用。
这意味着同一份引擎源码可以编出"零同步原语"的版本——对确定性和性能分析是利器:单线程构建跑 profile,火焰图里没有锁竞争噪声,热点全是真实计算。回放类工具(录像验证、帧同步游戏的确定性检查)也受益于这个构建模式:没有线程调度这个不确定源之后,逐帧 diff 才有意义。代价是构建脚本要单独维护一套 threads=no 的产物,CI 上多一条构建线。
结构示意
实战推论
- 自定义 C++ 模块遵守同一套约定:用
MutexLock守卫、默认递归锁、别手工配对。引入std::mutex直接用在模块里会破坏"单线程构建零开销"的承诺——你的锁不会随THREADS_ENABLED消失。 - 死锁排查先看递归锁的重入路径。递归锁让"同线程重入"不死锁,但也让锁的持有边界模糊。godot 的 debug 构建里 Mutex 带线程 ID 记录,死锁时的断言信息会给出持锁线程,比盲猜快。
- 别把服务器线程模式当性能开关乱试。开
run_on_separate_thread后,_physics_process回调时序变化(读到的可能是上帧状态),依赖精确时序的玩法代码会先坏——先在原型阶段验证玩法逻辑对一帧延迟的容忍度。 - GDScript 侧用 Mutex 类要谨慎:脚本层的
Mutex就是这个 C++ Mutex 的绑定,同样是递归锁。脚本里持锁调信号回调(回调里再碰同一把锁保护的数据)能跑通全靠递归语义,但这种结构在重构时极脆——回调链加一层就可能把锁顺序搞乱。 - 跨线程给主线程递数据用 call_deferred 而不是共享变量加锁。
call_deferred走的 MessageQueue 内部有自己的锁且经过全引擎验证,业务层自卷"锁 + 标志位"的交付机制几乎必然在压力下出竞态。锁的正确用法是保护纯数据结构(比如 worker 线程的私有缓冲队列),不是搭通信管道。 - 锁粒度往"数据归属"上靠,别往"代码位置"上靠。一把锁保护一份明确归属的数据(某个管理器的表、某个队列),而不是"这段函数都要锁"。前者重构时边界清楚,后者随着调用链生长必然出现重入和锁序混乱——引擎自己的服务器实现全是前者,值得照着学。
- profile 前确认构建配置。release_debug 和 release 的锁开销不同(断言和线程安全检查只在 dev/debug 路径),拿 debug 构建的锁竞争数据做优化决策会跑偏;要看真实锁开销,用 release 模板加
--verbose的帧时间,而不是调试器里的采样。
本文代码引自 Godot 4.2.2-stable 的 core/os/mutex.h、core/os/rw_lock.h、core/templates/safe_refcount.h、servers/physics_server_3d.h,均有删减。