游戏引擎-帧相位与 JobSystem 协同:从执行顺序到完成边界

文章目录

把一个耗时函数提交给线程池,往往只需要改几行代码。但从这一刻开始,原来隐含在函数调用中的保证,也可能一起消失了。

过去,函数返回意味着它的计算已经完成,下一段代码可以读取结果。现在,提交函数返回,可能只意味着任务进入了队列。调用顺序没有变,数据的生产与消费顺序却已经变了。

帧相位解决“什么工作应该先发生”,JobSystem 解决“工作由谁执行、怎样完成”。两者真正联动的关键,不是给每个阶段配一个线程池,而是把逻辑先后转化成可靠的完成边界,再把完成结果转化成安全的数据交接。

本文采用一种容易落地的基线:可变世界由协调者推进,经过约束的计算在阶段内部并行,必需任务在交接前收口,消费者读取冻结后的帧数据。跨帧重叠是可以继续扩展的能力,不是这套架构成立的前提。

一、相位顺序正确,数据为什么仍然可能出错

考虑一条简单流程:

1
更新世界 → 整理呈现数据 → 开始绘制

串行执行时,“整理呈现数据”返回之后,绘制可以使用其结果。若只做下面这样的改动:

1
2
3
更新世界
将“整理呈现数据”提交给任务系统
开始绘制

相位名称仍然按顺序出现,但绘制可能读到尚未填完的数组。

这类问题不能仅靠“阶段序号不得回退”发现。游标只能证明已经进入了哪个阶段,不能证明这个阶段发出的异步工作已经完成。

需要补上的关系是:

1
2
3
前置计算真正完成
→ 其写入对消费者可见
→ 消费者才允许开始

对于本文的同步交接模型,一个阶段能够成功向后推进,至少要满足:

1
2
3
进入顺序合法
且 本阶段交接所需的任务全部成功结束
且 当前世界与帧仍然有效

注意这里说的是“交接所需的任务”,不是“整个任务系统中的所有任务”。后台资源加载可以跨越多个显示帧,不应因为仍在运行,就阻止每一帧进入呈现。

这也是第一个设计原则:帧推进依赖明确的完成集合,而不是全局队列是否清空。

二、五种边界各司其职

很多调度问题来自把不同概念混成一个对象:既用它表示本帧,又用它表示线程归属,还希望它负责取消和发布。

更清楚的划分如下:

概念 回答的问题 不应被误认为
帧相位 哪些数据关系必须先成立? 工作已经完成的证明
完成组 当前等待的任务究竟有哪些? 全局任务队列或自动包含所有后代的任务树
生命周期作用域 谁拥有这些任务,何时必须取消并收口? 任务优先级或线程池选择
执行域 任务允许在哪里执行,使用哪类执行资源? 世界数据的所有权
发布边界 哪些结果已经完整、有效,可以交给消费者? CPU 或 GPU 所有工作都已结束

例如,一个呈现准备任务可以属于某一帧的生命周期作用域,被加入某个实例提取完成组,并在通用工作池执行。三者并不冲突:作用域描述归属,完成组描述等待集合,执行域描述执行权限。

同样,长期服务可以持续提交新任务,但某一帧只接纳它已经完成、且版本有效的结果。服务作用域还活着,并不意味着当前帧还不能完成。

这些概念可以通过轻量适配器组合起来,不必让普通业务任务直接操作全部底层机制。对于一个纯计算片段,知道自己的输入范围和输出位置,往往已经足够。

三、协调者推进世界,工作者承担受控计算

3.1 不要把整个系统更新一股脑塞进线程池

一个名为“更新角色”或“更新物理”的入口,内部可能混合了多种行为:修改组件、触发生命周期回调、访问脚本环境、调用线程相关的设备接口,以及等待更细粒度的内部任务。

这样的入口不能仅凭耗时较长,就变成普通工作线程任务。

一种稳健的分工是:

1
2
3
4
5
6
7
8
9
10
11
12
协调者:
接纳输入和已完成外部结果
推进可变世界
执行有线程要求的顶层入口
建立稳定的读取窗口
拆分任务、等待收口、归并和发布

工作者:
读取明确的输入
计算自己的片段
写入私有结果或不重叠区间
返回,不自行推进世界生命周期

这不意味着世界计算永远不能并行,而是先把可证明安全的部分开放出来。组件类型没有交集,也不等于系统之间没有共享状态;事件分发器、全局缓存、资源管理器和设备上下文都可能构成隐藏依赖。

特别要警惕“看起来只读”的接口。读取变换时补算缓存、访问材质时触发加载、查询组件时创建存储,都可能产生写入。建立只读窗口之前,应完成这些必要的求值与准备。

3.2 顶层协调与内部并行可以同时存在

物理更新是一个典型例子:顶层入口留在协调者,求解过程中的任务仍可交给专门的执行域并行。

1
2
3
4
5
协调者进入一个仿真步
→ 物理适配器组织内部依赖任务
→ 允许的执行者推进这些任务
→ 内部任务与完成通知全部收口
→ 返回协调者

顶层入口没有变成线程池任务,不代表内部只能串行。反过来,如果把一个会阻塞等待子任务的顶层入口先放进普通工作池,就可能制造嵌套等待,后文会讨论这种风险。

有些内部任务还会在执行过程中生成新的任务。对此,适配器需要保留动态依赖与完成协议,不能在开始时随意封闭一个静态成员集合,就宣称所有后续任务都会自动被覆盖。

3.3 统一任务系统,不等于所有工作共用一个线程池

通用计算、物理求解、受限的命令录制,可以使用不同执行域,但统一遵守接纳、取消、完成和诊断契约,并由上层统一预算线程资源。

串行通道也不一定拥有专用线程。它可以借用共享工作池,只保证某条通道上的任务按顺序、互不重叠地执行。如果代码要求每次都在同一条物理线程上运行,仅有串行保证并不够。

某些执行资源还属于已注册的执行位置,例如私有暂存区或命令分配器。此时必须验证执行身份和代次,不能假设“任务编号就是工作线程编号”,更不能给临时协作执行者伪造一个资源槽位。

四、完成组:把“等到结束”说清楚

4.1 为什么提交结束之后还需要封闭成员集合

假设一个阶段准备提交三个任务。第一个任务执行得很快,在第三个任务提交之前就结束了。

如果把“当前待处理计数为零”直接理解为整组完成,就可能在成员仍然增加时过早发布终态。

解决方法是区分两个事实:

1
2
成员是否已经全部确定?
已接纳的成员是否都已结束?

完成组可以采用下面的状态模型:

1
2
3
4
接纳中
→ 封闭成员集合
→ 所有已接纳成员收口
→ 成功 / 失败 / 取消

“封闭”只表示不再接纳新成员,不表示任何任务已经执行完成。成功也不同于终止:被取消或执行失败的组可以已经安全收口,但不能被当成成功结果使用。

显式完成组只覆盖显式加入的成员。任务内部发出的其他异步请求,不会因为逻辑上像“子任务”,就自动成为该组的一部分。普通帧任务可以禁止这种脱离管理的派发;确实需要动态子任务的模块,则应提供经过验证的专用协议。

4.2 一个同步批次适配器应当保证什么

对上层而言,可以将组和作用域隐藏在一个同步接口后面:内部并行,返回时全部收口。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
执行并行批次(片段列表、帧凭证):
确认调用者是协调者
确认帧凭证仍有效
创建带清理保护的生命周期作用域与完成组

对每个片段:
尝试将纯计算任务加入该组
如果提交失败:转入失败收尾

封闭成员集合
在允许的执行域内等待该组
如果任务失败或取消:转入失败收尾
如果帧凭证已经失效:转入失败收尾

回收成功完成的组记录与作用域记录
返回成功

失败收尾:
请求取消已经接纳的成员
确保成员集合不再扩展
排空已接纳任务及其捕获资源
清理本批临时状态
返回失败原因

这个接口的重要承诺不是“任务可能并行”,而是:任何返回路径都不遗留仍在访问调用者临时数据的任务。创建、提交和回调抛出异常时,也必须进入同样的清理保护。

成功路径只需要回收已经完成的记录,不应为了方便统一处理,仍然对成功任务发出取消请求。取消回调可能具有可观察行为,正常结束与失败清理应该具有不同语义。

4.3 完成不应早于任务捕获资源的收口

任务函数执行完最后一行,未必意味着它已经不再触碰外部资源。闭包销毁、暂存对象释放、引用归还,也可能属于执行尾部的一部分。

因此,若上层约定“等待完成后即可释放批次输入”,完成通知就必须覆盖这些尾部操作,或者用等价的所有权协议保证它们安全。

跨线程读取结果还需要真实的同步语义。普通计数器归零或阶段编号递增,不能自动建立正确的 happens-before 关系;完成实现应通过合适的同步操作使生产者写入对消费者可见。相关原则可参见 C++ 工作草案:数据竞争与执行顺序

五、一个落地例子:并行生成呈现数据

比起让整个世界任意并行,更适合作为起点的是纯数据准备:从稳定输入中筛选对象、统计数量、填充数组,最后生成只读结果。

5.1 先建立读取窗口,再派发任务

准备开始前,协调者需要完成影响输入的世界写入,例如变换更新、最终姿态整理、相机求值和附着关系调整。

随后建立一个受控读取窗口:任务可以临时借用稳定的输入范围,但这段时间内不允许结构修改,也不允许输入对象被释放。窗口一直持续到任务全部收口。

这和“输入已经是一份独立快照”不是同一回事。受控借用依赖协调边界保持稳定;独立快照则拥有自己的数据与资源持有关系。两者都可以用于准备阶段,但不能把一包只读指针误认为已经拥有独立生命周期。

后台服务此时可以继续计算自己的结果,却不应绕过接纳边界修改活跃世界。

5.2 把并行机会放在真正无冲突的工作上

生成可变长输出时,一种常见结构是:

1
2
3
4
5
6
稳定输入
→ 并行筛选并统计各块数量
→ 计算输出偏移
→ 并行填充互不重叠的区间
→ 按稳定规则归并
→ 冻结结果

对应的伪代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
块列表 = 按稳定输入次序分块

并行执行并收口:
每个块计算自己的输出数量

协调者计算各块输出偏移
分配最终输出存储

并行执行并收口:
每个块只写自己的输出区间

协调者校验数量、区间与资源版本
按块序归并需要统一编号的数据
移交结果所有权,结束可写阶段

这样不需要多个任务同时向同一个动态数组追加,也不需要未经审计就共享一个暂存分配器或去重表。

如果还需要材质编号、姿态去重或资源索引映射,可以先由各块生成局部结果,再由协调者确定性地归并。不要按工作线程编号或完成先后来分配最终编号,否则调度抖动可能变成输出抖动。

写入区间不重叠解决的是正确性基础,并不保证性能理想。相邻区间共享缓存行、块间工作量差异过大、归并阶段过重,仍可能抵消并行收益。

5.3 相位、批次和子步不要混在一起

一个呈现准备阶段可以包含多个依赖批次:统计批次完成,才允许填充批次开始。批次只是相位内部的执行结构,不必为每个批次增加一个全局相位。

整批收口是一种保守、容易审计的实现。若以后需要更细粒度的重叠,可以让某个下游任务在自己的全部前驱完成后被唤醒,而不是等待所有无关工作。改变的是调度粒度,不能改变必要依赖的完成语义。

固定步仿真又是另一层循环。若本显示帧需要推进多个物理步,每个子步都必须完成自身的必要工作,再进入下一子步。帧级数据提取通常发生在这些子步和后续世界整理完成之后,不能因为“每个阶段都有任务组”,就把不同子步的输入和结果混在一起。

六、失败路径决定并行接口是否真正安全

6.1 取消请求不能替代排空

设一个阶段准备提交四个片段:前两个已经接纳,第三个因为队列已满而被拒绝。

此时不能直接释放输出数组并返回失败。第一个任务可能正在写数组,第二个任务可能刚被工作线程取走。甚至在零工作线程配置下,任务还可能已经由提交者协作执行过。

安全的处理顺序是:

1
2
3
4
5
6
发现局部提交失败
→ 停止继续接纳
→ 请求取消已经接纳的任务
→ 等待它们全部收口
→ 释放或重建临时输出
→ 返回错误,或执行允许的后备路径

取消只是表达“不再需要这些结果”。尚未开始的任务可以被跳过,已经执行中的任务通常只能在检查到取消条件后退出,或者完成当前计算后返回。

排空才保证:不再有这些任务继续访问借用的数据。

因此,“请求取消后立刻清空容器”和“等待超时后直接析构输入”,都不能构成安全的失败路径。若需要超时返回,必须将仍存活的任务和数据交给另一个明确的生命周期持有者,而不是假定它们已经停止。

6.2 后备执行只能重试可重复的部分

纯提取任务读取稳定输入,写入可丢弃的临时结果。全部任务排空后,可以丢弃这次输出,按相同输入串行重算。

但不能因此设计一个通用规则:“并行失败,就把整个阶段重新执行一次。”

如果阶段包含仿真推进、脚本事件、资源挂载或音频播放,部分操作可能已经发生。整体重试会把副作用执行两遍。

正确的分界是:

1
2
3
4
5
可重复的纯准备:
排空 → 丢弃临时结果 → 从头串行计算

已经发生副作用的世界推进:
不自动重放 → 按失败策略终止或恢复

后备执行还应区分原因。容量不足或某种可恢复的执行条件,可能允许纯计算重试;世界已经替换、帧已取消、运行环境正在关闭,就不应该靠串行重试“挽救”一个已经失去发布资格的结果。

另外,排空时不能持有任务结束所必需的锁。若协调者拿着世界锁等待任务,而任务正在等待同一把锁,即使有完善的取消标记,也可能无法结束。

七、等待是受控执行边界,不是任意帮忙

7.1 为什么普通帧任务不应再嵌套同步批次

假设工作池有四条线程。四个外层任务分别占住一条线程,每个任务又提交一批子任务并阻塞等待。

1
2
四条工作线程:全部在等子任务
子任务队列:需要工作线程才能继续

这就是典型的线程池饥饿风险。某些运行时支持任务挂起、继续任务或经过证明的嵌套协作,但不能因为“理论上可以支持”,就默认任何同步等待都安全。

对于一般帧准备,最容易审计的规则是:同步批次由协调者发起,普通片段任务不再调用需要等待的顶层准备入口。

把多轮依赖放回协调层即可:

1
2
3
协调者等待统计批次
协调者计算偏移
协调者等待填充批次

不必把上述整段流程再包装成一个工作线程任务。

拥有动态任务生成和专用进度协议的模块,需要单独保留其正确性机制。禁止一般帧任务嵌套等待,不等于可以删除这些模块的内部完成协议。

7.2 协作等待必须有权限和范围

等待期间,协调者可以执行部分允许的任务,减少空等,也为某些零工作线程配置提供前进能力。

但“帮助执行”不应理解为从所有队列随便取一个任务。可执行集合至少受到两类约束:

1
2
属于本次允许推进的完成边界
且 位于调用者有能力执行的执行域

具有线程或专用资源要求的录制任务,不能因为协调者正在等待,就被一个没有对应执行身份的线程顺手执行。串行通道也不能为了推进当前任务而跳过尚未完成的前驱,破坏原有顺序。

如果当前边界无法由现有工作线程或合法协作执行者推进,就应报告明确原因,或由上层先满足必要条件,而不是无限自旋,更不能偷偷扩大权限。

零工作线程也不意味着所有执行域都必须自动退回调用线程。纯 CPU 片段可以支持调用者推进;受限执行域可以明确拒绝接纳,由业务层选择另一种合法策略。

7.3 不要用“运行到全局空闲”表示帧完成

长期服务可能持续提交解码、编译或网络相关任务。等待全局空闲,会把当前帧的截止时间绑定到与它无关的工作上。

帧批次应该等待自己的显式成员。其他工作仍可由工作线程正常调度,但不应被计入本帧的完成条件。

“等待得足够少”并不是漏等,而是把依赖边界描述得足够准确。

八、任务全部完成之后,还差一次安全发布

8.1 算完了,不代表结果仍然有效

想象一个加载或准备任务开始时,世界还在运行;任务完成前,用户已经切换了场景。

旧任务可能成功算出完整结果,但这个结果已经不属于当前世界。任务成功与结果可用,是两件不同的事。

可以为准备工作携带一个轻量身份:

1
世界身份 + 世界代次 + 帧序号

协调者在开始准备、任务收口、冻结和发布前检查身份。世界替换使旧代失效,本帧取消使当前结果失去发布资格,新帧推进也可以使旧帧不再具有“当前发布者”的资格。

身份检查并不负责保活。一个弱凭证只能判断结果是否过期,不能让任务安全访问已经析构的世界。受控借用仍然需要窗口内的生命周期保证;跨窗口使用的数据则需要独立持有。

切换或关闭世界时,可以先使旧身份失效并停止相关接纳,再取消、排空仍持有借用的任务,最后销毁被借用对象。底层执行环境也必须晚于需要借助它完成清理的任务作用域退出。

8.2 不可变数据要覆盖间接引用

把顶层对象标为只读,不代表整个数据包真的不可变。

它可能仍然指向会被修改的材质参数、骨骼数组、视图缓存或临时调试几何。消费者沿指针继续读取,仍可能重新进入可变世界。

冻结结果时,应明确区分:

  • 需要复制或移交的帧级值数据;
  • 可以共享、但必须持有生命周期并校验版本的资源;
  • 仅在准备窗口内有效、发布前必须消除的临时借用。

消费者得到的应是一份封闭、可解释的输入,而不是“顶层只读、内部继续访问活跃世界”的外壳。

版本校验用于拒绝不匹配的结果,不能让并发读写同一份资源自动变安全。资源仍需采用不可变版本,或由同步协议保护其访问与替换。

8.3 先全部预检,再开始外部副作用

一份帧结果可能包含图像、音频命令、界面和调试数据。若逐份“校验一个、消费一个”,后面某份数据才发现无效时,前面的声音或设备操作可能已经发生。

更稳健的发布结构是:

1
2
3
4
5
6
7
完成最后的世界写入
→ 执行必要准备批次并全部收口
→ 归并并冻结各类结果
→ 释放准备期的临时世界借用
→ 预检所有必需结果
→ 取得本帧一次性发布资格
→ 按消费依赖派发

“任务组封闭”和“数据冻结”在这里是两种动作:前者关闭任务接纳,后者终止数据写入。不能因为任务组已经封闭,就把尚未写完的数据交出去。

所有必需结果预检完成后,不依赖绘图的音频命令可以先交给音频侧处理,无需等待 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
2
3
4
提交 → 开始执行:排队延迟
开始 → 结束:计算与执行成本
进入等待 → 批次收口:组等待时间
准备开始 → 发布:整体准备与交接延迟

还应记录局部提交失败、取消、后备执行、任务粒度和归并成本,并按世界、帧、相位与子步关联。

等待时间也不能直接等同于线程空闲时间:协作等待期间,协调者可能正在执行任务。诊断时要区分真正阻塞与协作执行。

调度遥测宜按需开启,避免在每个热路径查询中扫描全部任务状态。没有开启采样,应报告“未测量”,而不是把缺失样本显示成零耗时。

十一、把整条协同链放进一段伪代码

前面的概念可以组合成下面的显示帧协调流程。它是职责示意,不要求每一行都成为新的全局相位。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
推进一个显示帧:
确认当前线程具有协调权限,且没有重入帧推进
开始新帧,取得包含世界身份、代次与帧序号的凭证

按明确截止点接纳输入和已完成的外部结果
未完成的服务任务继续留在自己的生命周期内

按相位推进本轮仿真准备与入站交接
如果帧失效:结束本轮,不继续发布

对固定步时钟要求的每个子步:
由协调者调用本步仿真
允许内部适配器组织受控并行
等待本步必要任务、依赖通知与资源借用收口
如果失败:标记本帧失败,结束本轮

完成帧级结果整理以及所有影响准备输入的世界写入

建立受控读取窗口
按依赖顺序执行各类数据准备:
无冲突的纯计算片段 → 同步批次适配器
必须串行或具有线程要求的工作 → 协调者

如果某个纯批次失败:
先排空该批已接纳任务
仅当输入仍有效、失败可恢复且计算可重复时:
丢弃该批临时结果,串行重算纯准备部分
否则:取消本轮准备,清理后返回失败

确认全部必需准备已经收口
按稳定规则归并,冻结各类结果
释放临时世界借用,结束读取窗口

如果凭证失效或任一必需结果预检失败:
丢弃未发布结果,不启动消费者

取得本帧一次性发布资格
按消费依赖派发音频、图像、界面等结果
若中途失败:停止后续消费,不重放已应用的结果

设备提交成功后,由实际设备完成协议持有相关资源
CPU 消费结束后,释放不再被任何 CPU 消费者使用的存储

所有失败和异常出口,都必须遵守已经建立的清理契约。伪代码中的“结束本轮”不表示可以跳过排空、提前释放活跃任务的数据,也不表示回滚已经完成的仿真或设备副作用。

这条流程中,帧相位没有被任务系统取代,任务系统也没有变成第二套世界更新规则。相位提供语义顺序,协调者将阶段拆成可完成的工作,JobSystem 提供受控执行与收口,发布层则负责最后的数据交接。

十二、用边界测试证明联动成立

一次成功运行只能证明某条路径走通,不能证明提交拒绝、迟到结果和部分失败时仍然正确。

测试应围绕边界设置反例:

验证场景 需要证明的性质
零、一、多工作线程执行同一纯准备输入 结果一致、归并顺序稳定,允许的零线程路径能够前进
提交到一半被拒绝 已接纳任务全部收口,不遗留访问临时输出的执行者
某个任务或捕获对象的清理被刻意阻塞 完成通知不会早于该边界要求的资源收口
普通帧任务调用需要等待的顶层批次 明确拒绝或由经过验证的机制处理,不发生隐式嵌套阻塞
等待者没有某执行域所需能力 不越权执行,不伪造资源身份
只等待一个显式任务组 无关服务任务不会被计入该组的完成条件
准备期间取消本帧或替换世界 已接纳工作安全收尾,旧结果不发布
后一份结果预检失败 前面的消费者也尚未开始
重复请求发布,或某消费者已经执行后失败 不重放整包,不重复已发生的外部效果
一帧执行多个固定子步 子步完成边界不混淆,帧级提取读取正确结果
CPU 已完成而 GPU 仍未完成 设备使用的资源保持有效
延迟反馈来自旧世界或旧对象代次 拒绝错误归属,但保留仍合法的延迟反馈

这是一份验证设计,不是某套测试已经通过的声明。可以用同步闸门、计数器和受控故障注入精确制造时序,避免依赖“睡眠若干毫秒,任务应该已经开始”这样的概率假设。

纯准备的串行实现还可以作为参考路径,与并行实现比较字段、数量、稳定编号和资源引用。对于浮点归约或物理求解,则应先定义可重复性要求:固定输入与稳定归并有助于复现,但不自动保证任意平台、线程数下逐比特一致。

性能验收与正确性验收应分别进行。先确认边界成立,再比较相同负载下的准备时间、队列延迟、等待尾部、内存峰值和端到端延迟。工作线程利用率上升,不能单独作为联动优化成功的证据。

结语:并行不应该削弱时间契约

串行程序把许多保证藏在函数返回里。引入 JobSystem 之后,这些保证需要被重新表达出来:谁属于当前批次,何时算真正结束,失败后怎样收口,结果是否仍有效,以及哪个消费者可以从哪个边界开始使用它。

帧相位与任务系统的协同,不是把“先后执行”简单改成“同时执行”,而是把必要的先后变成明确的依赖,把可并行的部分限制在安全窗口内,再用完成与发布协议连接两侧。

相位告诉我们什么时候可以开始,完成边界告诉我们什么时候可以继续,发布边界告诉我们什么时候可以交付。

当这三个问题都能被代码和测试回答,更多线程才会成为执行能力的扩展,而不是时间语义的漏洞。

本文作者:Berg Zha

本文链接:https://junglemanpro.com/posts/496458b4/

版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 4.0 许可协议。转载请注明出处!

关于

我是谁
一个热爱底层渲染的人
与我交流
bergzha@gmail.com
ESC 关闭 | 导航 | Enter 打开
输入关键词开始搜索