真正困难的不是让任务跑到另一个线程,而是让每一份并发都有明确的归属、完成条件和退出方式。
引言:线程池解决“谁来做”,架构还要回答“何时算完”
谈起 JobSystem,人们经常从线程池、无锁队列和工作窃取讲起。这些技术很重要,但在一个逐渐复杂的实时应用中,最先失控的往往不是队列算法,而是责任边界。
图像加载器创建两条线程,材质编译器再创建两条,物理库按 CPU 核数创建一组线程。每个模块独立看都很合理,组合起来却没有人能回答:后台一共允许多少工作同时进行?切换场景以后,谁保证旧任务不再访问旧世界?一个函数已经返回,是否意味着它捕获的资源也释放了?
更棘手的是,这些问题通常不会在正常路径上暴露。它们喜欢出现在取消、队列满、单线程退化、窗口重建和程序退出时,而这些恰好又是最难稳定复现的时刻。
所以,一套成熟的 JobSystem 不只是线程池外面包一层提交接口。它更像一份贯穿整个程序的执行契约:
业务决定做什么、结果何时有效;调度器决定在哪里执行、怎样等待与收尾;资源拥有者决定何时允许销毁和产生副作用。
理解这套架构,只需始终追问六件事:任务属于谁,在哪执行,谁能协助执行,等待哪一批任务,何时可以销毁,以及结果现在是否仍然有效。
下面从这些问题出发,逐步建立设计,再沿着真实工程中常见的演进路径,解释那些看起来不起眼、却决定系统能否稳定运行的细节。文中代码均为语言无关的伪代码,名称用于表达概念,不对应某套具体 API。
一、为什么有了线程池,还需要 JobSystem
1. 各个模块的局部最优,会变成进程的全局失控
设想一台有 16 个逻辑处理器的机器。物理模块认为“保留一个给主线程”很合理,于是申请 15 个 worker;资源模块不知道这件事,又为加载、解压和编译分别建立线程池。
这些线程不会创造更多计算能力。它们可能增加调度切换、缓存争抢和同时驻留的数据,还会使重要工作与非重要工作竞争同一批核心。
更根本的问题是:线程预算分散了。
当用户问“为什么这一帧卡了”,没人能从一个地方看到全部任务;当移动设备需要减少并发,也得逐个修改业务模块;当某个服务不再使用,它是否真的释放了线程,又取决于它自己的退出逻辑。
第一步改进,是把线程所有权从业务模块移到进程的组装入口。业务只提交任务,执行资源由统一的运行时配置。
这并不意味着所有工作都必须使用同一个池。物理计算、普通后台计算和受限制的渲染录制可以拥有不同预算。所谓统一,是预算和责任有共同入口,不是把不同性质的执行强行混在一起。
2. 每个模块都有停止标记,不代表它们有一致的取消语义
“取消任务”至少可能表示四件事:不再接受请求,不执行尚未开始的任务,请正在运行的任务尽快退出,或者只是不再接纳最终结果。
这几种行为不能互相替代。
例如,用户切换项目时,一个外部编码进程可能已经开始工作。业务可以立即宣布旧请求失效,但不能据此销毁编码任务仍在访问的输入。反过来,即使编码成功结束,也不代表结果还能写入新项目的缓存。
因此,需要同时建立两条约定:
- 执行层保证工作怎样停止、怎样完成收尾。
- 业务层判断请求和结果是否仍然属于当前状态。
这就是生命周期作用域与业务代次检查同时存在的原因。
3. 把任务移到 worker,不会自动得到线程安全
一个遍历可变世界、修改实体、更新音频状态的函数,并不会因为提交给线程池就突然变得安全。
调度器看见的是一个可调用对象,看不见它背后全部的共享数据关系。它不知道某个指针是否已经失效,也不知道两个函数是否会同时修改同一张表。
因此,并行化的起点不是“哪些函数能提交”,而是“哪些数据可以独立读取和写入”。
一个经常有效的拆分是:
1 | 协调线程选取并稳定输入 |
线程池提供算力,JobSystem 提供执行与收尾约定,数据边界决定并行是否合法。三者缺一不可。
二、全局架构:一个运行时,不等于一组线程
1. 先把所有权画清楚
下面的箭头表示所有权或调用关系,不表示整张图都在并发运行。
1 | flowchart TD |
进程入口创建并持有任务运行时,业务服务借用它。服务必须先结束,运行时才能销毁。
这里把负责组织更新和提交结果的线程称为协调线程,把某类资源的指定操作线程称为 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 | 开放:允许提交;即使 remaining = 0,也不能宣布终态 |
对应的关键条件很简单:
1 | function try_publish_completion(group): |
有些生命周期错误,正是来自把“暂时没有任务”误当成“以后不会再有任务”。封口把这个隐含假设变成了显式协议。
显式成员集合还避免了不必要的等待。假如一个小批次用“等到第 100 个任务完成”来表达,前面无关的慢任务也可能成为它的隐式前驱。只等待本组成员,才能准确表达调用方的真实依赖。
不过,group 独立不意味着可以违反串行通道的 FIFO。若本组任务排在同一通道的某个前驱之后,它依然要遵守通道顺序。
3. 执行身份,不能只靠一个 worker 编号
两个线程池都可能有 worker 0。已经存在的 owner 线程又不属于任何池。如果用这个编号索引线程私有的录制资源,不同执行者就可能访问同一份状态。
更可靠的做法是使用运行时内统一的执行槽位,并附带代次:
1 | execution_slot = { index, generation } |
索引用来定位槽位,代次拒绝已经注销、随后重新使用的位置所留下的旧身份。它不是物理 CPU 核号,也不应脱离所属运行时被当成全局通用标识。
身份与权限还必须分开。一个线程即使有 slot,也未必有资格执行录制;一次 wait 即使希望协助录制,也不能据此自动获得资源访问权。
可以把可帮助执行的工作理解为几个集合的交集:
1 | 可协作任务 = 当前等待的成员 |
这不是装饰性的权限系统,而是保护资源所有权的必要条件。
四、跟着一个任务批次,走完提交、完成和失败
现在用“计算一批对象的展示姿态”把前面的概念串起来。
1. 先建立一个不会逃逸的同步窗口
1 | function prepare_poses(stable_inputs): |
这里有几个不显眼、却不可缺少的前提:输入在同步窗口内保持稳定;输出提前分配;任务写互不重叠的区间;worker 不修改世界;所有已接纳任务结束以后,调用方才能退出这个窗口。
最后,协调线程仍要检查业务代次。任务完成保证“可以读取计算结果”,不保证“当前业务还需要这个结果”。
2. 窄适配器负责重复而容易出错的清理
多数帧内消费者不需要知道执行槽位、组句柄或协作权限。它们只需要表达“第 i 块做什么,并且返回前全部结束”。
因此,可以提供一个同步批次适配器,内部使用现有调度机制:
1 | function run_synchronous_batch(count, indexed_task): |
这段伪代码强调的是契约:所有失败出口都要处理已接纳任务;实际实现还需用自动清理保护覆盖异常出口。
适配器不是第二套调度器,不应重新创建队列或线程池。它只是用更窄的接口,替调用方封装正确的生命周期顺序。
同步窗口也使借用 callable 成为可能:整个批次共享一个按索引调用的函数对象,不必为每块复制一整套捕获。但这些并发调用仍然访问同一个函数对象,所以捕获状态是否安全,必须由调用方保证。
3. 提交首先是准入,不是执行承诺
一次可靠的提交,至少包含以下步骤:
1 | 加锁 |
登记与发布必须形成完整的准入过程。否则,内存分配等中途失败可能留下一个“未完成计数存在,但队列里永远找不到”的幽灵任务。
被接纳的任务应有可观察的成功、取消和失败状态。但“至多执行一次”不等于“保证执行一次”:尚未开始的任务可以被取消而完全不调用用户函数。
这个区别会直接影响业务 promise。若只有用户回调会兑现 promise,一次排队取消就可能让业务永远等不到完成。
4. 零 worker 会暴露一个隐藏假设
为了支持确定性执行、受限设备或测试,通用 CPU 域可以允许零 worker,由调用线程推进。
此时,submit 可能在返回前就执行刚提交的任务。对于需要特殊资源的执行域,则可以选择拒绝零 worker 提交,而不是继承这种内联行为。
因此,不要这样安排初始化:
1 | 提交一个会读取 service.state 的任务 |
也不要持有任务需要的业务锁进行可能内联执行的提交。异步任务接口不一定承诺“回调绝不会在调用栈内发生”。如果允许同步推进,就必须把重入写进契约和测试。
5. 部分失败比完全失败更危险
一个批次有四个任务,A、B 已经提交,C 因队列满被拒绝。
如果立即返回,A、B 可能仍在写输出,而调用方已经销毁输入、释放栈帧,或者在同一缓冲区开始串行重算。
于是,一个本来可处理的容量问题,就升级成悬空引用或并发写。
同步接口必须保证:无论成功还是失败,返回时都没有已接纳的回调继续借用调用方的状态。
这也是为什么 fallback 不能简单写成“提交失败就串行再跑一次”。必须先 drain;而且只允许重做纯计算,不能默认重放模拟更新、实体创建或播放事件。
五、等待不只是暂停:串行嵌套死锁的启示
1. 单个 worker 为什么需要协作等待
只有一个 worker 时,任务 A 提交 B,然后停住等待 B。如果调度器只是让 A 睡眠,就再没有执行者能运行 B。
协作等待允许 A 在满足约束时帮助执行相关任务,让系统继续前进。它不仅是减少空转的优化,也是零 worker 和嵌套依赖能够工作的基础。
但这种能力不是通用死锁检测器。任意业务锁、跨系统循环依赖和外部阻塞,都不能指望“等待时帮忙执行”自动解决。
2. 一个所有 worker 都救不了的死锁
设串行通道 L 使用一个布尔值表示是否正在运行:
1 | A 开始执行:L.running = true |
这个问题与 worker 数量无关。加十条线程也没有帮助,因为所有执行者都被同一条规则挡住了。
错误在于把两件不同的事混为一谈:禁止另一个线程并发进入,以及禁止当前线程在等待时重入自己的通道。
3. 用“所属执行栈 + 深度”代替一个布尔值
一种可行的修复是记录当前线程的任务执行栈,并为串行通道维护执行深度。
1 | function may_enter_serial_lane(lane, caller): |
同线程的合法嵌套可以推进,其他线程仍然互斥。队列取出仍遵守 FIFO,不能为了完成自己的 group 而跳过一个无关前驱。
这里的“串行”指不同线程不会同时执行该通道,不意味着父任务必须彻底返回后子任务才能开始。若业务本身禁止重入,上层就应该拒绝这种等待方式,而不是仅凭调度器支持便开放它。
任务也不能 drain 正在拥有自己的作用域或任务组,否则收尾操作会等待自己的生命结束。普通嵌套等待、组内自等待和所有权销毁,必须分别制定规则。
4. 没有数据竞争,也可能发生错误的串行化
另一个常见问题来自复制粘贴:两个无关服务使用了同一个 lane 数字常量。
结果不是崩溃,而是长时间编译挡住了另一个服务的短任务。数据完全安全,延迟却莫名其妙地变坏了。
这说明,串行身份也必须有所有者。应由运行时分配通道 ID,而不是让各模块自行协调数字命名空间。
通道 ID 可以在运行时生命周期内保持不复用,空闲队列存储则单独回收。业务服务通常创建一次通道,用自己的 scope 负责取消和排空。销毁一个非拥有型通道封装,不应暗中猜测是否还存在其他 scope 的任务,并替它们执行等待。
共享计算资源,不意味着共享业务顺序。只有确实需要排序的工作,才应该进入同一条通道。
六、生命周期:取消、完成与销毁,不能只靠一个 finished
1. 四个操作,四份不同承诺
| 操作 | 应当承诺什么 | 不应被误解成 |
|---|---|---|
| 等待完成 | 等待目标边界,观察失败或取消;必要时协作执行 | 自动释放拥有型句柄,或禁止开放 scope 接收其他工作 |
| 请求停止 | 关闭后续准入,通知运行中的任务协作退出 | 立即终止线程,或者所有任务已经退出 |
| 取消并排空 | 取消待执行工作,等待相关工作结算,然后关闭所有权边界 | 可以在持有任意业务锁时安全调用 |
| 成功回收 | 在已经成功完成时释放边界,不请求停止,不执行任务 | 未完成、失败或取消状态下的通用清理捷径 |
为什么要把成功回收单独拿出来?
因为一个已经完成的帧内批次,没有必要再走一遍“请求停止—取消—等待”的流程。它只需要释放组和作用域的记录。但成功回收必须严格检查前置条件,不能用性能优化的名义跳过未完成任务。
这个路径可以不等待任务,却仍然需要获取调度状态锁。“不等待任务”不等于“无锁”。父作用域回收前,也应先处理仍拥有生命周期的子 group。
还有一个结果层次必须分开:取消排空操作成功,表示清理完成,不表示被取消的业务任务执行成功。反过来,清理报告了某个任务错误,也不一定意味着清理本身没有完成。接口应说明句柄是否失效、边界是否已关闭,而不是让调用方用一个布尔值猜测一切。
2. 自动析构可以兜底,但不能替代退出协议
作用域如果只是一组整数,调用方丢弃它就可能忘记通知运行时。将它设计成拥有型对象,可以让析构自动执行必要的取消和排空,减少遗漏清理。
代价是:析构可能等待。
下面这个环不会被自动资源管理消除:
1 | 主线程持有 ServiceLock,开始销毁 scope |
正确顺序应该是:
1 | function shutdown_service(): |
进程级退出也是同一条所有权链:先停止任务生产者和更新循环,再停止并排空工作,然后销毁业务服务和适配对象,注销不再使用的线程身份,最后销毁任务运行时。
运行时不能先消失,让后续 scope 析构通过一个悬空引用回来请求清理。自动析构解决的是遗漏,不是颠倒所有权。
3. 函数返回了,任务真的完成了吗
假设一个 lambda 捕获了资源租约。函数体返回后,租约要在捕获对象析构时才归还。
如果执行顺序是:
1 | 执行函数 → 发布完成 → 唤醒等待者 → 析构函数捕获 |
等待者可能在第三步就销毁租约的所属服务,第四步却还在访问它。
因此,对正常执行路径,可靠的完成顺序应当是:
1 | 执行函数 → 销毁任务捕获 → 发布完成 → 唤醒等待者 |
捕获析构还应保持在任务执行身份的保护范围内,防止析构回调借机绕过“不能 drain 自己”的限制。
验证这个边界,可以做一个很有针对性的测试:让捕获对象的析构停在一个可控屏障上;此时确认 group 尚未完成、成功回收被拒绝;释放屏障以后,才允许观察完成。
这比简单地检查“任务函数被调用了”深入了一层:完成事件是一份资源生命周期承诺,不只是计数减一。
当然,若回调又发起了不受 scope 管理的异步工作,那部分工作需要自己的完成协议,不能被原任务的完成事件顺带包办。
4. 长期服务不能把全部历史都放在活跃表里
一个后台服务运行一天,可能处理数十万次请求。如果已完成任务永远留在活动表,等待、取消检查和诊断就可能越来越慢。
一种实用分离是:
1 | 活动表:只保留排队中、执行中的任务 |
例如,连续一万次成功,不必保留一万个完整任务记录;一个完成区间就能支持旧句柄查询。等待热路径只关心仍可能影响前进的活动任务。
但区间压缩不是严格恒定内存的证明:状态若持续交错,区间数仍会增加。scope 关闭、历史保留策略和业务请求预算仍然重要。
同样,有界队列也不代表整个系统内存天然有界。每条串行通道都可能有自己的队列,执行中的任务还持有输入,外部编码可能产生大块输出。执行层的容量控制必须与业务层的并发数、数据尺寸和保留预算配合。
七、典型消费者:把统一契约接到不同系统
1. 资源加载:执行完成与业务有效要分别判断
资源服务适合使用这样的结构:服务持有 scope,请求带独立身份,任务持有受保护的输入,完成结果回到业务层检查代次。
1 | request = { source, project_generation, request_id } |
业务代次检查不能代替资源保活。发现旧代结果不应使用,并不意味着可以先释放后台任务还在访问的对象。
为了避免直接等待,有时会把整个旧服务扔进一个“以后再销毁”的列表。短期看退出变快了,长期却可能保留整套线程、缓存和队列。更稳妥的方式,是明确停止准入、取消并排空,再销毁服务;若确实需要延迟回收,应保留最小必要状态,并设置容量。
外部进程又是另一类问题。停止令牌无法迫使一个卡住的编码进程退出,所以还需要超时、进程终止和输出清理策略。把阻塞调用放到 Job 里,不会使它变成原生异步 I/O。
2. 物理适配:迁移线程所有权,不能丢掉动态依赖
第三方物理库的任务通常不是一份固定列表。任务执行中可以生成新任务,依赖归零后才进入就绪状态,barrier 也可能在等待时增加成员。
这时不应强行把它压平为一个封口批次。更合适的分层是:第三方库管理动态依赖,适配器把已经就绪的任务提交到指定计算域,作用域约束这次更新的生命周期。
1 | 第三方依赖计数归零 |
困难主要集中在取消和拒绝。
调度器可能取消一个排队任务,完全不调用它的函数。但第三方库仍然需要释放引用、结算依赖和唤醒 barrier。如果只有用户函数能做这些事,整个物理步骤就会卡住。
适配器必须把“执行物理用户代码”与“完成第三方内部收尾”分离:停止以后跳过前者,仍然完成必要的后者。捕获对象直接被销毁时,也必须释放为排队持有的引用。
barrier 的完成同样不能只看任务的 done 位。有些实现先把任务标记为 done,再调用完成通知;等待者若据 done 位立即销毁 barrier,通知回调就会访问已释放对象。
正确边界应包含通知交付及其最后一次成员访问。等待器还要覆盖“取消没有执行回调,因此没有常规通知”的路径,不能无条件指望原来的条件变量唤醒。
适配任务系统,最难的不是转换函数签名,而是让双方对取消、完成和引用释放的理解一致。
3. 渲染:先冻结输入,再讨论并行录制
直接让 worker 遍历正在变化的世界,同时操作共享图形状态,是一条危险的捷径。任务调度即使完全正确,数据访问也已经越界了。
更可靠的主链是:
1 | flowchart LR |
所谓冻结,不只是把顶层指针改成只读。相机、实例、材质参数及其二级引用都需要稳定的生命周期;否则只读外壳仍然可能指向正在变化或已经销毁的数据。
准备与预检全部成功后,才开始新的消费副作用。不过,这不等于数据库事务:消费中途失败时,已播放的音频、已发生的外部效果未必能撤销,也不能简单重新执行整帧。
受控并行录制通常还需要三道约束:
- 执行者具有合法 slot 和录制能力,才能取得线程私有录制资源。
- 录制结果带发布代次(epoch),旧批次的迟到结果不能进入下一批。
- 可以乱序录完,但必须按稳定的业务顺序合并和消费。
录制失败后的回退,要与副作用边界配合。在 primary 尚未消费这批结果之前,可以排空并丢弃整批,再由 owner 重录;进入了部分消费之后,就不能无条件从头重放。
4. CPU 完成,不等于 GPU 已经用完
并行录制任务结束,只意味着 CPU 已经生成命令。GPU 可能还没有开始执行。
因此,下面这条推理是错误的:
1 | 任务组完成 → 这一帧所有资源都可以释放 |
正确模型需要两段生命周期:
1 | CPU 任务完成:允许回收不再使用的 CPU 准备数据 |
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 | 并行耗时 ≈ 可并行计算量 / 有效并行度 |
这是解释模型,不是精确的性能预测公式。当每块计算很轻,新增成本可能超过节省的计算时间。
一组轻量数据准备微基准就呈现过这种情况:为 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,不能让测试与被测库处于不一致的语义环境。
另一个陷阱是退出诊断。线程本地容器的辅助分配可能晚于某些泄漏报告阶段才析构,产生与真正长期泄漏不同的现象。处理这类问题时,应追踪实际分配与析构时序,而不是删除告警或把所有残留都归为同一种泄漏。
如果用固定容量线程本地栈消除相关分配,也必须明示嵌套上限和超限行为,不能把内存问题换成越界问题。
最后,必须区分不同证据:单元测试通过、主机侧平台代码可编译、目标工具链链接成功、真实设备运行、长期压力与帧级性能,分别回答不同问题。任何一种都不能替其余几种签字。
十、接入新任务之前,先问这八个问题
- 它是短时可分割的 CPU 工作,还是长期阻塞、事件驱动的运行机制?
- 输入和输出由谁持有?作用域早退、业务销毁、世界换代时,所有借用是否仍然安全?
- 需要的是一般并发、FIFO,还是固定线程亲和性?
- 等待的是一个明确批次,还是整个长期服务?能否使用窄适配器?
- 提交被拒绝、回调抛异常、排队任务根本没执行时,业务请求由谁完成?
- 能否重算?若能,是否先排空并行工作;若不能,副作用的恢复边界在哪里?
- CPU 完成以后,还有哪些业务代次、世界提交或 GPU 生命周期需要处理?
- 零/单/多 worker、满队列和销毁中任务,有没有可重复测试与同负载性能对照?
能够清楚回答这些问题,比继续增加一个 executor、一种 fence 或一层泛化接口,更接近架构上的成熟。
结语:成熟的并发,是每一条边界都有负责人
从局部线程池到稳定 JobSystem,最重要的变化不一定是调度算法,而是把原来混在一起的事情拆开:预算与业务,顺序与亲和,作用域与任务组,计算成功与结果有效,CPU 完成与 GPU 完成。
这些边界建立以后,资源服务可以迁移,第三方物理可以接入,渲染可以开放受控并行,网络纯计算可以复用已有能力,而不必每增加一个消费者,就重新讨论整套退出协议。
真正值得追求的,不是让更多代码同时运行,而是让并发不再是一种难以解释的偶然状态。
好的 JobSystem,使并行成为可以选择、可以验证、可以安全退出的能力。