6.3 多线程与队列并发 本节摘要:并发有两层:线程并发(CPU 侧谁碰谁不碰)与队列并发(GPU 侧谁和谁真并行)。本节给出对象级的线程安全清单、多队列并行的提交协议、跨队列族的资源移交写法,并把线程模型与队列模型对齐成一张可直接照做的工程图。 应用线程各录各的命令、GPU 各条队列各跑各的活——这两层并发拼在一起时,哪个对象需要加锁?哪两条队列真正并行?Vulkan 对这两个问题的答案都以查询与声明为准,本节把它们整理成可执行的清单。先回答一个更根本的问题:为什么非要并发不可?——因为单线程录制的 CPU 上限与单队列的调度空转,是当代渲染吞吐的两大天花板,绕不开。
本节摘要:并发有两层:线程并发(CPU 侧谁碰谁不碰)与队列并发(GPU 侧谁和谁真并行)。本节给出对象级的线程安全清单、多队列并行的提交协议、跨队列族的资源移交写法,并把线程模型与队列模型对齐成一张可直接照做的工程图。
应用线程各录各的命令、GPU 各条队列各跑各的活——这两层并发拼在一起时,哪个对象需要加锁?哪两条队列真正并行?Vulkan 对这两个问题的答案都以查询与声明为准,本节把它们整理成可执行的清单。先回答一个更根本的问题:为什么非要并发不可?——因为单线程录制的 CPU 上限与单队列的调度空转,是当代渲染吞吐的两大天花板,绕不开。
Vulkan 把多线程责任分得很清楚:设备侧的不可变对象天然线程安全,主机侧的有状态对象需要外部同步。按类清单化:
| 类别 | 代表对象 | 多线程规则 |
|---|---|---|
| 不可变资源 | Buffer、Image、Pipeline、集合布局、渲染通道 | 随便并发读,永不需锁 |
| 命令系统 | 命令池、命令缓冲 | 池与缓冲各自线程独占;缓冲必须单线程录 |
| 描述符系统 | 描述符池、描述符集 | 池与集按帧按线程分册,写侧不可并发 |
| 同步原语 | 栅栏、信号量、事件 | 等待可并发;复位与宿主置位要外部同步 |
| 队列 | VkQueue | vkQueueSubmit 本身线程安全,但跨线程提交顺序未定义 |
| 交换链 | VkSwapchainKHR | 图像采集需要外部同步,通常单帧单线程包办 |
清单的实际用法是设计约束而非事后补锁:命令池按线程分配(5.2)、描述符按帧分册(3.4)、采集与提交集中在帧线程——三条规矩守住,锁就基本不需要出现。需要提醒的一个高频误判:vkQueueSubmit 线程安全不代表「随便从哪个线程提交都行」,跨线程提交之间没有顺序保证,有依赖关系的工作仍须在同一次提交或用信号量显式连线。

多队列的提交协议只有一条主线:依赖用信号量声明,其余默认并行。每个队列各自 vkQueueSubmit,等待信号量与发出信号量在两侧成对出现;同帧内多个「发出」可以合并为一个信号量被下游等待(vkQueueSubmit 支持信号量数组)。排队行为有个不对称点要知道:同一队列内批间按提交序执行;不同队列之间只有显式信号量关系,驱动可自由穿插——这正是并行的来源,也是竞态的来源。
跨队列族的资源移交是协议里的特殊条款:独占共享模式(EXCLUSIVE)的图像与缓冲同时只归一个队列族所有,跨族使用要办「让渡与接收」手续——在屏障的 srcQueueFamilyIndex 与 dstQueueFamilyIndex 里填双方族号,原属队列先发释放屏障,接收队列再发获取屏障。语义与布局迁移共用一套屏障结构,所以常被忽略;验证层的队列所有权检查会把它点出来。
背景:某引擎要同时支撑流式资源上传、主场景渲染与每帧 GPU 剔除,初版全部塞在图形队列,上传挤渲染、剔除挤渲染,三项互相拖累。
操作:按能力查询结果拆队列。设备具备独立传输族与独立计算族,于是开出三条线程与三条队列的对应关系:帧线程管图形队列(渲染加呈现),上传线程管传输队列,计算工作投到计算队列(与图形同族不同队列)。连线协议:上传批完成发信号量,渲染帧头等待(只在该帧用到新资源时);剔除完成发信号量,渲染在剔除消费阶段前等待;呈现只挂图形队列。跨族移交出现在「上传到传输族、渲染在图形族」的纹理上,按条款办理让渡与获取。
结果:上传期间渲染帧时间稳定,剔除与主渲染部分重叠,整体帧时间下降;新增的复杂度集中在两条信号量链的失效路径处理(上传未完成时本帧跳过新资源)。
解读:三队列的收益全部来自「把互相拖累的三类工作物理隔离」,而成本全部集中在连线协议上。判据也因此清晰:存在物理上独立的队列族、且三类工作确有互相拖累的实测证据,才值得开;为并行而并行只会买到更多竞态。
变式:设备只有全能族时退化为「同族多队列」:三条逻辑队列共享一套硬件,并行收益打折,但排队隔离(避免大批拷贝把渲染顶出队)仍有价值。查询队列族的 queueCount 与能力位,永远先于架构决定。
把连线协议落成代码骨架,跨族移交的屏障是重点(省略号处沿用第 6 章的合同填法):
// 上传线程:传输队列提交,完成后发出信号量 VkSubmitInfo up = {VK_STRUCTURE_TYPE_SUBMIT_INFO}; up.signalSemaphoreCount = 1; up.pSignalSemaphores = &uploadDone; up.commandBufferCount = 1; up.pCommandBuffers = &uploadCmd; vkQueueSubmit(transferQueue, 1, &up, VK_NULL_HANDLE); // 帧线程:图形队列提交,先等上传信号量(从片段采样阶段开始等) VkPipelineStageFlags waitStage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; VkSubmitInfo gfx = {VK_STRUCTURE_TYPE_SUBMIT_INFO}; gfx.waitSemaphoreCount = 1; gfx.pWaitSemaphores = &uploadDone; gfx.pWaitDstStageMask = &waitStage; gfx.commandBufferCount = 1; gfx.pCommandBuffers = &frameCmd; vkQueueSubmit(graphicsQueue, 1, &gfx, frameFence); // 跨族移交:上传侧释放屏障(让渡)与渲染侧获取屏障(接收) // srcQueueFamilyIndex = transferFamily, dstQueueFamilyIndex = graphicsFamily // 两侧各一发,中间资源对原族不再可用
三个实践细节。信号量是一次性票据:发出被等待消费一次后即作废,复用要重新配对,逐帧新建加延迟删除是常态。失效路径必须写:上传未完成时,渲染帧要么等(帧头 vkWaitFences 语义不适用,用更细的信号量等待扩展或直接让本帧跳过新资源),要么跳过——跳过是更常见的选择,代码量也小。跨族移交的两侧屏障通常封装进「上传并移交」与「首次使用前接收」两个框架函数,业务代码不直接摸族号。