Job System 为什么会出现

过去很多程序员学习多线程时,接触到的通常是:

std::thread

或者:

ThreadPool

然后向线程池提交任务:

pool.enqueue(task);

等待执行。

对于许多应用程序而言,这已经足够。

例如:

  • 网络服务
  • Web 后端
  • 数据处理
  • 工具软件

任务之间相对独立,执行时间也相对稳定。

但游戏引擎很快发现,问题没有这么简单。


Thread Pool 最开始工作得很好

假设一个引擎拥有:

  • 物理系统
  • 动画系统
  • AI 系统
  • 音频系统

于是很自然地写出:

pool.enqueue(physics);
pool.enqueue(animation);
pool.enqueue(ai);
pool.enqueue(audio);

交给多个线程执行。

看起来一切正常。


但随着项目规模增长,问题开始出现。


真正的问题不是线程

很多开发者会把:

线程

和:

并行

混为一谈。

实际上并不是。


例如:

Thread 0
执行 10ms

Thread 1
执行 1ms

Thread 2
执行 1ms

Thread 3
执行 1ms

虽然拥有四个线程。

但最终仍然需要等待:

10ms

才能完成这一帧。


此时三个核心已经空闲。

而一个核心仍然繁忙。

CPU 利用率并不理想。


问题并不在于线程数量。

而在于工作无法均匀分配。


CPU 核心数越来越多

早期 CPU:

2 Core
4 Core

已经足够。


而今天:

8 Core
16 Core
24 Core
32 Core

已经十分常见。


如果仍然按照:

物理线程
动画线程
渲染线程

固定划分。

那么很快就会出现:

部分核心很忙

部分核心空闲

的情况。


随着核心数量增长。

固定线程模型的利用率越来越低。


为什么不把工作拆得更细

假设:

Physics

需要更新:

10000 个刚体

那么完全可以拆分为:

Physics Job 0
Physics Job 1
Physics Job 2
...
Physics Job 31

每个 Job 负责一部分数据。

例如:

0 ~ 312

313 ~ 624

625 ~ 936

此时调度器可以根据当前负载动态分配任务。

谁空闲。

谁执行。


关注点开始发生变化。


从:

线程

变成:

工作

Job System 的核心思想

很多人认为:

Job System 是一种高级 Thread Pool。

这个说法并不完全准确。


Thread Pool 的核心对象是:

Thread

Job System 的核心对象是:

Job

开发者只负责描述:

需要完成什么工作

系统负责决定:

哪个线程执行

什么时候执行

在哪里执行

关注点从线程转移到了工作。


数据布局推动了 Job System

上一篇讨论 SIMD 时提到。

现代引擎越来越倾向于:

Position[]
Velocity[]

这样的连续数据布局。


例如:

for(size_t i = 0; i < count; i++)
{
    position[i] += velocity[i];
}

这类循环天然可以拆分:

0 ~ 999

1000 ~ 1999

2000 ~ 2999

3000 ~ 3999

每个区间都是一个独立 Job。


因此:

SoA
↓
Archetype
↓
SIMD
↓
Job System

实际上是一条连续的演化路线。


连续数据不仅更适合 SIMD。

也更适合并行处理。


依赖关系开始成为问题

当 Job 数量增加之后。

新的问题出现了。


例如:

Animation
↓
Skinning
↓
Rendering

显然:

Rendering

不能在:

Skinning

之前执行。


同样:

Skinning

也不能在:

Animation

之前执行。


于是系统开始管理:

依赖关系

而不仅仅是任务队列。


此时 Job System 看起来越来越像:

调度系统

而不再只是一个线程池。


Work Stealing 的出现

大型 Job System 通常还会加入:

Work Stealing

工作窃取机制。


例如:

Core 0
很忙

Core 7
空闲

那么:

Core 7

可以主动从其他队列获取任务。


这样可以避免:

部分核心过载

部分核心空闲

的情况。


CPU 利用率进一步提高。


现代引擎中的调度器几乎都会实现类似机制。

因为真正重要的已经不是线程数量。

而是工作如何流动。


Job System 并没有消灭 Thread Pool

这是一个容易产生的误解。


实际上。

绝大多数 Job System 的底层仍然存在:

Worker Thread

区别在于:

Thread Pool 围绕线程组织系统。


Job System 围绕工作组织系统。


线程成为实现细节。


Job 成为核心抽象。


开发者思考的是:

工作之间的关系

而不是:

线程之间的关系

为什么现代引擎越来越依赖 Job System

随着硬件发展。

CPU 的提升越来越依赖:

更多核心

而不是:

更高频率

过去的问题是:

如何让一个核心更快

于是出现:

Cache
SIMD
SoA
Archetype

后来的问题变成:

如何让所有核心同时工作

于是出现:

Job System

它并不是为了替代 Thread Pool。

而是在工程规模增长后。

开发者逐渐发现:

真正需要管理的从来不是线程。

而是工作本身。


结语

从单线程到多线程。

从 Thread Pool 到 Job System。

变化的并不是底层执行单元。


CPU 仍然在执行线程。


真正发生变化的是软件的思考方式。


过去开发者思考的是:

创建多少线程

现在开发者思考的是:

如何拆分工作

如何描述依赖

如何调度任务

当核心数量不断增长时。

决定性能的往往不再是线程数。

而是工作如何流动。

而 Job System,正是这种变化后的产物。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐