中EN
概念页

作业系统

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、交换缓冲,空闲时睡眠)。这是简单的任务并行,但不实用:

  1. 子系统数和核数对不上,线程多于核时要分时共享;
  2. 各子系统每帧工作量不同,有的核很忙有的长期空闲;
  3. 子系统之间有数据依赖,渲染和音频必须等第 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。

  1. 启动时主线程把自己转换为纤程,使整个进程启用纤程;然后为 PS5 上开发者可用的 7 个核各生成一个工作线程,用 CPU 亲和性锁定到核(因此工作线程与核大致同义,尽管更高优先级线程偶尔会短暂打断)。PS5 上创建纤程很慢,所以预先生成纤程池及作为各纤程调用栈的内存块。
  2. 启动的作业声明进入队列;核/工作线程空闲时从队列取作业执行;运行中的作业也可以继续加作业。
  3. 执行作业:从纤程池取一个空闲纤程,工作线程 SwitchToFiber() 开始运行;作业从入口返回或自我终止时,最后一步是 SwitchToFiber() 回作业系统,再选下一个作业,如此往复。
  4. 作业等待计数器时被置为睡眠,其纤程(执行上下文)连同所等计数器放进等待列表;计数器归零时唤醒,从原处继续。睡眠与唤醒同样是在作业纤程与各核的作业系统管理纤程之间 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() 的机制。
  • 同桶读写:读用对象快照(状态缓存的一种),写用锁、请求队列或延后到下一个桶的同步作业。
  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 8 章 8.6 节
  3. Game_Engine_Architecture_4th_Edtion_Volume_2_-_Jason_Gregory.pdf
  4. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 II 第 17 章 17.7 节

相关页面

游戏开发游戏循环与更新方法Game Loop & Update Method游戏循环 (Game Loop) 让游戏时间的推进与玩家输入和处理器速度脱钩:每一轮非阻塞地处理输入、更新状态、渲染,并跟踪真实时间来控制节奏;最常推荐的做法是“固定更新步长、可变渲染”。更新方法 (Update Method) 是它的搭档:世界维护一组对象,每个对象实现一个模拟“一帧”行为的 update(),每帧依次调用。两者和组件模式一起常被视为游戏引擎的核心。游戏开发进程、线程与纤程Processes, Threads & Fibers并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。游戏开发线程同步原语Thread Synchronization Primitives并发编程的中心问题是数据竞争 (Data Race):多个控制流交错地读改写同一共享对象。解决思路是让同一对象上的关键操作具备原子性(看起来瞬间完成,从而被串行化)。最简单可靠的工具是操作系统提供的线程同步原语:互斥锁、临界区、条件变量、信号量、Windows 事件。它们好用但昂贵(需要内核调用,上下文切换可超过 1000 个时钟周期)。游戏开发并发与并行基础Concurrency & Parallelism FundamentalsGEA 区分两个正交的概念:并发 (Concurrency) 是软件层面多个控制流读写共享数据,核心难题是数据竞争;并行 (Parallelism) 是硬件层面两个以上部件同时工作。硬件并行又分为提升单线程速度的隐式并行(流水线、超标量、VLIW,即指令级并行 ILP)和为同时运行多条指令流而设计的显式并行(超线程、多核、多处理器、集群/云)。游戏开发SIMD 向量化SIMD VectorizationSIMD(单指令多数据)用一条机器指令并行处理多个数据。x86 上从 MMX 发展到 SSE(128 位,4 个 float)、AVX(256 位,8 个)和 AVX-512(512 位,16 个)。GEA 用 SSE 内建函数演示:数组相加、点积、向量×矩阵、矩阵×矩阵,强调两点——避免寄存器内的水平相加(先转置数据),以及把 SIMD 寄存器看成 N 条并行的「通道 (lane)」而不只是一个 4D 向量;条件分支则用向量谓词化 (vector predication / select) 代替。游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。游戏开发 · UE5任务系统Tasks SystemUE::Tasks 是 UE5 的任务级并行库,是对 TaskGraph 的改进,与 TaskGraph 共用同一套调度器和工作线程。它支持启动任务、等待或取结果、声明前置依赖、嵌套任务、管道 (Pipe) 和任务事件。官方建议用依赖关系把工作连成图,而不是阻塞等待;5.5 起用「超额订阅」取代了容易死锁的忙等待。游戏开发游戏时间线与时钟Game Timelines & Clocks游戏引擎要同时处理多种「时间」:真实时间、游戏时间、动画片段的局部时间、CPU 周期等。GEA 用抽象时间线 (abstract timeline) 统一看待它们,并讨论帧时间 \Delta t 的测量方法、高精度计时器、时间单位与时钟变量类型的取舍,以及断点恢复后的异常 \Delta t 处理。(卷 I 第 8 章 引言、8.4–8.5 节)游戏开发游戏内性能与内存剖析In-Game Profiling & Memory Tracking游戏是实时系统,要保持 30 或 60 FPS,同时受目标硬件内存限制。第三方剖析工具有局限、在主机上未必可用,所以许多引擎自带游戏内剖析器(手动插桩的代码块计时、层级关系、调用次数、导出 CSV)和内存统计与泄漏检测工具。GEA 讲解它们的实现要点和常见限制。(卷 I 10.8–10.9 节)游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。综合引擎运行时架构总览Engine Runtime Architecture Overview本页把 wiki 中分散在 GEA 第 1、6、8、17 章及 GPP、UE5 相关页面里的内容串成一条线,回答"一个游戏引擎在运行时是怎样组织起来的":静态结构上是自下而上的分层,上层依赖下层;启动关闭按依赖顺序显式进行;每一帧由主循环驱动各子系统批量更新,游戏对象的逻辑被安排在子系统阶段之间,并按依赖分桶或设先后关系;多核上一帧被拆成成百上千个作业;玩法基础系统(对象模型、事件、脚本、世界加载流送、高层流程)位于引擎与游戏的分界线附近。各结论的细节与出处见所链接的概念页。综合帧时间预算与渲染性能Frame Budget & Rendering Performance本页把 wiki 中分散在 RTR4 第 18~23 章的性能知识串成一条线:一帧的时间预算由最慢的管线阶段决定,先用毫秒帧时间度量并定位瓶颈,再按瓶颈所在位置——CPU/驱动、几何、像素、带宽/内存——选用对应手段;最后从 GPU 硬件机制(占用率、HiZ/early-z、压缩、分块架构)理解这些手段为何有效。各结论的细节与出处见所链接的概念页。游戏开发游戏对象的实时更新Updating Game Objects in Real Time游戏对象更新就是根据上一时刻状态 Si(t − Δt) 求出当前状态 Si(t)。"每帧对每个对象调一次虚函数 Update(),并在里面顺带更新它的动画、物理、渲染组件"的简单做法在商业引擎里行不通:底层子系统需要批量更新才能高效,而对象之间、子系统之间又有依赖顺序。GEA 介绍的对策依次是:子系统由游戏循环直接批量更新、对象只操纵子系统中的"代理状态";在帧内多个阶段挂回调;按依赖森林把对象分桶,逐桶运行完整的分阶段更新(Naughty Dog 的做法);并用状态缓存和时间戳对付更新过程中的状态不一致和一帧延迟(GEA4 卷 II 17.6)。

输入关键词开始搜索

滚轮或双指缩放 · 拖动平移 · 双击放大 / 还原