技术博客

RenderingDevice 多线程提交的限制与坑

第 48 / 149 篇 · 2026-02-26 · godot · 渲染管线源码分析多线程

_THREAD_SAFE_CLASS_ 这个宏挂在 RenderingDevice 类上(rendering_device.h),很容易给人一个错觉:RD 可以随便从多个线程调。真往多线程里用会踩一连串坑。我按"能不能做"整理成四条规则,每条给出源码依据和替代方案,基于 4.2/4.3 的 servers/rendering/rendering_device.h/.cpp。

规则一:资源创建和查询可以多线程,列表录制不行

_THREAD_SAFE_CLASS_ 展开成每个 public 方法入口的 mutex 加锁(core/threads/safe_refcount.h 系列宏的同类实现),所以它保证的是方法级原子:texture_create() 从两个线程并发调是安全的,内部元数据不会撕裂。

但 draw/compute 列表是显式的录制状态机:

// rendering_device.h — 列表状态
DrawListID draw_list_current = -1;
ComputeListID compute_list_current = -1;

compute_list_begin() 检查"当前没有活动列表"才放行,end() 清零。两个线程同时录同一个列表,或者 A 线程 begin() 还没 end() B 线程就 begin(),第二个直接 ERR_FAIL。这不是性能限制,是正确性前提——屏障自动推导(见屏障那篇)依赖"列表内命令顺序 = 调用顺序",交错录制会让这个前提物理上不成立。

结论:可以把资源准备的调用(create/update)铺到工作线程,列表必须单线程录。

规则二:submit 必须单线程,顺序就是 GPU 执行顺序

RD 不维护提交队列,它把 submit() 直接翻译成 vkQueueSubmit,提交顺序就是命令在 GPU queue 上的顺序。两个线程交替 submit(),两条命令流的相对顺序由线程调度决定——这次是 A 先,下次可能是 B 先,渲染结果不确定地抖动。对渲染器来说这等于每帧随机坏帧。

// 渲染线程外的工作线程做完活,正确姿势:
call_deferred("_on_worker_done");  // 回主线程统一 submit

Forward+ 渲染器的实际做法值得抄:所有 RD 调用发生在渲染线程(RenderingServer 的线程模式见服务器线程那篇),工作线程只做纯 CPU 数据准备,完成后把结果移交渲染线程统一录制提交。RD 的 staging buffer 设计也是按这个模式来的——工作线程只管 texture_update() 填数据,何时安全复用由 submit 节奏决定。

规则三:sync() 永远不要在非渲染线程调

sync() 内部是 fence 等待,阻塞到 GPU 追平。在工作线程里调它有两个后果:一是工作线程被拖住,任务池失去并行度;二是它会让 staging buffer 的三段式回退(见 RD 抽象层那篇)提前触发全停档——别的线程正在排队等用的 staging block 被一把 fence 清空。需要读回结果的流程(烘焙、截图)应该整个挪到专用线程,用本地 RD 做自己的 submit/sync 闭环,别碰主 RD 的时间线。

规则四:本地 RD 是唯一的真正并行口

RenderingServer.create_local_rendering_device() 创建的独立 RD 实例有自己完整的设备上下文:自己的列表状态、自己的 staging 池、自己的提交队列。两个线程各持一个本地 RD,互不干扰,这是官方认可的多线程 GPU 用法——粒子烘焙、批量贴图压缩这类离线吞吐型任务的标准解法。成本是每个本地 RD 占一份设备级开销,用完要 free(),别当线程池里随手建的对象用。

四条规则一图流

RD 多线程四条规则:什么能并行,什么必须串行 ✓ 资源 create / update 方法级互斥,多线程安全 ✗ 列表录制 单活动列表,跨线程报错 ✗ 多线程 submit 提交顺序 = GPU 顺序 ✗ 工作线程 sync() fence 全停拖累任务池 ✓ 本地 RD 每线程一个 独立队列,用完 free

为什么设计成这样

把对比对象摆出来就清楚了。D3D12 的 command list 可以任意多个、任意线程录,最后 ExecuteCommandLists 按数组顺序提交——顺序由参数数组定义,和录制线程无关。Vulkan 的 secondary command buffer 同理。这两个 API 都把"录制并行度"和"提交顺序"解耦了,代价是 API 复杂度:调用方要自己管理 command pool 的线程亲和、primary/secondary 的继承关系、提交数组的拼装。

RD 选了另一条路:只有 primary list、只有录制线程提交、顺序即调用序。丢失的是录制并行度,换来的是屏障推导和 staging 管理的自动化——这两个能力都建立在"命令序 = 调用序"上。对引擎内部的渲染器来说这个交易很划算(录制本来就在单渲染线程里),对用户暴露的本地 RD 场景(离线批处理)单线程录通常也不是瓶颈。真正需要多列表并行录制的场合,RD 就不适合了,得绕过它直接调驱动层——这正是 4.3 把 RDD 拆成公开层的原因之一。

排查线索

如果多线程用了 RD 后出现间歇性 validation 报错或花屏,按顺序查:有没有第二个线程在录列表(搜 compute_list_begin/draw_list_begin 的调用点);submit() 调用点是不是散布在多个线程;工作线程有没有藏 sync()。前两类错误的报错文本通常是"no compute list is active"或"already recording",第三类没有报错,只有随机坏帧——最阴。

本文基于 Godot 4.2/4.3 源码:servers/rendering/rendering_device.h 的列表状态字段与 _THREAD_SAFE_CLASS_,rendering_device.cpp 的 submit()/sync() 实现。