游戏引擎-JobSystem 与可控并发:任务归属、等待与生命周期

文章目录

真正困难的不是让任务跑到另一个线程,而是让每一份并发都有明确的归属、完成条件和退出方式。

引言:线程池解决“谁来做”,架构还要回答“何时算完”

谈起 JobSystem,人们经常从线程池、无锁队列和工作窃取讲起。这些技术很重要,但在一个逐渐复杂的实时应用中,最先失控的往往不是队列算法,而是责任边界。

图像加载器创建两条线程,材质编译器再创建两条,物理库按 CPU 核数创建一组线程。每个模块独立看都很合理,组合起来却没有人能回答:后台一共允许多少工作同时进行?切换场景以后,谁保证旧任务不再访问旧世界?一个函数已经返回,是否意味着它捕获的资源也释放了?

更棘手的是,这些问题通常不会在正常路径上暴露。它们喜欢出现在取消、队列满、单线程退化、窗口重建和程序退出时,而这些恰好又是最难稳定复现的时刻。

所以,一套成熟的 JobSystem 不只是线程池外面包一层提交接口。它更像一份贯穿整个程序的执行契约:

业务决定做什么、结果何时有效;调度器决定在哪里执行、怎样等待与收尾;资源拥有者决定何时允许销毁和产生副作用。

理解这套架构,只需始终追问六件事:任务属于谁,在哪执行,谁能协助执行,等待哪一批任务,何时可以销毁,以及结果现在是否仍然有效。

下面从这些问题出发,逐步建立设计,再沿着真实工程中常见的演进路径,解释那些看起来不起眼、却决定系统能否稳定运行的细节。文中代码均为语言无关的伪代码,名称用于表达概念,不对应某套具体 API。

一、为什么有了线程池,还需要 JobSystem

1. 各个模块的局部最优,会变成进程的全局失控

设想一台有 16 个逻辑处理器的机器。物理模块认为“保留一个给主线程”很合理,于是申请 15 个 worker;资源模块不知道这件事,又为加载、解压和编译分别建立线程池。

这些线程不会创造更多计算能力。它们可能增加调度切换、缓存争抢和同时驻留的数据,还会使重要工作与非重要工作竞争同一批核心。

更根本的问题是:线程预算分散了。

当用户问“为什么这一帧卡了”,没人能从一个地方看到全部任务;当移动设备需要减少并发,也得逐个修改业务模块;当某个服务不再使用,它是否真的释放了线程,又取决于它自己的退出逻辑。

第一步改进,是把线程所有权从业务模块移到进程的组装入口。业务只提交任务,执行资源由统一的运行时配置。

这并不意味着所有工作都必须使用同一个池。物理计算、普通后台计算和受限制的渲染录制可以拥有不同预算。所谓统一,是预算和责任有共同入口,不是把不同性质的执行强行混在一起。

2. 每个模块都有停止标记,不代表它们有一致的取消语义

“取消任务”至少可能表示四件事:不再接受请求,不执行尚未开始的任务,请正在运行的任务尽快退出,或者只是不再接纳最终结果。

这几种行为不能互相替代。

例如,用户切换项目时,一个外部编码进程可能已经开始工作。业务可以立即宣布旧请求失效,但不能据此销毁编码任务仍在访问的输入。反过来,即使编码成功结束,也不代表结果还能写入新项目的缓存。

因此,需要同时建立两条约定:

  • 执行层保证工作怎样停止、怎样完成收尾。
  • 业务层判断请求和结果是否仍然属于当前状态。

这就是生命周期作用域与业务代次检查同时存在的原因。

3. 把任务移到 worker,不会自动得到线程安全

一个遍历可变世界、修改实体、更新音频状态的函数,并不会因为提交给线程池就突然变得安全。

调度器看见的是一个可调用对象,看不见它背后全部的共享数据关系。它不知道某个指针是否已经失效,也不知道两个函数是否会同时修改同一张表。

因此,并行化的起点不是“哪些函数能提交”,而是“哪些数据可以独立读取和写入”。

一个经常有效的拆分是:

1
2
3
4
协调线程选取并稳定输入
→ 并行执行无副作用的计算
→ 等待全部计算收口
→ 协调线程检查结果有效性并提交变化

线程池提供算力,JobSystem 提供执行与收尾约定,数据边界决定并行是否合法。三者缺一不可。

二、全局架构:一个运行时,不等于一组线程

1. 先把所有权画清楚

下面的箭头表示所有权或调用关系,不表示整张图都在并发运行。

1
2
3
4
5
6
7
8
9
10
11
12
flowchart TD
Root["进程组装入口"] --> Runtime["拥有任务运行时"]
Root --> Services["业务服务:借用运行时,拥有任务作用域"]
Services --> Adapters["窄适配:同步批次 / 串行通道 / 第三方任务桥接"]
Adapters --> Runtime
Runtime --> General["通用计算池"]
Runtime --> Physics["物理计算池"]
Runtime --> Recording["受控录制池"]
General --> Serial["串行通道:不额外创建线程"]
Owner["已有 owner 线程:注册执行身份与能力"] -.-> Runtime
Services --> Commit["协调线程:验证并提交结果"]
Commit --> Graphics["图形后端:GPU 提交与资源保活"]

进程入口创建并持有任务运行时,业务服务借用它。服务必须先结束,运行时才能销毁。

这里把负责组织更新和提交结果的线程称为协调线程,把某类资源的指定操作线程称为 owner,把运行时创建的工作线程称为 worker。协调线程和 owner 可以是同一线程,也可以不同。

这与全局单例有本质区别:统一所有权,不等于允许任何模块随时从全局取到调度器。显式注入能让依赖关系、测试配置和销毁顺序都保持可见。

调度内核也不应该反向理解业务。它可以认识任务、队列、作用域和执行身份,但不必认识世界、材质、实体或物理库内部的依赖图。这些知识属于调用方和适配层。

2. 执行域表达规则,不直接等于线程数量

执行形态 要解决的问题 重要限制
通用计算池 解码、解压、采样、纯数据准备 不应被无期限等待的长期工作占满
独立计算池 为物理等工作保留可调预算 预算隔离不必等同于禁止所有跨域协作
串行通道 保证相关任务按序、互斥执行 不保证每次都由同一个线程执行
受控录制池 执行需要特定资源资格的录制任务 普通等待者不能随便协助执行
线程绑定身份 描述已有 owner 线程及其能力 注册身份不意味着一定要提供通用 owner 队列

其中,串行通道最容易被误解。

假设两个任务都操作同一个编译器状态,它们需要依次执行,但未必需要同一条 OS 线程。此时,一条由共享 worker 消费的串行通道就足够了,不需要为这个服务专门开线程。

反过来,如果一个操作必须在图形设备的 owner 线程上执行,仅仅放入串行通道并不安全。上一次执行者可能是 worker A,下一次可能是 worker B。

串行回答“能否同时执行”,线程亲和回答“允许谁执行”。它们是两个维度。

3. 线程预算要算总账,能力要单独计算

运行时拥有的 worker 总数,可以由各计算池的配置相加得到。但进程总线程数还包括协调线程、平台 owner、网络 I/O 和第三方内部线程。

协作等待又会带来另一个差别:某个时刻参与计算的人数,可能包含没有额外创建的调用线程。因此,线程池大小、正在工作的线程数和业务实际并行度,不是同一个统计量。

对于受控录制,甚至还要区分“配置了 worker”与“当前工作允许并行”。一个计算池存在,不代表所有任务都具备进入它的条件。

把这些数字混在一起,最容易得出“线程减少了,所以性能一定更好”或“开了四个 worker,所以快四倍”的错误结论。

三、六个边界分开,才能真正掌控全局

要回答的问题 使用的概念 不应误认为
工作归谁负责,何时必须结束? 生命周期作用域 scope 优先级,或自动建立的业务父子任务树
工作怎样排队和执行? 执行域、串行通道 lane 特定线程的资源身份
这个同步点确切等待哪些任务? 显式任务组 group 作用域中所有历史任务的前缀
当前执行者有何身份和资格? 执行槽位 slot、协作能力集合 池内 worker 编号、CPU 核号
结果还属于当前业务状态吗? 请求标识、业务代次、帧标识 任务成功状态
GPU 是否已经用完资源? GPU 完成信号与资源保活 CPU 任务组完成

1. Scope 管归属,Group 管这一次完成

一个长期资源服务可以拥有一个 scope,在其中不断处理请求。某个调用方却可能只想等待自己刚提交的三个准备任务,不愿意顺带等待其他无关请求。

所以,scope 与 group 不应合成同一个概念。

Scope 的含义是:“这些工作最终由谁负责收尾。”

Group 的含义是:“这次完成事件包含哪些明确成员。”

作用域可以附带“帧”“世界”“服务”等诊断标签,但标签本身不应被当成魔法。除非契约明确实现,否则标记为帧作用域,并不会自动得到跨帧队列、父子级联取消或 GPU 资源回收。

2. Group 为什么必须有一个封口动作

假设准备提交 A、B、C。A 非常快,在 B 提交之前就已经完成,此时剩余任务数为零。

能宣布整个 group 完成吗?不能,因为生产者还没有交完任务。

因此,任务组需要两个维度:还有多少成员未结算,以及是否还允许新增成员。

1
2
3
4
开放:允许提交;即使 remaining = 0,也不能宣布终态
└─ seal:生产者声明成员集合已经完整
└─ 封口:拒绝新成员
└─ 所有成员结算后,发布成功/取消/失败

对应的关键条件很简单:

1
2
3
function try_publish_completion(group):
if group.sealed and group.remaining == 0:
publish(group.failure_or_cancellation_or_success)

有些生命周期错误,正是来自把“暂时没有任务”误当成“以后不会再有任务”。封口把这个隐含假设变成了显式协议。

显式成员集合还避免了不必要的等待。假如一个小批次用“等到第 100 个任务完成”来表达,前面无关的慢任务也可能成为它的隐式前驱。只等待本组成员,才能准确表达调用方的真实依赖。

不过,group 独立不意味着可以违反串行通道的 FIFO。若本组任务排在同一通道的某个前驱之后,它依然要遵守通道顺序。

3. 执行身份,不能只靠一个 worker 编号

两个线程池都可能有 worker 0。已经存在的 owner 线程又不属于任何池。如果用这个编号索引线程私有的录制资源,不同执行者就可能访问同一份状态。

更可靠的做法是使用运行时内统一的执行槽位,并附带代次:

1
execution_slot = { index, generation }

索引用来定位槽位,代次拒绝已经注销、随后重新使用的位置所留下的旧身份。它不是物理 CPU 核号,也不应脱离所属运行时被当成全局通用标识。

身份与权限还必须分开。一个线程即使有 slot,也未必有资格执行录制;一次 wait 即使希望协助录制,也不能据此自动获得资源访问权。

可以把可帮助执行的工作理解为几个集合的交集:

1
2
3
4
可协作任务 = 当前等待的成员
∩ 本次等待允许的执行域
∩ 当前执行者实际具备的能力
∩ 满足顺序和资源约束的就绪任务

这不是装饰性的权限系统,而是保护资源所有权的必要条件。

四、跟着一个任务批次,走完提交、完成和失败

现在用“计算一批对象的展示姿态”把前面的概念串起来。

1. 先建立一个不会逃逸的同步窗口

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function prepare_poses(stable_inputs):
chunks = partition(stable_inputs)
outputs = allocate_one_output_per_chunk(chunks.count)

outcome = run_synchronous_batch(chunks.count, index =>
outputs[index] = pure_sample(chunks[index])
)

if outcome is CANCELLED:
return CANCELLED

if outcome is FAILED:
# 批次已排空,没有 worker 继续写 outputs。
# 只有纯计算才允许按业务策略串行重算。
if not policy_allows_pure_recomputation():
return FAILED
outputs = recompute_serially(stable_inputs)

if not business_generation_is_still_current():
return STALE_RESULT
commit_outputs_on_coordinator(outputs)

这里有几个不显眼、却不可缺少的前提:输入在同步窗口内保持稳定;输出提前分配;任务写互不重叠的区间;worker 不修改世界;所有已接纳任务结束以后,调用方才能退出这个窗口。

最后,协调线程仍要检查业务代次。任务完成保证“可以读取计算结果”,不保证“当前业务还需要这个结果”。

2. 窄适配器负责重复而容易出错的清理

多数帧内消费者不需要知道执行槽位、组句柄或协作权限。它们只需要表达“第 i 块做什么,并且返回前全部结束”。

因此,可以提供一个同步批次适配器,内部使用现有调度机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function run_synchronous_batch(count, indexed_task):
require_coordinator_caller()

create owning scope
create owning group inside scope

for each index:
try to admit indexed_task(index) into group
if admission fails:
cancel and drain all admitted members
close remaining ownership boundaries
return admission error

seal group
outcome = wait for this group

if outcome is successful:
retire group
retire scope
else:
cancel and drain remaining work
close remaining ownership boundaries

return outcome

这段伪代码强调的是契约:所有失败出口都要处理已接纳任务;实际实现还需用自动清理保护覆盖异常出口。

适配器不是第二套调度器,不应重新创建队列或线程池。它只是用更窄的接口,替调用方封装正确的生命周期顺序。

同步窗口也使借用 callable 成为可能:整个批次共享一个按索引调用的函数对象,不必为每块复制一整套捕获。但这些并发调用仍然访问同一个函数对象,所以捕获状态是否安全,必须由调用方保证。

3. 提交首先是准入,不是执行承诺

一次可靠的提交,至少包含以下步骤:

1
2
3
4
5
6
7
8
9
10
加锁
校验 runtime、scope、group 身份
检查停止状态、封口状态与执行资格
检查目标队列容量
登记任务,增加 scope/group 未完成计数
尝试发布到队列
发布失败则回滚登记和计数
解锁

通知执行者,或按契约推进刚提交的任务

登记与发布必须形成完整的准入过程。否则,内存分配等中途失败可能留下一个“未完成计数存在,但队列里永远找不到”的幽灵任务。

被接纳的任务应有可观察的成功、取消和失败状态。但“至多执行一次”不等于“保证执行一次”:尚未开始的任务可以被取消而完全不调用用户函数。

这个区别会直接影响业务 promise。若只有用户回调会兑现 promise,一次排队取消就可能让业务永远等不到完成。

4. 零 worker 会暴露一个隐藏假设

为了支持确定性执行、受限设备或测试,通用 CPU 域可以允许零 worker,由调用线程推进。

此时,submit 可能在返回前就执行刚提交的任务。对于需要特殊资源的执行域,则可以选择拒绝零 worker 提交,而不是继承这种内联行为。

因此,不要这样安排初始化:

1
2
提交一个会读取 service.state 的任务
然后才初始化 service.state

也不要持有任务需要的业务锁进行可能内联执行的提交。异步任务接口不一定承诺“回调绝不会在调用栈内发生”。如果允许同步推进,就必须把重入写进契约和测试。

5. 部分失败比完全失败更危险

一个批次有四个任务,A、B 已经提交,C 因队列满被拒绝。

如果立即返回,A、B 可能仍在写输出,而调用方已经销毁输入、释放栈帧,或者在同一缓冲区开始串行重算。

于是,一个本来可处理的容量问题,就升级成悬空引用或并发写。

同步接口必须保证:无论成功还是失败,返回时都没有已接纳的回调继续借用调用方的状态。

这也是为什么 fallback 不能简单写成“提交失败就串行再跑一次”。必须先 drain;而且只允许重做纯计算,不能默认重放模拟更新、实体创建或播放事件。

五、等待不只是暂停:串行嵌套死锁的启示

1. 单个 worker 为什么需要协作等待

只有一个 worker 时,任务 A 提交 B,然后停住等待 B。如果调度器只是让 A 睡眠,就再没有执行者能运行 B。

协作等待允许 A 在满足约束时帮助执行相关任务,让系统继续前进。它不仅是减少空转的优化,也是零 worker 和嵌套依赖能够工作的基础。

但这种能力不是通用死锁检测器。任意业务锁、跨系统循环依赖和外部阻塞,都不能指望“等待时帮忙执行”自动解决。

2. 一个所有 worker 都救不了的死锁

设串行通道 L 使用一个布尔值表示是否正在运行:

1
2
3
4
5
6
7
8
9
A 开始执行:L.running = true
A 向 L 提交 B
A 等待 B

等待者发现 L.running,拒绝取出 B
其他 worker 也发现 L.running,拒绝取出 B

A 不返回,L.running 就不会清除
B 不运行,A 就不会返回

这个问题与 worker 数量无关。加十条线程也没有帮助,因为所有执行者都被同一条规则挡住了。

错误在于把两件不同的事混为一谈:禁止另一个线程并发进入,以及禁止当前线程在等待时重入自己的通道。

3. 用“所属执行栈 + 深度”代替一个布尔值

一种可行的修复是记录当前线程的任务执行栈,并为串行通道维护执行深度。

1
2
3
4
5
6
7
8
function may_enter_serial_lane(lane, caller):
if lane.depth == 0:
return true
return caller.execution_stack already owns lane

进入通道:depth += 1
退出通道:depth -= 1
最外层退出以后,才重新允许其他线程进入

同线程的合法嵌套可以推进,其他线程仍然互斥。队列取出仍遵守 FIFO,不能为了完成自己的 group 而跳过一个无关前驱。

这里的“串行”指不同线程不会同时执行该通道,不意味着父任务必须彻底返回后子任务才能开始。若业务本身禁止重入,上层就应该拒绝这种等待方式,而不是仅凭调度器支持便开放它。

任务也不能 drain 正在拥有自己的作用域或任务组,否则收尾操作会等待自己的生命结束。普通嵌套等待、组内自等待和所有权销毁,必须分别制定规则。

4. 没有数据竞争,也可能发生错误的串行化

另一个常见问题来自复制粘贴:两个无关服务使用了同一个 lane 数字常量。

结果不是崩溃,而是长时间编译挡住了另一个服务的短任务。数据完全安全,延迟却莫名其妙地变坏了。

这说明,串行身份也必须有所有者。应由运行时分配通道 ID,而不是让各模块自行协调数字命名空间。

通道 ID 可以在运行时生命周期内保持不复用,空闲队列存储则单独回收。业务服务通常创建一次通道,用自己的 scope 负责取消和排空。销毁一个非拥有型通道封装,不应暗中猜测是否还存在其他 scope 的任务,并替它们执行等待。

共享计算资源,不意味着共享业务顺序。只有确实需要排序的工作,才应该进入同一条通道。

六、生命周期:取消、完成与销毁,不能只靠一个 finished

1. 四个操作,四份不同承诺

操作 应当承诺什么 不应被误解成
等待完成 等待目标边界,观察失败或取消;必要时协作执行 自动释放拥有型句柄,或禁止开放 scope 接收其他工作
请求停止 关闭后续准入,通知运行中的任务协作退出 立即终止线程,或者所有任务已经退出
取消并排空 取消待执行工作,等待相关工作结算,然后关闭所有权边界 可以在持有任意业务锁时安全调用
成功回收 在已经成功完成时释放边界,不请求停止,不执行任务 未完成、失败或取消状态下的通用清理捷径

为什么要把成功回收单独拿出来?

因为一个已经完成的帧内批次,没有必要再走一遍“请求停止—取消—等待”的流程。它只需要释放组和作用域的记录。但成功回收必须严格检查前置条件,不能用性能优化的名义跳过未完成任务。

这个路径可以不等待任务,却仍然需要获取调度状态锁。“不等待任务”不等于“无锁”。父作用域回收前,也应先处理仍拥有生命周期的子 group。

还有一个结果层次必须分开:取消排空操作成功,表示清理完成,不表示被取消的业务任务执行成功。反过来,清理报告了某个任务错误,也不一定意味着清理本身没有完成。接口应说明句柄是否失效、边界是否已关闭,而不是让调用方用一个布尔值猜测一切。

2. 自动析构可以兜底,但不能替代退出协议

作用域如果只是一组整数,调用方丢弃它就可能忘记通知运行时。将它设计成拥有型对象,可以让析构自动执行必要的取消和排空,减少遗漏清理。

代价是:析构可能等待。

下面这个环不会被自动资源管理消除:

1
2
3
主线程持有 ServiceLock,开始销毁 scope
→ scope 等待任务退出
→ 任务退出前需要取得 ServiceLock

正确顺序应该是:

1
2
3
4
5
6
7
8
9
function shutdown_service():
lock service_state:
reject_new_business_requests()
mark_service_stopping()
unlock

request_stop(scope)
cancel_and_drain(scope) # 不持有任务可能需要的业务锁
destroy_task_dependencies()

进程级退出也是同一条所有权链:先停止任务生产者和更新循环,再停止并排空工作,然后销毁业务服务和适配对象,注销不再使用的线程身份,最后销毁任务运行时。

运行时不能先消失,让后续 scope 析构通过一个悬空引用回来请求清理。自动析构解决的是遗漏,不是颠倒所有权。

3. 函数返回了,任务真的完成了吗

假设一个 lambda 捕获了资源租约。函数体返回后,租约要在捕获对象析构时才归还。

如果执行顺序是:

1
执行函数 → 发布完成 → 唤醒等待者 → 析构函数捕获

等待者可能在第三步就销毁租约的所属服务,第四步却还在访问它。

因此,对正常执行路径,可靠的完成顺序应当是:

1
执行函数 → 销毁任务捕获 → 发布完成 → 唤醒等待者

捕获析构还应保持在任务执行身份的保护范围内,防止析构回调借机绕过“不能 drain 自己”的限制。

验证这个边界,可以做一个很有针对性的测试:让捕获对象的析构停在一个可控屏障上;此时确认 group 尚未完成、成功回收被拒绝;释放屏障以后,才允许观察完成。

这比简单地检查“任务函数被调用了”深入了一层:完成事件是一份资源生命周期承诺,不只是计数减一。

当然,若回调又发起了不受 scope 管理的异步工作,那部分工作需要自己的完成协议,不能被原任务的完成事件顺带包办。

4. 长期服务不能把全部历史都放在活跃表里

一个后台服务运行一天,可能处理数十万次请求。如果已完成任务永远留在活动表,等待、取消检查和诊断就可能越来越慢。

一种实用分离是:

1
2
3
活动表:只保留排队中、执行中的任务
历史表:将相邻且同状态的任务编号压缩成区间
摘要:保留需要传播的失败和取消信息

例如,连续一万次成功,不必保留一万个完整任务记录;一个完成区间就能支持旧句柄查询。等待热路径只关心仍可能影响前进的活动任务。

但区间压缩不是严格恒定内存的证明:状态若持续交错,区间数仍会增加。scope 关闭、历史保留策略和业务请求预算仍然重要。

同样,有界队列也不代表整个系统内存天然有界。每条串行通道都可能有自己的队列,执行中的任务还持有输入,外部编码可能产生大块输出。执行层的容量控制必须与业务层的并发数、数据尺寸和保留预算配合。

七、典型消费者:把统一契约接到不同系统

1. 资源加载:执行完成与业务有效要分别判断

资源服务适合使用这样的结构:服务持有 scope,请求带独立身份,任务持有受保护的输入,完成结果回到业务层检查代次。

1
2
3
4
request = { source, project_generation, request_id }

后台:计算 request 对应的结果
前台:只有 request 仍属于当前项目,才接纳结果

业务代次检查不能代替资源保活。发现旧代结果不应使用,并不意味着可以先释放后台任务还在访问的对象。

为了避免直接等待,有时会把整个旧服务扔进一个“以后再销毁”的列表。短期看退出变快了,长期却可能保留整套线程、缓存和队列。更稳妥的方式,是明确停止准入、取消并排空,再销毁服务;若确实需要延迟回收,应保留最小必要状态,并设置容量。

外部进程又是另一类问题。停止令牌无法迫使一个卡住的编码进程退出,所以还需要超时、进程终止和输出清理策略。把阻塞调用放到 Job 里,不会使它变成原生异步 I/O。

2. 物理适配:迁移线程所有权,不能丢掉动态依赖

第三方物理库的任务通常不是一份固定列表。任务执行中可以生成新任务,依赖归零后才进入就绪状态,barrier 也可能在等待时增加成员。

这时不应强行把它压平为一个封口批次。更合适的分层是:第三方库管理动态依赖,适配器把已经就绪的任务提交到指定计算域,作用域约束这次更新的生命周期。

1
2
3
4
5
第三方依赖计数归零
→ 适配器确认尚未提交
→ 持有执行所需引用
→ 向统一运行时提交就绪任务
→ 执行并释放引用,通知第三方完成

困难主要集中在取消和拒绝。

调度器可能取消一个排队任务,完全不调用它的函数。但第三方库仍然需要释放引用、结算依赖和唤醒 barrier。如果只有用户函数能做这些事,整个物理步骤就会卡住。

适配器必须把“执行物理用户代码”与“完成第三方内部收尾”分离:停止以后跳过前者,仍然完成必要的后者。捕获对象直接被销毁时,也必须释放为排队持有的引用。

barrier 的完成同样不能只看任务的 done 位。有些实现先把任务标记为 done,再调用完成通知;等待者若据 done 位立即销毁 barrier,通知回调就会访问已释放对象。

正确边界应包含通知交付及其最后一次成员访问。等待器还要覆盖“取消没有执行回调,因此没有常规通知”的路径,不能无条件指望原来的条件变量唤醒。

适配任务系统,最难的不是转换函数签名,而是让双方对取消、完成和引用释放的理解一致。

3. 渲染:先冻结输入,再讨论并行录制

直接让 worker 遍历正在变化的世界,同时操作共享图形状态,是一条危险的捷径。任务调度即使完全正确,数据访问也已经越界了。

更可靠的主链是:

1
2
3
4
5
6
flowchart LR
Update["协调线程:完成可变更新"] --> Prepare["纯准备:独立输出并收口"]
Prepare --> Freeze["冻结输入并统一预检"]
Freeze --> Consume["按约定顺序消费"]
Consume --> Submit["录制与 GPU 提交"]
Submit --> Done["GPU 完成后释放对应资源"]

所谓冻结,不只是把顶层指针改成只读。相机、实例、材质参数及其二级引用都需要稳定的生命周期;否则只读外壳仍然可能指向正在变化或已经销毁的数据。

准备与预检全部成功后,才开始新的消费副作用。不过,这不等于数据库事务:消费中途失败时,已播放的音频、已发生的外部效果未必能撤销,也不能简单重新执行整帧。

受控并行录制通常还需要三道约束:

  • 执行者具有合法 slot 和录制能力,才能取得线程私有录制资源。
  • 录制结果带发布代次(epoch),旧批次的迟到结果不能进入下一批。
  • 可以乱序录完,但必须按稳定的业务顺序合并和消费。

录制失败后的回退,要与副作用边界配合。在 primary 尚未消费这批结果之前,可以排空并丢弃整批,再由 owner 重录;进入了部分消费之后,就不能无条件从头重放。

4. CPU 完成,不等于 GPU 已经用完

并行录制任务结束,只意味着 CPU 已经生成命令。GPU 可能还没有开始执行。

因此,下面这条推理是错误的:

1
任务组完成 → 这一帧所有资源都可以释放

正确模型需要两段生命周期:

1
2
CPU 任务完成:允许回收不再使用的 CPU 准备数据
GPU 实际完成:允许释放命令仍然引用的 GPU 资源

GPU 完成信号、提交身份和资源保活由图形后端管理,不应由 CPU scope 的 drain 冒充。

同一帧的索引还可能被循环复用,因此异步反馈最好携带世界代次、原始帧身份和提交完成凭据。读取到某个 GPU 结果,不代表它属于当前仍然有效的帧。

5. 网络:长期 I/O 不应占住普通计算 worker

网络任务很适合检验一个抽象是否过度统一。

远端姿态插值属于短时纯计算,可以复用同步批次:协调线程选取有效输入,worker 分块采样,完成后再检查连接或对象代次并修改世界。

长期收发则不同。它需要持续等待事件、处理连接和定时器,不应长期占住普通计算池的 worker。一个独立的 I/O owner 配合事件驱动后端,通过有界消息队列把业务结果交回逻辑线程,通常更符合这种运行机制。

这两种执行方式完全可以共存。统一 JobSystem 的目标,不是取消所有专属线程,而是不再让每个短时计算服务各自养一套线程池。

八、一套稳定的 JobSystem,通常怎样逐步完善

把所有机制一次性设计出来并不现实。更稳妥的路线,是先统一可验证的契约,再迁移真实消费者,随后利用审计和性能证据收缩设计。

第一步:清点线程和生命周期,建立基线

先找出线程在哪里创建、数量由谁决定、谁负责停止,以及服务销毁时还可能剩下什么任务。

不要只数源码中的线程构造。还要看第三方内部线程、外部编码进程、切换场景后的残留和实际队列积压。把既有测试失败单独记录,避免以后把旧问题误认成迁移回归,或反过来用旧问题掩盖新回归。

这一步的产物不是复杂框架,而是一张明确的所有权清单。

第二步:先冻结最小契约,暂缓高级调度策略

先定义任务身份、作用域、错误、取消、容量和等待语义。尤其要明确:提交失败有没有句柄,排队取消会不会调用函数,零 worker 怎么推进,谁负责最终 drain。

这时不必立即引入优先级、配额、工作窃取或通用 DAG。先有一套可编译、可测试的契约,后续迁移才能对齐同一个语义基础。

串行参考执行形态在这个阶段很有用,但它还不能替代真实并发实现的验证。

第三步:沿所有权边界迁移消费者,再完成组装

可以把工作划成两类:一类负责运行时内核和普通资源服务,另一类负责第三方适配与编辑器类长生命周期服务。双方共享冻结契约,组装入口由明确的责任方统一接线。

每迁移一个组件,不只是把旧提交函数替换掉,还要迁移任务输入所有权、业务 promise、错误转换、代次检查和析构排空。

迁移期允许临时接缝,但验收时必须确认旧私有池是否真的删除。否则所谓统一,可能只是给两套系统起了一个共同名字。

第四步:先审计生产正确性,再继续扩张

真实并发接入以后,重点检查那些正常业务还没走到的边界:同通道嵌套等待、零 worker、满队列、部分提交、任务中取消和反复销毁重建。

尤其要检查测试工厂真正创建了哪种后端。测试名称叫“契约测试”,不代表它已经测试过生产实现。只在串行参考形态上通过,可能把生产后端独有的死锁完全遗漏掉。

如果参考后端与生产后端大量复制生命周期代码,后续很容易修一边、漏一边。一个更克制的选择,是共享同一内核,把参考形态变成零 worker 配置,再用独立的断言和多配置测试验证契约。

共享内核能减少语义分叉,但也意味着参考形态不是独立正确性证明。并发压力和故障注入仍然必要。

第五步:用复杂消费者逼出真正需要的边界

物理适配会暴露动态依赖与取消收尾;渲染录制会暴露执行身份、精确完成集合与 GPU 生命周期;长期资源服务会暴露历史记录和旧代结果保留。

这些压力比凭空设想十几种 executor 更有价值。因为每增加一个概念,都能回答它究竟保护了什么真实约束。

第六步:先建立不可变交接,再扩大帧内并行

将可变更新、数据准备、冻结与消费拆开;只让有独立读写边界的任务进入 worker;部分提交失败先收口,再决定是否重算。

完成帧内并行,不等于已经获得跨帧重叠。后者还需要队列深度、延迟、帧身份和资源保留策略,不应在一次“增加并行”改造中顺带发生。

第七步:依据真实消费者收缩公开接口

当生产使用形态已经清楚,就可以把常见场景封装为同步批次和串行通道等窄入口,删除没有真实消费者的通用能力。

例如,若通用 owner 队列最终只有图形预热一个用户,可以把它放回图形模块,仅在调度器保留仍需要的线程身份。若外部完成桥没有独立需求,也不必让每个消费者承担这套概念。

“未来也许能用”不足以证明一个接口应该长期存在。反过来,删除抽象前也要核对测试替身和第三方适配,不能因为主路径只看到一个实现,就断言所有可替换接口都没有价值。

第八步:去掉确定的热路径成本,并补上跨平台证据

最后再处理高频路径:配置查询不扫描诊断表,队列数量增量维护,遥测可真正关闭,空闲通道回收存储,批次减少 callable 复制,成功完成使用更窄的回收路径。

这些改动仍要接受生命周期检验。比如“更快的成功回收”,就会逼着系统把捕获析构也纳入完成边界。

跨平台标准库能力不一致时,应适配 callable、停止令牌等必要原语,并尽量保持语义一致,不要为了编译通过而偷偷改变任务必须可复制、取消何时可见等契约。

最后,用新的真实消费者验证复用价值:它能否在不修改调度内核的前提下接入,同时保留自身的业务边界?这比 API 数量减少了多少更有说服力。

九、性能与测试:如何证明它值得信任

1. 并行更慢,并不意味着实现没有价值

可以用一个粗略模型理解并行成本:

1
2
3
4
并行耗时 ≈ 可并行计算量 / 有效并行度
+ 分块与提交成本
+ 等待与归并成本
+ 数据访问额外成本

这是解释模型,不是精确的性能预测公式。当每块计算很轻,新增成本可能超过节省的计算时间。

一组轻量数据准备微基准就呈现过这种情况:为 4096 个简单渲染实例准备 CPU 数据,每个实例只有一个子网格;使用 4 个通用计算 worker,预热 5 次、采样 25 次,优化构建下的数据为:

路径 中位数 P95
串行 0.6456 ms 0.8534 ms
并行 0.9493 ms 1.4745 ms

这是局部历史实验,不是完整帧的性能结果;它只能说明该负载没有获得并行收益,不能推广到所有对象复杂度、硬件或任务规模。

合理的处理是保留正确的并行能力,同时对该轻量路径维持默认串行。其他消费者根据任务规模和成本设置自己的门槛,再用同一计算函数建立串并行对照。

JobSystem 的价值之一,是让系统拥有可控的选择,而不是要求所有可并行代码都默认并行。

2. 优化先回答“省掉了什么操作”

几类收益通常可以从源码直接确认:

  • 读取不变 worker 配置,改用常量时间查询,而不是锁住调度器扫描所有 scope。
  • 队列数量和高水位在入队、出队、取消时增量维护,而不是每次提交遍历全部队列。
  • 昂贵的逐任务计时和位置采样按需编译或开启;关闭时不应只是“不读取统计结果”。
  • 批次借用一个 indexed callable,避免逐个复制拥有型捕获。
  • 释放空闲串行队列存储,避免服务反复创建后残留无用容器。

这里仍需克制。减少 callable 复制,不意味着整个提交过程零分配;关闭遥测,不意味着任务执行没有任何开销;减少全表扫描,也不意味着系统已经变成无锁调度器。

配置、结构诊断与性能遥测最好分开。前者服务于日常决策,第二类用于检查正确性与残留,第三类用于性能分析。不要让一个只想知道 worker 数的调用者付出完整诊断成本。

3. 测试矩阵应覆盖执行形态

至少要让同一套行为测试运行在零 worker、单 worker 和多个 worker 下。零 worker 暴露同步重入和无人推进,单 worker 暴露嵌套依赖,多 worker 暴露真正的竞争与身份别名。

还应系统覆盖以下组合:

测试维度 主要检验什么
正常、取消、异常 任务状态、错误传播与业务完成是否闭合
有容量、队列满、部分接纳 是否留下未排空任务和悬空借用
开放组、封口组、已完成组 成员集合和完成发布是否准确
独立 group、同通道前驱、嵌套等待 隔离、FIFO 与前进能力是否同时成立
未注册、合法 slot、过期 slot 能力判断是否越权或发生身份别名
析构中、反复创建、旧代结果迟到 生命周期与代次检查是否可靠
遥测开关、不同构建与工具链 测试是否覆盖实际发布形态

并发测试尽量用可控屏障组织交错,不要依赖“睡几毫秒,大概另一个线程就执行完了”。死锁用例需要超时兜底,资源用例需要确认最后一次回调和最后一个引用已经结束。

4. 测试代码本身,也可能制造假象

一种典型失误,是把必要初始化写进断言表达式。优化构建删除断言后,初始化也一起消失,测试于是崩溃。修复测试检查策略时,还要留意第三方头文件的条件编译是否改变 ABI,不能让测试与被测库处于不一致的语义环境。

另一个陷阱是退出诊断。线程本地容器的辅助分配可能晚于某些泄漏报告阶段才析构,产生与真正长期泄漏不同的现象。处理这类问题时,应追踪实际分配与析构时序,而不是删除告警或把所有残留都归为同一种泄漏。

如果用固定容量线程本地栈消除相关分配,也必须明示嵌套上限和超限行为,不能把内存问题换成越界问题。

最后,必须区分不同证据:单元测试通过、主机侧平台代码可编译、目标工具链链接成功、真实设备运行、长期压力与帧级性能,分别回答不同问题。任何一种都不能替其余几种签字。

十、接入新任务之前,先问这八个问题

  1. 它是短时可分割的 CPU 工作,还是长期阻塞、事件驱动的运行机制?
  2. 输入和输出由谁持有?作用域早退、业务销毁、世界换代时,所有借用是否仍然安全?
  3. 需要的是一般并发、FIFO,还是固定线程亲和性?
  4. 等待的是一个明确批次,还是整个长期服务?能否使用窄适配器?
  5. 提交被拒绝、回调抛异常、排队任务根本没执行时,业务请求由谁完成?
  6. 能否重算?若能,是否先排空并行工作;若不能,副作用的恢复边界在哪里?
  7. CPU 完成以后,还有哪些业务代次、世界提交或 GPU 生命周期需要处理?
  8. 零/单/多 worker、满队列和销毁中任务,有没有可重复测试与同负载性能对照?

能够清楚回答这些问题,比继续增加一个 executor、一种 fence 或一层泛化接口,更接近架构上的成熟。

结语:成熟的并发,是每一条边界都有负责人

从局部线程池到稳定 JobSystem,最重要的变化不一定是调度算法,而是把原来混在一起的事情拆开:预算与业务,顺序与亲和,作用域与任务组,计算成功与结果有效,CPU 完成与 GPU 完成。

这些边界建立以后,资源服务可以迁移,第三方物理可以接入,渲染可以开放受控并行,网络纯计算可以复用已有能力,而不必每增加一个消费者,就重新讨论整套退出协议。

真正值得追求的,不是让更多代码同时运行,而是让并发不再是一种难以解释的偶然状态。

好的 JobSystem,使并行成为可以选择、可以验证、可以安全退出的能力。

本文作者:Berg Zha

本文链接:https://junglemanpro.com/posts/29be1544/

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

关于

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