把一个耗时函数提交给线程池,往往只需要改几行代码。但从这一刻开始,原来隐含在函数调用中的保证,也可能一起消失了。
过去,函数返回意味着它的计算已经完成,下一段代码可以读取结果。现在,提交函数返回,可能只意味着任务进入了队列。调用顺序没有变,数据的生产与消费顺序却已经变了。
帧相位解决“什么工作应该先发生”,JobSystem 解决“工作由谁执行、怎样完成”。两者真正联动的关键,不是给每个阶段配一个线程池,而是把逻辑先后转化成可靠的完成边界,再把完成结果转化成安全的数据交接。
本文采用一种容易落地的基线:可变世界由协调者推进,经过约束的计算在阶段内部并行,必需任务在交接前收口,消费者读取冻结后的帧数据。跨帧重叠是可以继续扩展的能力,不是这套架构成立的前提。
一、相位顺序正确,数据为什么仍然可能出错
考虑一条简单流程:
1 | 更新世界 → 整理呈现数据 → 开始绘制 |
串行执行时,“整理呈现数据”返回之后,绘制可以使用其结果。若只做下面这样的改动:
1 | 更新世界 |
相位名称仍然按顺序出现,但绘制可能读到尚未填完的数组。
这类问题不能仅靠“阶段序号不得回退”发现。游标只能证明已经进入了哪个阶段,不能证明这个阶段发出的异步工作已经完成。
需要补上的关系是:
1 | 前置计算真正完成 |
对于本文的同步交接模型,一个阶段能够成功向后推进,至少要满足:
1 | 进入顺序合法 |
注意这里说的是“交接所需的任务”,不是“整个任务系统中的所有任务”。后台资源加载可以跨越多个显示帧,不应因为仍在运行,就阻止每一帧进入呈现。
这也是第一个设计原则:帧推进依赖明确的完成集合,而不是全局队列是否清空。
二、五种边界各司其职
很多调度问题来自把不同概念混成一个对象:既用它表示本帧,又用它表示线程归属,还希望它负责取消和发布。
更清楚的划分如下:
| 概念 | 回答的问题 | 不应被误认为 |
|---|---|---|
| 帧相位 | 哪些数据关系必须先成立? | 工作已经完成的证明 |
| 完成组 | 当前等待的任务究竟有哪些? | 全局任务队列或自动包含所有后代的任务树 |
| 生命周期作用域 | 谁拥有这些任务,何时必须取消并收口? | 任务优先级或线程池选择 |
| 执行域 | 任务允许在哪里执行,使用哪类执行资源? | 世界数据的所有权 |
| 发布边界 | 哪些结果已经完整、有效,可以交给消费者? | CPU 或 GPU 所有工作都已结束 |
例如,一个呈现准备任务可以属于某一帧的生命周期作用域,被加入某个实例提取完成组,并在通用工作池执行。三者并不冲突:作用域描述归属,完成组描述等待集合,执行域描述执行权限。
同样,长期服务可以持续提交新任务,但某一帧只接纳它已经完成、且版本有效的结果。服务作用域还活着,并不意味着当前帧还不能完成。
这些概念可以通过轻量适配器组合起来,不必让普通业务任务直接操作全部底层机制。对于一个纯计算片段,知道自己的输入范围和输出位置,往往已经足够。
三、协调者推进世界,工作者承担受控计算
3.1 不要把整个系统更新一股脑塞进线程池
一个名为“更新角色”或“更新物理”的入口,内部可能混合了多种行为:修改组件、触发生命周期回调、访问脚本环境、调用线程相关的设备接口,以及等待更细粒度的内部任务。
这样的入口不能仅凭耗时较长,就变成普通工作线程任务。
一种稳健的分工是:
1 | 协调者: |
这不意味着世界计算永远不能并行,而是先把可证明安全的部分开放出来。组件类型没有交集,也不等于系统之间没有共享状态;事件分发器、全局缓存、资源管理器和设备上下文都可能构成隐藏依赖。
特别要警惕“看起来只读”的接口。读取变换时补算缓存、访问材质时触发加载、查询组件时创建存储,都可能产生写入。建立只读窗口之前,应完成这些必要的求值与准备。
3.2 顶层协调与内部并行可以同时存在
物理更新是一个典型例子:顶层入口留在协调者,求解过程中的任务仍可交给专门的执行域并行。
1 | 协调者进入一个仿真步 |
顶层入口没有变成线程池任务,不代表内部只能串行。反过来,如果把一个会阻塞等待子任务的顶层入口先放进普通工作池,就可能制造嵌套等待,后文会讨论这种风险。
有些内部任务还会在执行过程中生成新的任务。对此,适配器需要保留动态依赖与完成协议,不能在开始时随意封闭一个静态成员集合,就宣称所有后续任务都会自动被覆盖。
3.3 统一任务系统,不等于所有工作共用一个线程池
通用计算、物理求解、受限的命令录制,可以使用不同执行域,但统一遵守接纳、取消、完成和诊断契约,并由上层统一预算线程资源。
串行通道也不一定拥有专用线程。它可以借用共享工作池,只保证某条通道上的任务按顺序、互不重叠地执行。如果代码要求每次都在同一条物理线程上运行,仅有串行保证并不够。
某些执行资源还属于已注册的执行位置,例如私有暂存区或命令分配器。此时必须验证执行身份和代次,不能假设“任务编号就是工作线程编号”,更不能给临时协作执行者伪造一个资源槽位。
四、完成组:把“等到结束”说清楚
4.1 为什么提交结束之后还需要封闭成员集合
假设一个阶段准备提交三个任务。第一个任务执行得很快,在第三个任务提交之前就结束了。
如果把“当前待处理计数为零”直接理解为整组完成,就可能在成员仍然增加时过早发布终态。
解决方法是区分两个事实:
1 | 成员是否已经全部确定? |
完成组可以采用下面的状态模型:
1 | 接纳中 |
“封闭”只表示不再接纳新成员,不表示任何任务已经执行完成。成功也不同于终止:被取消或执行失败的组可以已经安全收口,但不能被当成成功结果使用。
显式完成组只覆盖显式加入的成员。任务内部发出的其他异步请求,不会因为逻辑上像“子任务”,就自动成为该组的一部分。普通帧任务可以禁止这种脱离管理的派发;确实需要动态子任务的模块,则应提供经过验证的专用协议。
4.2 一个同步批次适配器应当保证什么
对上层而言,可以将组和作用域隐藏在一个同步接口后面:内部并行,返回时全部收口。
1 | 执行并行批次(片段列表、帧凭证): |
这个接口的重要承诺不是“任务可能并行”,而是:任何返回路径都不遗留仍在访问调用者临时数据的任务。创建、提交和回调抛出异常时,也必须进入同样的清理保护。
成功路径只需要回收已经完成的记录,不应为了方便统一处理,仍然对成功任务发出取消请求。取消回调可能具有可观察行为,正常结束与失败清理应该具有不同语义。
4.3 完成不应早于任务捕获资源的收口
任务函数执行完最后一行,未必意味着它已经不再触碰外部资源。闭包销毁、暂存对象释放、引用归还,也可能属于执行尾部的一部分。
因此,若上层约定“等待完成后即可释放批次输入”,完成通知就必须覆盖这些尾部操作,或者用等价的所有权协议保证它们安全。
跨线程读取结果还需要真实的同步语义。普通计数器归零或阶段编号递增,不能自动建立正确的 happens-before 关系;完成实现应通过合适的同步操作使生产者写入对消费者可见。相关原则可参见 C++ 工作草案:数据竞争与执行顺序。
五、一个落地例子:并行生成呈现数据
比起让整个世界任意并行,更适合作为起点的是纯数据准备:从稳定输入中筛选对象、统计数量、填充数组,最后生成只读结果。
5.1 先建立读取窗口,再派发任务
准备开始前,协调者需要完成影响输入的世界写入,例如变换更新、最终姿态整理、相机求值和附着关系调整。
随后建立一个受控读取窗口:任务可以临时借用稳定的输入范围,但这段时间内不允许结构修改,也不允许输入对象被释放。窗口一直持续到任务全部收口。
这和“输入已经是一份独立快照”不是同一回事。受控借用依赖协调边界保持稳定;独立快照则拥有自己的数据与资源持有关系。两者都可以用于准备阶段,但不能把一包只读指针误认为已经拥有独立生命周期。
后台服务此时可以继续计算自己的结果,却不应绕过接纳边界修改活跃世界。
5.2 把并行机会放在真正无冲突的工作上
生成可变长输出时,一种常见结构是:
1 | 稳定输入 |
对应的伪代码如下:
1 | 块列表 = 按稳定输入次序分块 |
这样不需要多个任务同时向同一个动态数组追加,也不需要未经审计就共享一个暂存分配器或去重表。
如果还需要材质编号、姿态去重或资源索引映射,可以先由各块生成局部结果,再由协调者确定性地归并。不要按工作线程编号或完成先后来分配最终编号,否则调度抖动可能变成输出抖动。
写入区间不重叠解决的是正确性基础,并不保证性能理想。相邻区间共享缓存行、块间工作量差异过大、归并阶段过重,仍可能抵消并行收益。
5.3 相位、批次和子步不要混在一起
一个呈现准备阶段可以包含多个依赖批次:统计批次完成,才允许填充批次开始。批次只是相位内部的执行结构,不必为每个批次增加一个全局相位。
整批收口是一种保守、容易审计的实现。若以后需要更细粒度的重叠,可以让某个下游任务在自己的全部前驱完成后被唤醒,而不是等待所有无关工作。改变的是调度粒度,不能改变必要依赖的完成语义。
固定步仿真又是另一层循环。若本显示帧需要推进多个物理步,每个子步都必须完成自身的必要工作,再进入下一子步。帧级数据提取通常发生在这些子步和后续世界整理完成之后,不能因为“每个阶段都有任务组”,就把不同子步的输入和结果混在一起。
六、失败路径决定并行接口是否真正安全
6.1 取消请求不能替代排空
设一个阶段准备提交四个片段:前两个已经接纳,第三个因为队列已满而被拒绝。
此时不能直接释放输出数组并返回失败。第一个任务可能正在写数组,第二个任务可能刚被工作线程取走。甚至在零工作线程配置下,任务还可能已经由提交者协作执行过。
安全的处理顺序是:
1 | 发现局部提交失败 |
取消只是表达“不再需要这些结果”。尚未开始的任务可以被跳过,已经执行中的任务通常只能在检查到取消条件后退出,或者完成当前计算后返回。
排空才保证:不再有这些任务继续访问借用的数据。
因此,“请求取消后立刻清空容器”和“等待超时后直接析构输入”,都不能构成安全的失败路径。若需要超时返回,必须将仍存活的任务和数据交给另一个明确的生命周期持有者,而不是假定它们已经停止。
6.2 后备执行只能重试可重复的部分
纯提取任务读取稳定输入,写入可丢弃的临时结果。全部任务排空后,可以丢弃这次输出,按相同输入串行重算。
但不能因此设计一个通用规则:“并行失败,就把整个阶段重新执行一次。”
如果阶段包含仿真推进、脚本事件、资源挂载或音频播放,部分操作可能已经发生。整体重试会把副作用执行两遍。
正确的分界是:
1 | 可重复的纯准备: |
后备执行还应区分原因。容量不足或某种可恢复的执行条件,可能允许纯计算重试;世界已经替换、帧已取消、运行环境正在关闭,就不应该靠串行重试“挽救”一个已经失去发布资格的结果。
另外,排空时不能持有任务结束所必需的锁。若协调者拿着世界锁等待任务,而任务正在等待同一把锁,即使有完善的取消标记,也可能无法结束。
七、等待是受控执行边界,不是任意帮忙
7.1 为什么普通帧任务不应再嵌套同步批次
假设工作池有四条线程。四个外层任务分别占住一条线程,每个任务又提交一批子任务并阻塞等待。
1 | 四条工作线程:全部在等子任务 |
这就是典型的线程池饥饿风险。某些运行时支持任务挂起、继续任务或经过证明的嵌套协作,但不能因为“理论上可以支持”,就默认任何同步等待都安全。
对于一般帧准备,最容易审计的规则是:同步批次由协调者发起,普通片段任务不再调用需要等待的顶层准备入口。
把多轮依赖放回协调层即可:
1 | 协调者等待统计批次 |
不必把上述整段流程再包装成一个工作线程任务。
拥有动态任务生成和专用进度协议的模块,需要单独保留其正确性机制。禁止一般帧任务嵌套等待,不等于可以删除这些模块的内部完成协议。
7.2 协作等待必须有权限和范围
等待期间,协调者可以执行部分允许的任务,减少空等,也为某些零工作线程配置提供前进能力。
但“帮助执行”不应理解为从所有队列随便取一个任务。可执行集合至少受到两类约束:
1 | 属于本次允许推进的完成边界 |
具有线程或专用资源要求的录制任务,不能因为协调者正在等待,就被一个没有对应执行身份的线程顺手执行。串行通道也不能为了推进当前任务而跳过尚未完成的前驱,破坏原有顺序。
如果当前边界无法由现有工作线程或合法协作执行者推进,就应报告明确原因,或由上层先满足必要条件,而不是无限自旋,更不能偷偷扩大权限。
零工作线程也不意味着所有执行域都必须自动退回调用线程。纯 CPU 片段可以支持调用者推进;受限执行域可以明确拒绝接纳,由业务层选择另一种合法策略。
7.3 不要用“运行到全局空闲”表示帧完成
长期服务可能持续提交解码、编译或网络相关任务。等待全局空闲,会把当前帧的截止时间绑定到与它无关的工作上。
帧批次应该等待自己的显式成员。其他工作仍可由工作线程正常调度,但不应被计入本帧的完成条件。
“等待得足够少”并不是漏等,而是把依赖边界描述得足够准确。
八、任务全部完成之后,还差一次安全发布
8.1 算完了,不代表结果仍然有效
想象一个加载或准备任务开始时,世界还在运行;任务完成前,用户已经切换了场景。
旧任务可能成功算出完整结果,但这个结果已经不属于当前世界。任务成功与结果可用,是两件不同的事。
可以为准备工作携带一个轻量身份:
1 | 世界身份 + 世界代次 + 帧序号 |
协调者在开始准备、任务收口、冻结和发布前检查身份。世界替换使旧代失效,本帧取消使当前结果失去发布资格,新帧推进也可以使旧帧不再具有“当前发布者”的资格。
身份检查并不负责保活。一个弱凭证只能判断结果是否过期,不能让任务安全访问已经析构的世界。受控借用仍然需要窗口内的生命周期保证;跨窗口使用的数据则需要独立持有。
切换或关闭世界时,可以先使旧身份失效并停止相关接纳,再取消、排空仍持有借用的任务,最后销毁被借用对象。底层执行环境也必须晚于需要借助它完成清理的任务作用域退出。
8.2 不可变数据要覆盖间接引用
把顶层对象标为只读,不代表整个数据包真的不可变。
它可能仍然指向会被修改的材质参数、骨骼数组、视图缓存或临时调试几何。消费者沿指针继续读取,仍可能重新进入可变世界。
冻结结果时,应明确区分:
- 需要复制或移交的帧级值数据;
- 可以共享、但必须持有生命周期并校验版本的资源;
- 仅在准备窗口内有效、发布前必须消除的临时借用。
消费者得到的应是一份封闭、可解释的输入,而不是“顶层只读、内部继续访问活跃世界”的外壳。
版本校验用于拒绝不匹配的结果,不能让并发读写同一份资源自动变安全。资源仍需采用不可变版本,或由同步协议保护其访问与替换。
8.3 先全部预检,再开始外部副作用
一份帧结果可能包含图像、音频命令、界面和调试数据。若逐份“校验一个、消费一个”,后面某份数据才发现无效时,前面的声音或设备操作可能已经发生。
更稳健的发布结构是:
1 | 完成最后的世界写入 |
“任务组封闭”和“数据冻结”在这里是两种动作:前者关闭任务接纳,后者终止数据写入。不能因为任务组已经封闭,就把尚未写完的数据交出去。
所有必需结果预检完成后,不依赖绘图的音频命令可以先交给音频侧处理,无需等待 GPU。界面叠加是否依赖场景目标,则由具体消费关系决定。共享发布边界,不意味着所有消费者都必须挂在渲染模块后面,更不意味着增加了独立音频线程。
这仍然不是跨设备事务。消费开始后,某个设备失败或本帧被取消,已经发生的外部效果未必能够撤销。合理策略是停止后续消费并禁止整包重放,而不是再次发布整帧,让已经应用的音频或其他命令重复发生。
一次性发布资格也不是全局锁。它用于防止重复发布;世界写入、资源版本变化和跨线程取消,仍需有明确的协调与同步协议。
8.4 当前发布与延迟反馈,不应使用同一种过期规则
发布新帧通常要求身份对应当前可发布帧。但 GPU 读回、性能时间戳等反馈天然会晚到,不能因为它们来自前几帧就全部丢弃。
对延迟反馈,更合适的判断通常是:来自同一世界与同一代次,源帧身份有效,并且涉及的对象、视图或输出代次仍然匹配。
这样既能拒绝旧世界的迟到结果,也不会错误丢弃仍属于当前世界的合法反馈。
九、CPU 批次完成与 GPU 完成之间,还有几道边界
在多层流水线中,“完成”不能只用一个布尔值表示。
| 边界 | 已经成立的事实 | 仍不能推断的事情 |
|---|---|---|
| 准备任务收口 | 本批 CPU 准备工作不再访问临时输入输出 | 结果已经校验并发布 |
| 帧数据发布 | 结果满足交接条件,可交给消费者 | CPU 消费或设备执行已经结束 |
| CPU 消费结束 | 对应 CPU 消费者不再读取这些数据 | GPU 不再使用相关资源 |
| GPU 提交成功 | 设备队列接纳了工作 | 设备已经完成工作 |
| GPU 完成确认 | 对应提交达到规定的完成条件 | 图像已经实际显示到屏幕上 |
CPU 命令录制任务可以被 JobSystem 管理,但录制完成只说明命令准备完毕。命令提交后的设备生命周期仍应由图形后端的完成协议管理。以图形 API 的命令缓冲生命周期为例,待执行状态与执行完成后的状态具有不同使用限制,参见 Vulkan 规范:命令缓冲生命周期。
工程上可以把大块 CPU 帧数据与 GPU 资源持有关系拆开:CPU 消费结束、且没有其他读取者后,部分 CPU 存储可以释放;上传页、命令分配器和设备资源则继续由提交持有物保留,直到真实 GPU 完成,或确认未提交工作已经安全放弃。
不能用 CPU 帧号增长、帧数据对象析构或任务组完成,替代真实 GPU 完成证明。
这套边界在同线程直接交接时就已经有价值,不需要先增加渲染线程或跨帧队列。若后续要重叠多帧,还必须单独设计背压,并计算“正在构建、等待消费、正在消费”的全部存活数据,而不是只看队列长度。
十、联动性能看关键路径,不看线程有多忙
10.1 相位时间取决于最晚完成的必要依赖
假设一个准备批次包含三个独立片段,纯计算耗时分别为 3、3、2 个时间单位。在执行资源充足且忽略额外成本的示意模型里,串行需要 8 个单位,并行下限可以接近 3 个单位。
但真实耗时还包括提交、排队、同步、缓存竞争、分配与归并。这组数字只是解释模型,不是性能测量。
如果某个片段承担了大部分工作,即使另外几条线程一直忙碌,也未必缩短关键路径。若统计完成后还必须串行做一次昂贵归并,归并同样会成为阶段下限。
可以用一个理想化下界帮助思考:
1 | 并行执行时间 ≥ 最大值(总计算工作量 ÷ 可用执行者数量,关键依赖链长度) |
实际系统通常还要加上调度与数据移动成本,而且不同执行域的资源未必可以互换。因此,增加线程数量不能无限缩短一帧。
10.2 粒度、预算与队列接纳要一起设计
任务太小,计算量不足以覆盖提交和同步开销;任务太大,负载不平衡和尾部等待会变重。
可以设置串行阈值,并依据实例数、骨骼数量、几何展开成本等估计分块,而不是只按固定对象数量盲目平分。一个复杂对象的工作量,可能远大于多个简单对象。
线程预算应覆盖通用计算、物理和录制等所有执行域,避免各模块都按“机器核心数”创建一整套工作线程。
另外,帧作用域只是生命周期标签,不会自动给予任务高优先级。帧内关键计算如果与长时后台工作共享执行资源,仍需关注排队延迟、负载隔离和接纳策略。
有界队列可以在过载时明确拒绝接纳,但它只约束相应队列的排队数量,不自动限制已经运行的任务、同时活跃的队列数量,以及仍被消费者持有的帧数据。内存预算应覆盖这些存活对象。
10.3 测量从排队到发布的整条链
只测任务函数体,容易遗漏真正的问题。至少应区分:
1 | 提交 → 开始执行:排队延迟 |
还应记录局部提交失败、取消、后备执行、任务粒度和归并成本,并按世界、帧、相位与子步关联。
等待时间也不能直接等同于线程空闲时间:协作等待期间,协调者可能正在执行任务。诊断时要区分真正阻塞与协作执行。
调度遥测宜按需开启,避免在每个热路径查询中扫描全部任务状态。没有开启采样,应报告“未测量”,而不是把缺失样本显示成零耗时。
十一、把整条协同链放进一段伪代码
前面的概念可以组合成下面的显示帧协调流程。它是职责示意,不要求每一行都成为新的全局相位。
1 | 推进一个显示帧: |
所有失败和异常出口,都必须遵守已经建立的清理契约。伪代码中的“结束本轮”不表示可以跳过排空、提前释放活跃任务的数据,也不表示回滚已经完成的仿真或设备副作用。
这条流程中,帧相位没有被任务系统取代,任务系统也没有变成第二套世界更新规则。相位提供语义顺序,协调者将阶段拆成可完成的工作,JobSystem 提供受控执行与收口,发布层则负责最后的数据交接。
十二、用边界测试证明联动成立
一次成功运行只能证明某条路径走通,不能证明提交拒绝、迟到结果和部分失败时仍然正确。
测试应围绕边界设置反例:
| 验证场景 | 需要证明的性质 |
|---|---|
| 零、一、多工作线程执行同一纯准备输入 | 结果一致、归并顺序稳定,允许的零线程路径能够前进 |
| 提交到一半被拒绝 | 已接纳任务全部收口,不遗留访问临时输出的执行者 |
| 某个任务或捕获对象的清理被刻意阻塞 | 完成通知不会早于该边界要求的资源收口 |
| 普通帧任务调用需要等待的顶层批次 | 明确拒绝或由经过验证的机制处理,不发生隐式嵌套阻塞 |
| 等待者没有某执行域所需能力 | 不越权执行,不伪造资源身份 |
| 只等待一个显式任务组 | 无关服务任务不会被计入该组的完成条件 |
| 准备期间取消本帧或替换世界 | 已接纳工作安全收尾,旧结果不发布 |
| 后一份结果预检失败 | 前面的消费者也尚未开始 |
| 重复请求发布,或某消费者已经执行后失败 | 不重放整包,不重复已发生的外部效果 |
| 一帧执行多个固定子步 | 子步完成边界不混淆,帧级提取读取正确结果 |
| CPU 已完成而 GPU 仍未完成 | 设备使用的资源保持有效 |
| 延迟反馈来自旧世界或旧对象代次 | 拒绝错误归属,但保留仍合法的延迟反馈 |
这是一份验证设计,不是某套测试已经通过的声明。可以用同步闸门、计数器和受控故障注入精确制造时序,避免依赖“睡眠若干毫秒,任务应该已经开始”这样的概率假设。
纯准备的串行实现还可以作为参考路径,与并行实现比较字段、数量、稳定编号和资源引用。对于浮点归约或物理求解,则应先定义可重复性要求:固定输入与稳定归并有助于复现,但不自动保证任意平台、线程数下逐比特一致。
性能验收与正确性验收应分别进行。先确认边界成立,再比较相同负载下的准备时间、队列延迟、等待尾部、内存峰值和端到端延迟。工作线程利用率上升,不能单独作为联动优化成功的证据。
结语:并行不应该削弱时间契约
串行程序把许多保证藏在函数返回里。引入 JobSystem 之后,这些保证需要被重新表达出来:谁属于当前批次,何时算真正结束,失败后怎样收口,结果是否仍有效,以及哪个消费者可以从哪个边界开始使用它。
帧相位与任务系统的协同,不是把“先后执行”简单改成“同时执行”,而是把必要的先后变成明确的依赖,把可并行的部分限制在安全窗口内,再用完成与发布协议连接两侧。
相位告诉我们什么时候可以开始,完成边界告诉我们什么时候可以继续,发布边界告诉我们什么时候可以交付。
当这三个问题都能被代码和测试回答,更多线程才会成为执行能力的扩展,而不是时间语义的漏洞。