聊到 Vulkan,大多数人的第一印象是“渲染API”,但接触久了你会发现,它真正颠覆性的地方在于把多线程这件事从“加分项”变成了“默认项”。我最早在 OpenGL 引擎里做多线程时,折腾两三个月也就换来一点异步上传的收益;切到 Vulkan 之后,用 Multithreading with Vulkan 的思路去重构渲染器,一周时间就让同一台机器上的 CPU 帧时长从 7ms 掉到 3ms 以内。这篇文章不聊 API 手册,只聊我在实际项目里怎么做并发录制、怎么选同步原语、怎么调度队列提交,以及最后踩过的那些坑和实测数据,给正在纠结怎么把渲染器改成多线程的人一个可落地的参考。
1. Vulkan凭什么能撑起多线程:从API设计看并行渲染的底气
1.1 OpenGL时代那根“看不见的锁”锁住了谁
老一代图形 API 的核心问题不是慢,而是“把状态藏得太深”。OpenGL 驱动内部维护一个巨大的全局状态机,当前绑定的程序、缓冲、纹理、帧缓冲全部是隐式共享的,而且这个状态机通常和线程绑定在一起。你想让两个线程同时提交渲染命令,驱动必须保证全局状态不被互相踩坏,于是只能在内部加一把大锁。结果就是:多线程确实允许了,但提交一多,线程之间互相等锁,实际跑出来的帧率还不如单线程。
这种设计在单核年代没有矛盾,因为 CPU 本来就只有一个核能干活。但到了多核处理器普及之后,场景遍历、裁剪、合批、上传这些 CPU 密集工作在单线程里越堆越慢,GPU 性能再强也只能等着 CPU 把命令递过来。大家把这种状态叫“CPU-bound”,意思是瓶颈不在显卡,而在处理器那一侧。
1.2 线程安全边界:Vulkan把“谁该被锁”摆到了台面上
Vulkan 的做法和 OpenGL 完全相反:它不给你全局状态,而是把每个对象的状态都拆开,由应用自己管理。官方在设计时就明确了哪些操作是线程安全的、哪些需要外部同步。这个边界的划分非常关键,因为它直接决定了你敢不敢放手让多个线程同时录命令。
就我实际使用体会来说,Vulkan 的多线程友好主要体现在三点:
- 命令缓冲区(VkCommandBuffer)的录制天然鼓励每线程独立完成。只要不同线程操作不同的命令缓冲区,就不存在状态冲突。
- **命令池(VkCommandPool)**的设计目标是配合多线程。规范明确要求同一个命令池上的操作需要外部同步,但开发者可以很自然地做到“一个线程一个池”,从而彻底绕开锁竞争。
- **队列提交(vkQueueSubmit)**的入口本身允许并发调用。这意味着多个线程录完命令后,可以直接把各自的提交压到队列上,不需要一个“总调度线程”来中转。
这不是说所有对象都无线程安全了,比如同一个命令缓冲区同时被两个线程录制,或者同一个描述符集在录制过程中被另一个线程改写,都会触发验证层报错。关键是 Vulkan 把规则写得很透明,你只需要在几个明确的位置做互斥,其他部分可以放心并行。
1.3 多线程要抢回来的,其实是CPU帧时长
很多人以为用多线程是为了让 GPU 跑得更快,其实不是。GPU 的帧时长主要取决于渲染分辨率、着色器复杂度和绘制量,CPU 端无论怎么开线程都改变不了这些。多线程真正抢回的是CPU 侧准备一帧的时间:场景裁剪、资源上传、命令录制、提交等待,这些开销在过去都堆在主线程上。
一个很常见的现象是,单线程录制时 CPU 帧耗时 6ms,GPU 帧耗时只有 4ms,最终帧率被 CPU 卡在 160 FPS 上不去。多线程把录制拆成 4 份之后,CPU 帧耗时降到 2ms,帧率立刻翻身变成由 GPU 说了算。所以判断自己该不该做多线程,第一步永远是把 CPU 帧时长和 GPU 帧时长分别统计出来,前者明显大于后者才值得投入。
2. 多线程渲染器的架构决策:录什么、谁去录、怎么交
2.1 主线程、录制线程与提交线程的职责切分
动手写多线程渲染器之前,最先要定清楚的就是线程模型。我见过不少失败案例,是上来就把主线程里的渲染调用随意丢进线程池,结果资源竞争、帧顺序错乱,改了一个月还在修偶发白屏。更稳的做法是先分三个角色:
- 主线程(管理线程):负责任遍历、裁剪、排序,生成当前帧的绘制任务列表。它不直接碰 Vulkan 命令录制,只输出“这一帧要画哪些物体、用什么渲染状态”的描述。
- 录制线程(Worker 线程):每个 Worker 拿到主线程分配的任务子集,使用自己的命令池录制命令缓冲区。Worker 之间互不通信,唯一的共享是只读的几何数据。
- 提交线程(提交者):等所有 Worker 录完后,把多个命令缓冲区放进同一次
vkQueueSubmit提交出去。
这个模型的好处是职责单向流动:主线程 → 任务队列 → Worker → 完成事件 → 提交线程。每一步都只需要关注自己的输入输出,不会出现环形等待。
2.2 命令缓冲区三种划分思路
同一个“多线程录制”目标,实际落地时有三种常见的划分方式,我分别试过,各有取舍。
| 划分方式 | 做法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| 每帧一条命令缓冲区 | 每帧创建/重置一条,录完提交 | 实现最简单,资源开销低 | 录制串行,没法多线程 | 教学Demo、调试阶段 |
| 按空间分区 | 把屏幕或场景切成几块,每条线程录一块 | 可扩展性好,Worker间几乎没有依赖 | 需要处理视锥跨块和合并 | 大世界场景、可见性系统复杂 |
| 按物体集合分区 | 把物体列表均匀分给 Worker,每条线程录一组物体的 draw | 最容易理解和实现,worker负载均衡好调 | 如果两个物体共用动态描述符集,要小心更新冲突 | 中型场景、Draw Call 密集 |
我项目里最终选的是“按物体集合分区”,也就是把一帧里的所有可见物体编号,按线程数均分成连续几段,每个线程负责其中一段的vkCmdBindPipeline + vkCmdDraw。原因很直接:物体之间互相独立,渲染状态切换在录命令时不影响实际 GPU 状态,线程间连锁都不用加。
2.3 命令池绑定线程:一个线程一个池
这个点看起来小,但几乎所有第一次写 Vulkan 多线程的人都会在这里翻车。VkCommandPool本身不是线程安全的,如果多个线程共享同一个命令池去分配命令缓冲区,验证层会立刻给你一个 Host synchronization 报错。
正确做法是从一开始就坚持“一个线程一个命令池”。具体可以用thread_local存储每个 Worker 自己的池,或者把池句柄放进 Worker 上下文结构体,每次录制直接从自己的池里分配。池的初始大小根据每帧的命令数设置,别设太小的默认值,否则录制过程中频繁扩容会触发内部重新分配,反而拖慢性能。
2.4 资源的版本化:双缓冲和三缓冲为什么在多线程里更关键
单线程渲染时,双缓冲主要解决“上一帧还没画完,下一帧就要覆盖顶点数据”的问题。到了多线程,这个问题被放大了:主线程在整理第 N+2 帧的物体列表时,Worker 可能正在录制第 N+1 帧的命令缓冲区,而 GPU 可能还在执行第 N 帧。如果三者同时读写同一个顶点缓冲,那程序行为就是未定义的。
我的做法是给动态资源准备至少三个版本,用“帧索引取模”的方式分配。静态资源(场景网格、纹理)可以安全共享,因为录制命令只是引用它的句柄,不会改写内容;动态资源(骨骼动画缓冲、粒子数据、UI 顶点)必须按帧切三份,并保证“上一帧的 fence 已经等待完成”之后才允许复写最老的那个版本。三缓冲在 Vulkan 多线程下不是锦上添花,而是避免数据竞争的基本前提。
3. 命令缓冲区并发录制的工程细节:从指令分配到填满每一核
3.1 录制阶段哪些操作是隐式线程安全的
确认线程模型之后,下一步是搞清楚录命令的时候,哪些操作不用加锁、哪些必须避开。我先说结论:只要每个 Worker 使用独立的命令池和命令缓冲区,那么vkBeginCommandBuffer、vkCmdBindPipeline、vkCmdBindVertexBuffers、vkCmdDraw这些调用在各自线程内是完全安全的。这是因为它们只修改当前命令缓冲区的内部状态,不触碰任何全局对象。
真正容易出问题的是三类操作:
- 动态描述符集更新。如果两个 Worker 需要更新同一个描述符集,必须加锁,或者干脆给每个 Worker 分配自己的描述符池。我倾向于后者,因为锁在录制高频路径上的代价不小。
- 资源状态转换(Pipeline Barrier)。不同的 Worker 可能同时提交对同一资源的 Barrier,这时候 GPU 侧的执行依赖会变得很混乱。正确的做法是尽量把 Barrier 放在帧开始和帧结束的统一阶段处理,而不是在录制过程中零零散散地穿插。
- 主线程对资源结构的修改。Worker 在录制时会解析
VkBuffer、VkImageView等句柄,如果主线程恰好在这时候销毁这些资源,验证层可能不会每次都能抓住,但崩溃概率极高。建议资源的创建和销毁都在帧外统一阶段完成,录制期间只引用不修改。
3.2 一个录制线程的典型代码路径
用一个伪代码片段来展示 Worker 线程最核心的录制循环,可以更直观地看出整条数据流:
void WorkerThreadRecord( WorkerContext& ctx, const FrameTaskList& tasks, uint32_t frameIndex) { // ctx.cmdPool 和 ctx.cmdBuffer 都是本线程独占的 vkResetCommandBuffer(ctx.cmdBuffer[frameIndex], 0); VkCommandBufferBeginInfo beginInfo{}; beginInfo.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; beginInfo.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; vkBeginCommandBuffer(ctx.cmdBuffer[frameIndex], &beginInfo); vkCmdSetViewport(ctx.cmdBuffer[frameIndex], 0, 1, &ctx.viewport); vkCmdSetScissor(ctx.cmdBuffer[frameIndex], 0, 1, &ctx.scissor); for (size_t i = tasks.start; i < tasks.end; ++i) { const DrawItem& item = tasks.items[i]; vkCmdBindPipeline(ctx.cmdBuffer[frameIndex], VK_PIPELINE_BIND_POINT_GRAPHICS, item.pipeline); vkCmdBindDescriptorSets(ctx.cmdBuffer[frameIndex], VK_PIPELINE_BIND_POINT_GRAPHICS, item.layout, 0, 1, &item.descriptorSet, 0, nullptr); vkCmdBindVertexBuffers(ctx.cmdBuffer[frameIndex], 0, 1, &item.vertexBuffer, &item.vertexOffset); vkCmdBindIndexBuffer(ctx.cmdBuffer[frameIndex], item.indexBuffer, 0, VK_INDEX_TYPE_UINT32); vkCmdDrawIndexed(ctx.cmdBuffer[frameIndex], item.indexCount, 1, item.firstIndex, item.vertexOffset, 0); } vkEndCommandBuffer(ctx.cmdBuffer[frameIndex]); // 录制完成,把命令缓冲区句柄交给提交线程 ctx.completedCommandBuffers.push(frameIndex); }这个循环里每个 Worker 只和自己的一段DrawItem列表打交道,VkPipeline、VkDescriptorSet、VkBuffer全部是只读引用,所以根本不需要锁。实际项目中可以把vkCmdBindPipeline的频率进一步优化,比如同一管线连续画多个物体时,只在切换物体组时重新绑定。
3.3 重置时机与帧循环缓冲池
命令缓冲区的重置不像表面看起来那么简单。vkResetCommandBuffer可以在录制前调用,但前提是这条命令缓冲区上一次提交给 GPU 的工作已经完成。如果它还没执行完就被重置,轻则丢帧,重则驱动报 device lost。
我的做法是引入一个“帧循环缓冲池”:预分配MAX_FRAMES_IN_FLIGHT条命令缓冲区给每个 Worker,每帧用frameIndex % MAX_FRAMES_IN_FLIGHT轮流取用。由于帧索引循环的速度比 GPU 执行速度快,可能第 N 帧的命令跑到第 N+2 帧时才释放。所以我把MAX_FRAMES_IN_FLIGHT从常规的 2 提高到 3,给 GPU 留足追赶余量,然后在等待 fence 时保证该帧资源已释放。这个组合在实践中很少出现“Buffer still in use”的报错。
3.4 Secondary Command Buffer:递归录制的利与弊
Vulkan 提供了 Secondary Command Buffer,可以理解成“命令缓冲区里的函数”。主命令缓冲区通过vkCmdExecuteCommands把多个次级命令缓冲区嵌进来执行。这样设计本来就是为了支持多线程:每个线程录制一段次级命令缓冲区,然后由主线程统一嵌入。
我也试用过这个方案,后来放弃了,原因有两个。一是次级命令缓冲区录制本身有额外开销,当每个物体组的命令都比较短时,反而比直接在主命令缓冲区里录制更慢;二是它使用起来约束更多,比如动态状态设置不完整会导致执行报错。它更适合“场景裁剪后的大块子场景重现”,例如门户系统、模块化房间,不太适合我这种把所有 Draw Call 平铺开的中型场景。
4. 同步原语选型:Fence、Semaphore、Event不是三选一
4.1 三者的边界与适用场景
很多刚接触 Vulkan 的人会把 fence、semaphore、event 混为一谈,以为都是“等待信号”。实际上它们服务的是完全不同的同步层级,用错了就会陷入“看似等到了、实际没等到”的深渊。
| 原语 | 面向对象 | 典型用途 | 注意点 |
|---|---|---|---|
| Fence | CPU 与 GPU 之间 | 等待一整批提交在 GPU 上执行完成,然后 CPU 放心复用资源 | 可被 CPU 查询和等待,超时参数要设置合理 |
| Semaphore | GPU 与 GPU 之间 | 在队列提交之间建立执行依赖,例如图形队列完成后通知计算队列 | CPU 无法直接等待,必须借助提交结构体传递 |
| Event | 设备执行流程内部 | 在命令缓冲区内部或提交之间标记某个阶段的完成状态 | 适合精细的管线内同步,但从 CPU 调试角度看不如 fence 直观 |
用生活化类比的话,fence 像是“你给装修队打电话确认完工”,semaphore 像是“瓦工和水电工之间对讲机沟通”,event 则像是“施工现场的一道工序门禁”。
4.2 一条典型多线程帧流程的同步链
一个典型的多线程帧流程,同步链可以拆成三段:
- 录制阶段:几个 Worker 并行录制,它们之间靠任务分发天然完成同步,不依赖 Vulkan 原语。
- 提交阶段:所有 Worker 的命令缓冲区集合好后,由一个提交线程把它们放进一次
vkQueueSubmit里。提交时,可以用一个VkSubmitInfo数组把不同阶段的提交连接起来,用 semaphore 传递“上一个提交完成,下一个提交才能开始”的依赖。 - 帧结束阶段:这一帧的提交带一个 fence,主线程在下一帧开始前用
vkWaitForFences等待该 fence,确认 GPU 已经把这一帧的所有命令消耗完,之后才允许复用资源。
这里有个容易忽略的点:同一个vkQueueSubmit传入多个命令缓冲区时,它们会被按数组顺序依次执行,相当于把它们串成一个提交批次。如果两个子提交之间有真实的数据依赖,比如前一个写入的缓冲要给后一个采样,那还是得通过 semaphore 或者 pipeline barrier 显式同步,不能指望命令缓冲区数组顺序本身带来内存可见性。
4.3 一个实际发生过的死锁案例与根因
我在项目调试中遇到过一次非常隐蔽的死锁,现象是程序启动后跑到第 20 帧左右就卡死,CPU 占用率掉到 0。用调试器挂上去,发现主线程卡在vkWaitForFences,而所有 Worker 线程都卡在一个任务队列的等待上。
根因还是环形等待:主线程在等待 GPU 完成第 N 帧时,手里还攥着“场景资源更新锁”;Worker 线程第 N+1 帧的任务依赖这个锁更新骨骼动画数据,但 Worker 又被主线程排在任务队列里等 GPU 资源释放。也就是说,主线程等 Worker,Worker 等主线程手里的锁,GPU 的 fence 永远等不到有人提交下一帧。
当时的修复很直接:把主线程对锁的持有范围缩小到“只读场景结构”而不是“整个场景更新”,并且让主线程在等待 frame fence 之前先释放所有和录制相关的互斥量。现在我在设计线程模型时都会先画一遍“谁在等谁”的依赖图,凡是形成环的等待路径绝对不留。
4.4 等待超时参数与排障技巧
vkWaitForFences的 timeout 参数,我建议不要写UINT64_MAX。虽然规范允许,但一旦遇到驱动 bug 或者死锁,程序会真正“永久等下去”,排查几乎无从下手。我习惯写成10ms的超时,等待失败后打印出当前帧索引、fence 对应提交的标签、以及每个 Worker 的任务进度,然后中断在调试器里。这样定位问题通常五分钟之内就能完成。
另一个小技巧是给每次vkQueueSubmit的提交信息加一个pNext链上的自定义标签结构,把“哪一帧、哪个阶段”记录下来。验证层和 GPU 崩溃转储里能直接看到这个标签,而不是面对一堆光秃秃的VkSubmitInfo地址发愁。
5. 队列与队列族:多线程提交里的“交通规则”
5.1 队列族不等于队列:先看枚举结果再谈并行
“队列族”和“队列”是两层概念。队列族表示的是一组能力属性,例如支持图形命令、支持计算命令、支持传输命令;一个队列族下面可以包含若干个队列。多线程提交时,很多人第一反应是“我有两个图形队列,就让两条线程同时 submit”,但现实是很多消费级硬件只给图形队列族提供一个队列,多队列并行的期望直接落空。
所以动手之前,先老老实实枚举一遍设备能力,看看queueCount到底是多少。举个例子:
uint32_t PickGraphicsQueueFamily( VkPhysicalDevice device, uint32_t& queueCount) { uint32_t count = 0; vkGetPhysicalDeviceQueueFamilyProperties(device, &count, nullptr); std::vector<VkQueueFamilyProperties> props(count); vkGetPhysicalDeviceQueueFamilyProperties(device, &count, props.data()); for (uint32_t i = 0; i < count; ++i) { if (props[i].queueFlags & VK_QUEUE_GRAPHICS_BIT) { queueCount = props[i].queueCount; return i; } } return VK_QUEUE_FAMILY_IGNORED; }如果queueCount == 1,那思路就不应该放在“多个队列并行提交”上,而是老老实实把录制并行做好,再集中提交到这一个队列。
5.2 单队列多线程提交:常态下性价比最高的方案
我自己的经验是,绝大多数项目里单队列加多线程录制已经能把 CPU 瓶颈吃得很干净。原因在于队列提交本身不是渲染工作的重心,命令录制才是。多个 Worker 录好命令后,提交线程把命令缓冲区放进一个 submit 数组,一次性丢给队列,驱动会按顺序执行。这个过程中 CPU 侧的开销非常小,完全不需要用多队列来抢。
有人会问:那能不能让每个 Worker 录完命令立刻vkQueueSubmit?理论上可以,因为 VkQueue 的提交入口本身支持并发调用;实际做的时候你会发现两个问题。第一,多个线程同时调用vkQueueSubmit时,驱动内部的提交锁会让你的“并发提交”退化成几乎串行;第二,命令缓冲区的执行顺序会变得难以预测,一旦有跨提交的资源依赖,同步逻辑会复杂到你想放弃。所以,提交集中在一处永远更简单、更可控。
5.3 多队列并行:收益与同步负担的权衡
确实存在需要多队列的场景,比如异步计算。现在不少引擎会把轻量级计算任务放在计算队列上,与图形队列并行执行,从而压榨 GPU 的闲置计算单元。这时队列之间的依赖必须靠 semaphore 显式传递:图形队列等待计算队列写完某个缓冲,计算队列也等待图形队列释放某个资源。
我的建议是:先保证图形队列这一路完全稳定、多线程录制顺利跑通之后,再考虑加计算队列。过早引入多队列,会让错误排查难度成倍上升,因为同步链横跨两个队列族,验证层给出的提示往往模糊得多。先做好“单队列 + 多线程录制”,已经可以覆盖八成渲染器的性能需求。
5.4 命令缓冲区的复用限制:提交未完成不能重复提交
多线程提交里还有一个很容易忽略的限制:同一个命令缓冲区,在 GPU 执行完成之前不能再次提交,更不能把它的VkSubmitInfo同时交给两个线程去提交。原因是命令缓冲区对象记录的是一份命令流,它在执行期间被驱动视为只读的,重复提交会破坏执行状态。
所以我的资源循环策略是:每个 Worker 使用一条VkCommandBuffer,并且在frameIndex % MAX_FRAMES_IN_FLIGHT的轮转里,严格保证“上一轮对应的 fence 已等待完成”才允许它进入下一轮的录制。如果为了省内存把命令缓冲区数量压到和线程数相同,遇到 GPU 追不上 CPU 的峰值帧时,就会触发这个复用限制,表现为偶发的 device lost。
6. 实测数据与踩坑清单:真正跑起来之后才知道的事
6.1 一个可复现的测试场景与数据对比
我在某台 8 核测试机上做了对照实验,场景是 5000 个 Draw Call 的静态网格,顶点总量约 300 万,每帧没有动态合批,所有物体独立绘制。关闭 vSync,固定视口,记录 1000 帧的平均值。
| 配置 | CPU 帧耗时 | GPU 帧耗时 | 瓶颈类型 |
|---|---|---|---|
| 单线程录制 | 7.2 ms | 4.1 ms | CPU-bound |
| 2 线程录制 | 4.5 ms | 4.2 ms | CPU-bound |
| 4 线程录制 | 2.9 ms | 4.3 ms | GPU-bound |
| 8 线程录制 | 2.6 ms | 4.4 ms | GPU-bound |
从 7.2ms 降到 2.9ms 确实很诱人,但注意几个细节:4 线程之后收益趋缓,8 线程几乎没有进一步提升;GPU 帧时长反而微微上升,原因是多个 Worker 录制时命令缓冲区的内部排列和单线程录制不完全一致,同时提交带来的驱动开销让 GPU 的启动节奏产生微小变化。也就是说,线程数不是越多越好,够用就行。
6.2 验证层最常抓到的多线程错误
开启 Vulkan 验证层之后,多线程场景下最常见的三类错误让我印象深刻:
- Host synchronization violation:最常见的根因是多个线程同时操作同一个命令池或同一个描述符池。验证层会直接报出对象句柄,但它不会告诉你“该用 thread-local 池”,需要自己对着句柄查是谁共用。
- Descriptor set 更新冲突:两个 Worker 同时调用
vkUpdateDescriptorSets更新同一个描述符集。这种问题在单线程录制时完全不出现,开了多线程就随机闪崩。 - Command buffer in use:提交还没完成就把命令缓冲区重置了。这种错误有时候不立刻崩,而是隔几帧后驱动返回 device lost,最难排查。
我的应对策略是:上线前用验证层跑一遍所有关卡,把每一类报错都当成必修 Bug 来看待。宁可在开发期多花一天修锁,也不要在用户机器上面对随机白屏。
6.3 缩小问题范围的最小复现法
多线程 Bugs 最大的痛点是“时序偶发”,直接上来开 6 线程调起来很痛苦。我总结了一个三层递进的最小复现法:
- 先把 Worker 数设成 1,确认单线程录制下功能完全正常,排除渲染逻辑本身的问题。
- 再把 Worker 数调到 2,同时固定每帧的任务分区方式,禁用动态负载均衡,让每一帧的分区尽量保持一致,这样复现概率显著提高。
- 一旦问题复现,把每个线程的任务范围、起止时间、命令缓冲区句柄打印到日志,对比正常帧和异常帧的差异,基本就能锁到是哪个资源或哪个同步点出了问题。
这种方式的劣势是慢,但优势是稳。多线程渲染的同行们应该都体会过,那种“跑了 10 次只崩 1 次”的 Bug 最磨人,而系统化的缩小范围是唯一能让你摆脱玄学调试的路径。
6.4 几条已经被验证过的工程经验
最后把我反复验证过的经验集中列一下,全是实际项目里换来的教训:
- 共享资源尽量做成只读。场景静态顶点缓冲、静态材质参数,这些完全可以多线程无锁共享。需要动态更新的数据,才按帧数做多份拷贝。
- 描述符集更新集中在帧外。不要让 Worker 在录制过程中去更新共享描述符集,最好在主线程整理任务列表时就把每帧需要的描述符集准备好。
- 避免持锁等待 GPU。主线程在
vkWaitForFences之前,必须先释放所有可能被 Worker 持有的锁,否则你就是亲手造了一个环形等待。 - 先看 CPU 帧耗时再决定要不要多线程。如果单线程录制时 CPU 只有 2ms,而 GPU 是 6ms,多线程优化几乎不会让帧率变高,反而徒增复杂度和同步开销。
我个人对 Vulkan 多线程的体会是:多线程不是银弹,它只是把 CPU 端的帧准备时间挪到多核上摊薄。真正的关键不是把每个线程都填满,而是把资源的生命周期和同步链理清楚。最稳妥的路线是先让单线程能稳定跑完一帧,然后把一帧切成几块独立任务,最后才交给不同线程去填。这样走下来,Validation Layers 报错的数量能控制在个位数以内,性能提升也基本都能落到实处。