作业系统
Job System
多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)
任务分解(卷 I 8.6.1)
把每轮游戏循环的工作分解成可并行的子任务,就把顺序程序变成了并发程序。分解方式大致分两类(见 并发与并行基础):
- 任务并行:要做许多不同的事,分到多个核上并行,例如动画混合与碰撞检测并行,或提交第 N 帧图元给 GPU 的同时开始更新第 N+1 帧的游戏世界。
- 数据并行:同一计算要在大量数据上重复。GPU 是典型,但 CPU 在游戏循环中也有很多此类工作。
每子系统一个线程(卷 I 8.6.2)
把渲染、碰撞与物理、动画管线、音频等各放一个线程,由主线程控制同步并承担大部分高层逻辑(书中图 8.4:主线程跑 HID、更新游戏对象、启动动画/动力学/渲染;动画线程做姿势混合、全局姿势、蒙皮矩阵调色板;动力学线程做模拟积分、粗/细阶段碰撞、约束;渲染线程做可见性、排序、提交图元、等 GPU、全屏效果、等 VBlank、交换缓冲,空闲时睡眠)。这是简单的任务并行,但不实用:
- 子系统数和核数对不上,线程多于核时要分时共享;
- 各子系统每帧工作量不同,有的核很忙有的长期空闲;
- 子系统之间有数据依赖,渲染和音频必须等第 N 帧的动画、碰撞、物理完成,互相依赖的就无法并行。
分散/汇集(卷 I 8.6.3)
许多任务是数据密集的:大量射线检测、大批动画姿势混合、为海量对象算世界矩阵。分而治之:例如把 13,000 次射线检测分成 13 批、每批 1000 次,分给 PS5 / Xbox Series X|S 上可用的 13 个硬件线程(书中脚注:两机都有 8 核、每核 2 个超线程,但第 8 核完全不可用——为容忍制造缺陷——剩 7 核 14 线程,其中 1 个留给操作系统)。
原书问题:这个脚注和公开资料对不上,书内前后也不一致。Digital Foundry 对 Xbox Series X 的官方规格报道说,第 8 个核(2 个线程)是留给操作系统和前端 shell 的,游戏可用 7 核:关闭 SMT 时 7 线程,开启 SMT 时 14 线程。报道里没有说这个核是为了容忍制造缺陷而禁用的(https://www.digitalfoundry.net/articles/digitalfoundry-2020-inside-xbox-series-x-full-specs)。书中 8.6.4.9 又说 PS5 上开发者可用 7 个核,每核一个工作线程,和这里的“13 个硬件线程”是两种说法。PS5 的系统预留情况索尼没有公开。分布式系统术语称之为 scatter/gather:拆分、分发执行(散),全部完成后合并结果(收)。
在游戏循环中(8.6.3.1):主线程把 N 个数据项分成 m 批(每批约 N/m,m 通常取决于可用核数,也可留几个核做别的),启动 m 个工作线程并给每个传起始下标和数量;工作线程原地更新,或(通常更好)输出到各自预分配的缓冲。主线程散出后可以先做别的,之后用 pthread_join() 之类等全部完成(若已结束则立即返回,否则睡眠),再合并结果——例如动画混合全部完成后才开始算蒙皮矩阵。
下图示意一帧内主线程多次 scatter/gather:
flowchart LR
A["HID 与更新游戏对象"] --> S1{{scatter}}
S1 --> P1["姿势混合 ×3"]
P1 --> G1{{gather}}
G1 --> B["动画后游戏对象更新"]
B --> S2{{scatter}}
S2 --> P2["模拟/积分 ×3"]
P2 --> G2{{gather}}
G2 --> C["布娃娃物理 …"]- SIMD(8.6.3.2):循环向量化(见 SIMD 向量化)其实是极细粒度的 scatter/gather,通常与线程级 scatter/gather 结合使用,每个工作线程内部再向量化。
- 效率问题(8.6.3.3):生成和合并线程都要内核调用,内核要做大量建立和拆除工作,每次 scatter/gather 都新建线程不现实。可用预先生成的线程池(如 Windows 的线程池 API;没有就用条件变量、信号量、原子布尔等自己实现,见 线程同步原语)。但池中线程必须能执行任何一种 scatter/gather 操作,用巨大的
switch很笨拙难维护——真正需要的是通用的并发工作执行系统。
作业系统与典型接口(卷 I 8.6.4、8.6.4.1)
作业系统把任意工作单元(作业 (job))调度到多个核上:程序员把每轮循环拆成较多相互独立的作业提交,系统维护作业队列,交给线程池的工作线程或其他方式执行——像一个调度作业而非线程的轻量自制内核。作业可以任意细粒度、许多互不依赖,因此能最大化处理器利用率,并自然适配任意核数(书中图 8.6)。
接口类似线程库:启动 (kick) 作业(相当于 pthread_create())、等待其他作业结束(相当于 pthread_join())、可能的提前自我终止;还要提供自旋锁或互斥锁,可能还有让作业睡眠/唤醒的条件变量类机制。启动作业时传入作业声明 (job declaration):至少有入口函数指针;参数最简单是一个 uintptr_t(能传布尔或整数,也能安全转换指针以传任意结构体);还可带优先级。书中示例 API:
namespace job {
typedef void EntryPoint(uintptr_t param); // 所有作业入口的签名
enum class Priority { LOW, NORMAL, HIGH, CRITICAL };
struct Counter /* 实现略 */; Counter* AllocCounter(); void FreeCounter(Counter* pCounter);
struct Declaration {
EntryPoint* m_pEntryPoint;
uintptr_t m_param;
Priority m_priority;
Counter* m_pCounter;
};
void KickJob(const Declaration& decl);
void KickJobs(int count, const Declaration aDecl[]);
void WaitForCounter(Counter* pCounter); // 等计数器归零
void KickJobAndWait(const Declaration& decl);
void KickJobsAndWait(int count, const Declaration aDecl[]);
}
基于线程池的简单实现及其局限(卷 I 8.6.4.2–8.6.4.3)
- 每个 CPU 生成一个工作线程,用亲和性锁定到一个核。工作线程无限循环:在条件变量上睡眠等待作业 → 加锁取出下一个作业声明的副本 → 解锁 → 调用入口函数 → 回到循环顶部。
- 局限:例如 NPC 思考作业中途启动一个瞄准射线检测作业(在另一核上跑),然后
WaitForRayCast()等结果再决定是否开火。简单线程池里每个作业一旦开始就必须跑完,不能睡眠让出工作线程,因为作业与工作线程共享同一调用栈;要让作业睡眠就得保存它的栈和寄存器并换入另一个作业的栈,这种简单执行方式做不到。 - 原书问题(代码笔误):书中
NpcThinkJob(uintparam_t param)的参数类型uintparam_t不存在,应该和job::EntryPoint的签名一致,写成uintptr_t。8.6.4.2 的JobWorkerThread示例也不完整:没有重置g_ready,返回void*的函数也没有return,书中自己也说明它只用于示意。
作业即协程或纤程(卷 I 8.6.4.4–8.6.4.5)
- 协程:可以在执行中途让给另一个协程,之后从原处继续——实现上确实在所在线程内交换调用栈,所以协程作业可以「睡眠」让其他作业运行。
- 纤程:纤程间上下文切换总是协作式的。系统先把一个线程转换为纤程,该线程一直运行这个纤程,直到显式调用
SwitchToFiber();切换时整个调用栈被保存;纤程甚至可以在线程间迁移,因此很适合实现作业系统。Naughty Dog 的作业系统基于纤程。(底层机制见 进程、线程与纤程)
作业计数器(卷 I 8.6.4.6)
有了可睡眠的作业,就能实现 join:调用者睡眠直到其他作业完成(类似 pthread_join() 或 Windows 的 WaitForSingleObject())。
- 句柄方案:每个作业一个句柄,
job::Join(handle)。缺点:等待大量作业时扩展性差;等待单个作业需要周期性轮询所有作业状态,浪费 CPU。 - 计数器方案:像「反向的信号量」。作业启动时可关联一个计数器(经
job::Declaration传入),启动时加一、结束时减一;等一批作业就是用同一计数器启动它们,再等计数器归零。检查可以在计数器递减的那一刻进行,比轮询高效。Naughty Dog 作业系统使用计数器。
作业同步原语(卷 I 8.6.4.7)
作业系统也要提供自己的同步原语,而且通常不是内核线程同步原语的简单包装:OS 互斥锁拿不到锁时会让整个线程睡眠;在线程池作业系统中,这会让整个工作线程停下,而不只是想等锁的那个作业,该核上再无作业可运行,很可能导致死锁。
- 低争用时用自旋锁即可,Naughty Dog 的作业系统大部分加锁用自旋锁。
- 高争用时可用自定义「作业互斥锁」:先忙等,短暂超时后仍拿不到就把协程/纤程让给其他作业(让等待的作业睡眠);作业系统要像内核跟踪等锁线程那样跟踪所有睡眠作业,在锁释放时唤醒它们。
可视化与剖析工具(卷 I 8.6.4.8)
作业及其依赖图很快变得庞大复杂,应提供可视化与剖析工具。Naughty Dog 的工具(用于《神秘海域:失落的遗产》及其 PS4/PS5 游戏)纵向列出 7 个核加 GPU,时间从左到右、以竖线分帧;每个核时间线上的细块是作业,下面的细块行是作业的调用栈及各函数耗时。作业按功能着色(如射线检测为红色,可目测找出过宽的作业);点击作业时非同类作业变灰,并用细线连到它启动的作业和启动它的作业;悬停显示名称和毫秒耗时。
- 剖析陷阱 (profile trap):某区域大多 30 FPS 偶尔掉到 24 FPS 时,设一个陷阱(如超过 35 ms 的帧),正常游玩;一旦出现这样的帧就自动暂停并显示剖析视图,分析那一帧的作业找出元凶。帧预算视角另见 帧时间预算与渲染性能。
Naughty Dog 作业系统(卷 I 8.6.4.9)
用于《最后生还者 重制版》《神秘海域 4》《神秘海域:失落的遗产》,基本遵循上述设计:基于纤程(而非线程池或协程)、用自旋锁并提供能让作业睡眠的特殊作业互斥锁、用计数器而非作业句柄实现 join。
- 启动时主线程把自己转换为纤程,使整个进程启用纤程;然后为 PS5 上开发者可用的 7 个核各生成一个工作线程,用 CPU 亲和性锁定到核(因此工作线程与核大致同义,尽管更高优先级线程偶尔会短暂打断)。PS5 上创建纤程很慢,所以预先生成纤程池及作为各纤程调用栈的内存块。
- 启动的作业声明进入队列;核/工作线程空闲时从队列取作业执行;运行中的作业也可以继续加作业。
- 执行作业:从纤程池取一个空闲纤程,工作线程
SwitchToFiber()开始运行;作业从入口返回或自我终止时,最后一步是SwitchToFiber()回作业系统,再选下一个作业,如此往复。 - 作业等待计数器时被置为睡眠,其纤程(执行上下文)连同所等计数器放进等待列表;计数器归零时唤醒,从原处继续。睡眠与唤醒同样是在作业纤程与各核的作业系统管理纤程之间
SwitchToFiber()。
下图概括作业在 Naughty Dog 系统中的流转:
flowchart LR
K["KickJob:声明入队"] --> Q["作业队列"]
Q --> W["空闲工作线程取作业<br/>从纤程池取纤程 SwitchToFiber"]
W --> R{"作业运行"}
R -- "等待计数器" --> WL["纤程进入等待列表"]
WL -- "计数器归零" --> R
R -- "结束" --> B["SwitchToFiber 回作业系统"] --> Q深入资料:Christian Gyrling 的 GDC 2015 演讲《Parallelizing the Naughty Dog Engine》(书中参考文献 [221],幻灯片 [222])。
与其他来源的对照
- 任务系统(UE 5.8 文档)说明 UE5.5 起不再在等待时执行其他任务(忙等待),改为唤醒额外线程(超额订阅);GEA 描述的 Naughty Dog 方案则是让等待中的作业纤程睡眠、在同一批工作线程上切换到其他作业。两者是处理「作业内等待」的不同设计,各自来源所述,并列记录。
- 多线程渲染与任务系统(RTR4 第 18 章)从渲染应用阶段讲任务化与同步开销;GEA 这里从游戏循环整体讲作业系统的实现。
GEA 第 17 章的补充:把游戏对象更新变成作业(卷 II 17.7)
GEA 第 17 章把本页的作业系统用到了游戏对象模型上,详见 游戏对象并发更新:
- 并行度与同步点:一组作业的 DOP 是理论上能同时运行的最大作业数;每个依赖都会引入同步点。推迟同步点(让依赖者在被依赖者完成很久之后才运行)可以消除空等,Mike Acton 的说法是"优化并发设计的秘诀是延迟"。
- 桶即作业批次:Naughty Dog 把对象分桶,每个桶内的对象更新用
job::KickJobsAndWait()作为一批作业启动;桶与桶之间是同步点。 - 纤程让阻塞调用重新可行:在纤程作业系统中,对象更新里可以直接调用
castRayAndWait(),作业睡眠、结果就绪后被唤醒,这正是本页作业计数器一节WaitForCounter()的机制。 - 同桶读写:读用对象快照(状态缓存的一种),写用锁、请求队列或延后到下一个桶的同步作业。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 8 章 8.6 节
- Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.7 节