自制游戏引擎 - 多线程
CPU的算力是指CPU拥有的逻辑核心数(超线程下,一个物理核心等于两个逻辑核心) * 单个核心的频率,每个逻辑核心都可以挂载一个线程来实现并行计算。CPU硬件近些年的提升也是集中在可用的逻辑核心数越来越多,而非单核频率上。
举个例子,考虑一台电脑上拥有8个逻辑核心的CPU,但我们只使用单线程运算,那么程序即使写的再好也只能发挥12.5%以下的CPU性能。所以一些没有设计一套多线程基础框架的传统软件,目前非常难利用好现代CPU,虽然有一些类似Intel TBB,OpenMP之类的多线程库可以很方便地在某个功能内直接使用来优化性能,但是由于线程本身的启动,挂起,切换上下文都具备不少的性能开销,只有在明显长时间卡顿的情况下才是一个好的选择。
所以对于现代化游戏引擎或者软件设计,我们在早期就搭建好一套方便上层使用的多线程框架是有必要的:
- 我们可以读取当前CPU硬件的核心数量,在引擎启动时便准备好固定数量的线程数,与逻辑核心挨个绑定,避免线程管理上的混乱
- 多线程框架设计出的API可以让其他模块的开发减少对各类多线程同步原语的关注,可以按同步的编程思路实现异步,类似C#的await/async,减轻心智负担
- 模块早期开发的逻辑和算法设计上,能注意到对多线程的使用
准备
- 基础概念
- 同步原语
- Mutex
- Spin lock
- Atomic, lockfree
- Semaphore
- 协程Coroutine
- 纤程Fiber
- Fence/Barrier
- 同步原语
Unity Job System用例
Job System是Unity运行时的多线程框架。可以先从它在上层使用的一些例子来简单理解。
- 执行单个Job
1
2
3
4
5
6
7
8
9
10
11
12
13// 输入Job的函数指针及需要的参数,输出Fence变量用于等待同步
// 安排Job执行
JobFence jobFence;
ScheduleJob(jobFence, MyJobFunction, jobData);
// 无须等待Job完成就可以做的一些事情
DoSomethingNotRelatedToJob();
// 等待Job完成
SyncFence(jobFence);
// 做一些依赖于Job生成数据的事情
DoSomethingAfterFinishingJob(); - 执行多个相互无依赖的Job
1
2
3JobFence jobFence;
// 与执行单个Job是类似的,换了个API,传入JobData数组以及Job的数量
ScheduleJob(jobFence, MyJobFunction, jobDataArray, jobCount); - 执行两个Job,第二个Job依赖于第一个Job
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17// 安排第一个Job执行
JobFence job1Fence;
ScheduleJob(job1Fence, MyJobFunction1, myJobData);
// 安排第二个Job执行,依赖于第一个Job完成,把Job1的Fence作为输入
// Job1完成后会自动进行Job2
JobFence job2Fence;
ScheduleJobDepends(job2Fence, MyJobFunction2, myJobData, job1Fence);
// 无须等待Job完成就可以做的一些事情
DoSomethingNotRelatedToJob();
// 等待第二个Job完成
SyncFence(job2Fence);
// 做一些依赖于Job生成数据的事情
DoSomethingAfterFinishingJob(); - 执行一个依赖于多个Job完成的Job
1
2
3JobFence jobFence;
// 与两个Job顺序执行的例子是类似的,只是需要把依赖的所有Job的Fences数组作为输入
ScheduleJobMultipleDependencies(jobFence, MyJobFunction, jobData, dependencyFences, dependencyFenceCount);
Unity Job System的High Level设计
源码位于EditorApp项目下的Runtime/Jobs目录,我们先从high level了解一些Schedule API背后的概念。
Job是对一次函数调用的描述。
- Job
- JobFunc
typedef void JobFunc (void* userData);
- JobData
void* userData;
- JobFunc
为了存储Job的其他关联性数据,需要扩展Job,用JobInfo进行描述。
- JobInfo
- Job
- JobGroup* group
- JobInfo* next
- 快速得到同一个JobGroup中该JobInfo的下一个JobInfo结点
上层调用ScheduleJob系列API时,第一步是把传入的单个Job或者多个Job先包装成JobInfo对象,再一起打包到给一个新创建的JobGroup作为统一的管理容器,JobGroup内部是一个JobInfo*的单向链表。
所以JobGroup创建出的数量与Schedule API调用次数是对等的。同时Schedule API需要依赖其他Job时,JobGroup会记录依赖的其他Job所在的JobGroup是什么,也就是说我们可以通过JobGroup得到一张Job System的依赖关系图。
为了让JobGroup互相之间能按照优先级,依赖关系等决定执行顺序的因素进行正确计算,需要有一个管理所有JobGroup的容器,它叫JobQueue,是一个线程安全的单例,内部存储了用于复用JobInfo,JobGroup对象的内存池,负责分配工作线程去执行Job以及工作线程的管理,相当于Job System的Context类。
JobQueue内部有两种途径去执行Job,一种是把高优先级Job压到存储优先执行Job的栈中,同是高优先级则最先Schedule的最后执行(First In Last Out);另一种是把常规Job压到一个队列中,最先Schedule的最先执行(First In First Out)。
TODO :另外Job System有一种叫做ZeroJobWorkerMode的模式,开启后JobQueue会按照高优先级的方式处理Job,暂时还没了解到它的使用场景,略。
JobFence则是用于同步某一个Job完成信号的封装类。它可以快速查询到某个Schedule API产生的Job Group的状态进行不同等待处理。比如说Job提交之后等待信号量完成Semaphore->WaitForSignal();
,或者工作线程下yield出去让其他Job进来跑一会。
我们以ScheduleJob API为例做一次回顾,加深印象。
1 | void ScheduleJobInternal(JobFence& fence, JobFunc* jobFunc, void* userData, JobPriority priority) |
Unity Job System的Low Level细节
TBD
Naughty Dog Fiber based Job System的设计
TBD
自制Job System的设计
- TBD
参考链接
- Distinguishing coroutines and fibers https://www.open-std.org/jtc1/sc22/wg21/docs/papers/2014/n4024.pdf
- GDC2015上顽皮狗提出的Fiber based Job System概念 https://www.gdcvault.com/play/1022186/Parallelizing-the-Naughty-Dog-Engine
- TheMachinery引擎的Fiber based Job System实践 https://ourmachinery.com/post/fiber-based-job-system/