中EN
概念页

并发与并行基础

Concurrency & Parallelism Fundamentals

GEA 区分两个正交的概念:并发 (Concurrency) 是软件层面多个控制流读写共享数据,核心难题是数据竞争;并行 (Parallelism) 是硬件层面两个以上部件同时工作。硬件并行又分为提升单线程速度的隐式并行(流水线、超标量、VLIW,即指令级并行 ILP)和为同时运行多条指令流而设计的显式并行(超线程、多核、多处理器、集群/云)。

并发:关键在共享数据(卷 I 4.1.1)

  • 并发软件用多个控制流解决一个问题,控制流可以是同一进程内的多个线程、单机或多机上协作的多个进程,也可以是纤程或协程。
  • 区分并发与顺序编程的主要因素是对共享数据的读和/或写。两个控制流各自处理完全独立的数据,严格说不算并发,只是「同时计算」。
  • 核心问题是协调多个读者/写者,保证结果可预测、正确。其中心是数据竞争 (data race):两个以上控制流争着「读-改-写」同一块共享数据。识别并消除数据竞争是并发问题的关键(详见 线程同步原语、无锁编程与内存序)。

并行的几种分类(卷 I 4.1.2–4.1.4)

  • 1989 年前的消费级处理器(6502、8086/80286/80386)都是串行的;如今并行无处不在,粒度从单 CPU 内的多个 ALU 到计算机集群。
  • 隐式 vs 显式:隐式并行在 CPU 内部用并行部件加速单条指令流(ILP);显式并行复制硬件以同时运行多条指令流,目的是让并发软件跑得更高效。GPU 也大量使用隐式并行。
  • 任务并行 vs 数据并行:在一个核上算动画、另一个核上做碰撞检测是任务并行(异构操作并行);把 1000 个蒙皮矩阵分成 4 份、每核算 250 个是数据并行(同一操作作用于多个数据)。真实程序两者都用。
  • 弗林分类 (Flynn's Taxonomy)(Michael J. Flynn,1966)按指令流数与数据流数分四象限。注意「单数据」对二元运算指一对输入和一个输出。
类别含义例子(乘法 a×b 与除法 c/d)
SISD单指令流单数据流一个 ALU 先乘后除
MIMD多指令流多数据流两个 ALU 各跑一条指令流;单 ALU 时间片轮流执行两条指令流也算 MIMD
SIMD单指令流多数据流一个向量处理单元 (VPU) 先乘后除,每条指令处理一对 4 元素向量
MISD多指令流单数据流两个 ALU 执行同一指令流,互为热备份,用于容错;游戏中很少用
SIMT单指令多线程(新增第五类)SIMD 与多线程(时间片共享处理器)的混合,NVIDIA 提出,可描述任何 GPU
  • 书中称 SIMT 设计(大量轻量 SIMD 核)为 manycore,与 CPU 的 multicore(较少的重量级通用核,MIMD)相对。GPU 的 SIMT 细节见 GPU 并行架构、GPGPU 编程入门。

并发与并行正交(卷 I 4.1.5)

并发软件不需要并行硬件:多线程程序可以靠抢占式多任务跑在单个串行核上;反过来,ILP 加速的是单线程,对顺序程序和并发程序都有益。只要系统里有共享数据对象的多个读者/写者,就是并发系统;无论靠时间片还是真并行实现,本章的技巧都适用。

隐式并行:流水线(卷 I 4.2.1–4.2.3)

每条指令都要经过取指 (Fetch)、译码 (Decode)、执行 (Execute)、访存 (Memory access)、写回 (Register write-back) 五个阶段,各阶段由 CPU 内不同电路负责(控制单元与内存控制器取指、CU 译码、ALU/FPU/VPU 执行、内存控制器访存、寄存器写回)。非流水线 CPU 里一个阶段工作时其余都闲着;流水线每个时钟周期发射一条新指令,让多条指令同时「在飞」,就像洗衣机和烘干机同时运转。

flowchart LR
  F["取指"] --> D["译码"] --> E["执行"] --> M["访存"] --> W["写回"]

上图是五级流水线;理想情况下第 n 个周期取指第 n 条指令,同时第 n-1 条在译码,以此类推。

  • 理论上 N 级流水线比串行快 N 倍,但指令间的依赖会打折扣,所以写高性能代码的程序员不能对 ILP 视而不见。
  • 延迟与吞吐:单条指令的延迟是各级延迟之和 Tpipeline=∑i=0N−1TiT_{pipeline}=\sum_{i=0}^{N-1}T_i;吞吐(每秒指令数)由最慢的一级决定 f=1max⁡(Ti)f=\frac{1}{\max(T_i)}。
  • 流水线深度:把慢的级拆开能平衡各级延迟、提高吞吐,但级数越多,总延迟越高,停顿代价越大。实际 CPU 在 4–5 级到约 30 级之间折中。

停顿与三类依赖(卷 I 4.2.4–4.2.6)

某周期无法发射新指令叫停顿 (stall),产生的空闲「气泡」逐级向后传播,也叫延迟槽 (delay slot)。三类依赖会导致停顿:数据依赖、控制依赖(分支依赖)、结构依赖(资源依赖)。

  • 数据依赖:imul eax,10 后紧跟 add eax,7,add 要等 imul 走完流水线,五级流水线浪费 4 个周期。
    • 指令重排:在依赖指令之间塞入无关指令填满气泡。现代优化编译器很擅长自动做;但仍应看反汇编确认,并遵循 80/20 规则只优化真正影响性能的代码。
    • 乱序执行 (Out-of-Order Execution):CPU 在「前瞻窗口」里分析寄存器使用,找不依赖在飞指令的指令提前发射。所以程序员不能指望 CPU 按书写顺序执行指令。编译器与 CPU 都保证单线程行为不变,但重排会让并发程序出错(见 无锁编程与内存序)。
  • 分支依赖:cmp 与 jz 之间的依赖,CPU 得等比较结果才能决定跳转。
    • 推测执行 / 分支预测:猜一个分支继续发射,猜错就冲刷流水线从正确分支重来,即分支惩罚。最简单的静态猜法是「永不跳转」(对指令缓存友好);稍好的是「向后跳转总跳、向前跳转不跳」(循环末尾是向后跳转)。高端 CPU 有能识别模式的分支预测硬件。PS3 的 Cell 分支预测器很差,而 PS4/Xbox One 的 AMD Jaguar 与 PS5/Xbox Series 的 Zen 2 都很先进。
    • 谓词化 (Predication):两条路径都算,用全 0/全 1 掩码选结果(也叫 select 操作)。书中例子 SafeFloatDivide_pred:mask = 0U - (unsigned)(b != 0.0f),用 memcpy 把浮点位模式转成无符号整数,(qu & mask) | (du & ~mask)。前提是两条路径都能安全执行:浮点除以零不会崩溃,整数除以零会抛异常导致崩溃(除非被捕获),所以例子改成浮点。书中称浮点除以零产生 QNaN(代码注释也写「b == 0.0f 时 q 为 QNaN」)——原书问题:按 IEEE 754,非零有限数除以 ±0 得到带符号的无穷大(并置除零标志),只有 0/0 才得到 QNaN;对本例无影响,因为 b == 0 时结果反正被掩码丢弃。
    • 取舍:这可能白费力气,x64 Clang 3.0.0 在 -O1 以上会看穿意图照样生成分支。谓词化只有在 ISA 提供 select 指令时才真正有用,如 PowerPC 的 isel、fsel、vecsel(PS3),在 PowerPC 平台上确实能带来性能提升。书中 Clang 3.0.0 的输出用 ucomiss + setp/setne + je 仍然分支。

超标量与 VLIW(卷 I 4.2.7–4.2.8)

  • 标量处理器每周期最多发射一条指令;超标量 (Superscalar) 复制流水线各级电路,每周期从同一指令流取并发射两条以上。书中脚注:流水线和超标量是两种独立的并行,但多数超标量 CPU 也是流水线的。
  • 超标量需要前端控制逻辑(通常带乱序与推测执行的调度器),并引入第三类依赖——资源依赖:例如两个整数 ALU 但只有一个 FPU 时,连续两条浮点指令不能同周期发射。
  • 双路超标量约需两倍芯片面积,为了腾出晶体管,多数超标量 CPU 是 RISC(指令少而专一),与 CISC(指令多且复杂)相对。
  • VLIW(超长指令字):去掉复杂调度逻辑,由程序员/编译器把多个操作编进一条指令字的多个「槽」,省下的晶体管用于更多计算单元或更大缓存;编译器能从整个函数范围挑指令,窗口比 CPU 前瞻大得多。例:PS2 的向量单元 VU0、VU1 每条指令有 low/high 两个槽,手写汇编很难填满,后来有工具帮助把「每周期一条指令」的程序转换为高效的每周期两条格式。取舍:硬件更简单、潜在并行度更高,但把串行程序改造成能吃满 VLIW 的形式很难;可变宽 VLIW 等改进缓解了部分限制。

显式并行(卷 I 4.3)

按粒度从细到粗:

形式要点
超线程 (Hyperthreading, HT)一个核有两套寄存器组和取指/译码前端,共享后端执行单元、乱序调度器和 L1;乱序调度器可从两条指令流挑指令填延迟槽。晶体管比双核少,但因争用共享资源,吞吐低于真双核
多核 (Multicore)核是能执行至少一条指令流的独立单元,每个核可以是上述任一设计(串行、流水线、超标量、VLIW 或超线程),同一片上多个核即多核 CPU;PS4、PS5、Xbox One、Xbox Series S/X 都是多核。PS4/Xbox One 的 APU 含两个四核 AMD Jaguar 模块(各共享 2 MiB L2),与 GPU、内存控制器、视频编解码器同片;8 核中 7 个可供游戏使用,第 7 核约一半带宽留给操作系统。书中还称 Xbox One X 的八核 APU 基于与 AMD 合作的专有技术而非 Jaguar(对照:Microsoft 在 Hot Chips 29 披露 Scorpio Engine 为两组四核、2.3 GHz、共 4 MiB L2 的定制 x86 核心;Digital Foundry《Inside the Scorpio Engine》(2017) 指出 Microsoft 不再称其为 Jaguar,但设计以 Jaguar 为起点,Tom's Hardware 的 Hot Chips 报道亦称其为 Jaguar 派生核心。因此「非 Jaguar」只在命名意义上成立,架构上是改进版 Jaguar)
对称多处理 (SMP)核同构、同 ISA、操作系统一视同仁,任何线程可调度到任何核(可设亲和性 affinity)。PS4、Xbox One 属此类
非对称多处理 (AMP)核不一定同构,主核运行操作系统并向从核分发任务。PS3 的 Cell:PowerPC 架构的 PPU 加 8 个指令集完全不同的 SPU
分布式计算集群、网格计算、云计算;书中只讨论单机内的并行
  • 书中 PS4 架构图:CPU 1.6 GHz,8 GiB GDDR5,CPU 总线 20 GiB/s,缓存一致的「Onion」总线(每方向 10 GiB/s)与非缓存一致的「Garlic」总线(176 GiB/s)连接 GPU(约相当于 Radeon 7870,800 MHz,1152 个流处理器)。Xbox One:CPU 1.75 GHz,8 GiB 主存(图中标 GDDR3;原书问题:Xbox One 实际使用 8 GiB DDR3(带宽约 68 GB/s),而非 GDDR3,见 Digital Foundry《Project Scorpio tech revealed》(2017) 规格对比表,Wikipedia「Xbox One」硬件规格同),CPU 与 GPU 间缓存一致的通路 30 GiB/s、非一致通路 68 GiB/s,32 MiB eSRAM(最高 204 GiB/s),GPU 约相当于 7790(853 MHz,768 个流处理器)。缓存层次与一致性见 计算机硬件与内存层次。

与其他来源的关系

多线程渲染与任务系统(RTR4)从渲染应用阶段角度讲「多处理器流水线(时间并行)」与「并行处理(空间并行)」;GEA 这里讲的是 CPU 内部的指令流水线与硬件分类。两者用词相近但层次不同。

  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 4 章 4.1–4.3 节

相关页面

游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。图形学GPU 并行架构GPU Parallel ArchitectureGPU 为吞吐量而设计:芯片大部分面积是成千上万个着色器核心,而不是缓存和控制逻辑。它靠在大量线程组 (warp/wavefront) 之间快速切换来隐藏访存延迟,用 SIMD 让一组线程锁步执行同一条指令。理解占用率和线程分歧,是写出高效着色器的关键。游戏开发构建配置与编译优化Build Configurations & Compiler OptimizationC++ 游戏代码从源文件到可执行文件要经过预处理、编译、链接。游戏项目通常不止 Debug/Release 两种构建配置 (build configuration),而是 Debug、Development、Ship,有时再加混合构建;配置越多测试越难。编译器优化分为局部优化和全局优化(链接时优化 LTO、剖析引导优化 PGO),它们让程序更快,但也让调试器“说谎”。所以要学会调试优化过的构建,并用统计式或插桩式性能分析器 (profiler) 找出最耗时的代码。游戏开发进程、线程与纤程Processes, Threads & Fibers并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。游戏开发线程同步原语Thread Synchronization Primitives并发编程的中心问题是数据竞争 (Data Race):多个控制流交错地读改写同一共享对象。解决思路是让同一对象上的关键操作具备原子性(看起来瞬间完成,从而被串行化)。最简单可靠的工具是操作系统提供的线程同步原语:互斥锁、临界区、条件变量、信号量、Windows 事件。它们好用但昂贵(需要内核调用,上下文切换可超过 1000 个时钟周期)。游戏开发基于锁的并发问题Problems with Lock-Based Concurrency即使所有共享数据操作都用锁保护好了,基于锁的并发仍有四类问题:死锁 (Deadlock)、活锁 (Livelock)、饥饿 (Starvation)、优先级反转 (Priority Inversion)。哲学家就餐问题集中体现了它们。GEA 由此总结出经验法则:全局排序、事务式算法、尽量减少争用;并反对把所有接口都做成「100% 线程安全」,主张正视并发、少用锁。游戏开发无锁编程与内存序Lock-Free Programming & Memory Ordering「无锁 (lock-free)」的真正含义不是不用互斥锁,而是从不让线程阻塞(作者说叫 block-free 更贴切)。数据竞争有三个来源:关键操作被打断、编译器与 CPU 的指令重排、缓存一致性协议优化造成的内存序问题。对应的工具是原子指令(TAS、XCHG、CAS、LL/SC)和内存屏障/栅栏(acquire、release、full fence),C++11 用 std::atomic 与 std::memoryorder 把它们封装成可移植接口。在此基础上可以写出自旋锁、可重入锁、读写锁,以及基于「本地准备 + 一次原子提交」事务的无锁数据结构。游戏开发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) 代替。图形学GPGPU 编程入门Introduction to GPGPU ProgrammingGEA 把 GPGPU 讲成「SIMD 向量化推到极致」:GPU 结合 SIMD(向量化 ALU)与 MIMD(一种抢占式多线程),即 NVIDIA 所说的 SIMT。适合 GPU 的任务必须是数据并行的——每个元素的计算不依赖其他元素的结果。写法与 CPU 向量化相反:不把循环硬编码成每次 4/8/16 个,而是保留单通道的循环体,抽成计算核函数 (compute kernel),由 GPU 替我们「跑循环」。一个「GPU 线程」其实就是一条 SIMD 通道。游戏开发容器与哈希表Containers & Hash Tables容器的职责都是容纳和管理零个或多个元素,差别在于实现方式与取舍。GEA 概览了常见容器与操作、迭代器、大 O 复杂度与内存布局的综合考量,讨论引擎为何常自研容器、C++ 标准库 / Boost / Folly / Loki 的利弊,并重点讲了动态数组的增长策略与哈希表(开放哈希与封闭哈希、哈希函数、线性/二次探测、Robin Hood 哈希)。游戏开发作业系统Job System多核硬件上,游戏循环的工作要分解成可并行执行的子任务。GEA 依次比较「每子系统一个线程」「分散/汇集 (scatter/gather)」和通用的作业系统 (job system),并给出典型作业 API、基于线程池的简单实现及其局限、用协程或纤程让作业可以睡眠、作业计数器、作业同步原语与可视化剖析工具,最后介绍 Naughty Dog 基于纤程的作业系统。(卷 I 8.6 节)游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。

输入关键词开始搜索

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