中EN
概念页

无锁编程与内存序

Lock-Free Programming & Memory Ordering

「无锁 (lock-free)」的真正含义不是不用互斥锁,而是从不让线程阻塞(作者说叫 block-free 更贴切)。数据竞争有三个来源:关键操作被打断、编译器与 CPU 的指令重排、缓存一致性协议优化造成的内存序问题。对应的工具是原子指令(TAS、XCHG、CAS、LL/SC)和内存屏障/栅栏(acquire、release、full fence),C++11 用 std::atomic 与 std::memory_order 把它们封装成可移植接口。在此基础上可以写出自旋锁、可重入锁、读写锁,以及基于「本地准备 + 一次原子提交」事务的无锁数据结构。

非阻塞算法的进展保证(卷 I 4.9)

按保证由弱到强:

类别保证备注
阻塞 (blocking)线程可能睡眠等资源易死锁、活锁、饥饿、优先级反转
无阻碍 (obstruction-free)其他线程全部挂起时,单个线程总能在有限步内完成(solo-terminating)用互斥锁或自旋锁的算法都不是:持锁线程被挂起,单独运行的线程会永远卡住
无锁 (lock-free)任意无限长运行中会完成无限多操作,即任一线程被挂起时其他线程仍能前进通常基于事务,失败回滚重试;避免死锁但可能让某些线程饥饿
无等待 (wait-free)无锁且无饥饿,所有线程都能前进

「无锁编程」常被宽泛地指任何不阻塞的算法,严格的统称是非阻塞算法 (non-blocking algorithm),这仍是开放的研究领域。

数据竞争的三个来源(卷 I 4.9.1)

  1. 一个关键操作被同一对象的另一个关键操作打断(抢占或多核);
  2. 编译器的指令重排优化与 CPU 的乱序执行(只保证单线程可观察行为不变,见 并发与并行基础);
  3. 内存控制器的激进优化使读写效果相对延迟,书中称为内存序 bug (memory ordering bug)。

要保证原子性,三者都必须排除。

实现原子性(卷 I 4.9.2)

  • 关中断(x86 cli)只影响执行它的那个核,只在单核抢占式系统有效,实用性有限。
  • 原子读写:多数 CPU 上 4 字节对齐的 32 位整数读写是原子的;有的 CPU 对按自身大小对齐的字节或 64 位整数也是。原因是不超过寄存器宽度(有时是缓存行宽度)的对齐访问在一个内存周期内完成,而时钟驱动的内存周期不可打断,连其他核也打断不了。未对齐访问通常由两次对齐访问拼成,不能假定原子(对齐见 C++ 内存布局与对齐)。依赖前务必查 ISA 文档。
  • 原子读-改-写 (RMW):实现互斥锁必需。x86 的 lock 前缀能让几乎任何指令原子执行。
指令语义要点
测试并置位 (TAS)原子地把布尔设为 true 并返回旧值可实现自旋锁 while (_tas(pLock)) PAUSE();;VS 内建函数为 _interlockedbittestandset()
交换 (XCHG)原子交换寄存器与内存x86 上寄存器与内存交换默认带 lock 语义;VS _InterlockedExchange8。带下划线的 interlocked 系列是编译器内建函数,直接生成指令;书中称 Windows SDK 中不带下划线的同名函数「要进内核,贵得多」(见下方原书问题)
比较并交换 (CAS)当且仅当内存值等于期望值时才写入新值,返回是否成功通用策略:读旧值→修改→用 CAS 写→失败则重试。x86 为 cmpxchg,VS _InterlockedCompareExchange()
LL/SC(载入链接/条件存储)LL 读值并把地址存入链接寄存器;SC 仅当链接寄存器仍匹配时写入总线上任何写都会清空链接寄存器,可能虚假失败,只是多转几圈,不影响正确性(这是书中的简化模型:实际 ARM 等实现用「独占监视器 (exclusive monitor)」跟踪一个地址粒度区间,上下文切换、缓存行被逐出等也会让 SC 失败)

原书问题(4.9.2.6):(1) 书中称 Windows SDK 的 InterlockedExchange 等无下划线函数「涉及内核调用、贵得多」。据 Microsoft Learn 的 InterlockedExchange 等页面,这些函数在可能时就是用编译器内建函数实现的(winnt.h 中直接映射到 _InterlockedExchange 等),在用户态执行一条带 lock 语义的指令,并不进入内核;二者开销基本相同。(2) 示例 _InterlockedExchange8(old, pLock); 参数顺序和用法不对:原型是 char _InterlockedExchange8(char volatile* Target, char Value),返回旧值,应写成 old = _InterlockedExchange8((char volatile*)pLock, 1);。另外书中 TAS/XCHG/CAS 自旋锁示例本身都声明为「缺少内存栅栏、仅作示意」,完整版本见 4.9.7。

  • CAS 实现自旋锁的示例把锁值设为 -1(0xFFFFFFFF),while (!_cas(pValue, 0, kLockValue)) PAUSE();;原子自增则是「读旧值 → +1 → CAS,失败重试」。
  • ABA 问题:CAS 读到 A,提交前另一线程先写 B 再写回 A,CAS 无法察觉发生过竞争。
  • LL/SC 相对 CAS 的优点:不受 ABA 影响;更适合流水线——CAS 需要两个访存阶段(读比较、写),LL 与 SC 各只需一个。
  • 强/弱 compare-exchange:C++11 的 compare-exchange 在不同硬件上可能由 CAS 或 LL/SC 实现;strong 版本对程序员隐藏 SC 的虚假失败,weak 版本不隐藏(理由见 Lawrence Crowl 的论文)。
  • 相对强度:在多线程共识 (consensus) 问题上,TAS 只操作布尔值,只能解决两个线程的无等待共识;CAS 操作 32 位值,能解决任意多线程的共识。这主要与容错系统相关。

屏障:防止指令重排(卷 I 4.9.3)

  • 例:生产者 g_data = 42; g_ready = 1;,消费者 while (!g_ready) PAUSE(); ASSERT(g_data == 42);。即便 32 位读写都是原子的,编译器或 CPU 也可能把 g_ready = 1 提到 g_data = 42 之前;理论上编译器还可能把消费者对 g_data == 42 的检查提到 while 循环之前。重排发生在汇编层面,例如 A = B + 1; B = 0; 可被重排为先写 B 再写 A(例子来自 Jeff Preshing 的博客)。
  • C/C++ 的 volatile 没用:它为内存映射 I/O 和信号处理设计,只保证变量不被缓存在寄存器、每次从内存重读;不阻止重排是标准行为(部分编译器在特定平台或选项下才保证);也挡不住 CPU 乱序和缓存一致性问题。Java 和 C# 的 volatile 则保证原子性(见下文)。
  • 编译器屏障:GCC 用 asm volatile("" ::: "memory"),MSVC 用 _ReadWriteBarrier()。多数函数调用是隐式编译器屏障,因为编译器看不到副作用;但仅当编译器看不到函数定义时成立(例如定义在另一个翻译单元),链接时优化 (LTO) 会消除这类隐式屏障从而引入并发 bug;内联函数也可能例外。编译器屏障挡不住 CPU 运行时乱序,有的 ISA 有专门指令(PowerPC isync)。

内存序语义(卷 I 4.9.4)

  • 只在多核 + 多级缓存的机器上出现:单核总按执行顺序看到自己的读写,多核之间可能对读写的表观顺序产生分歧,而且不同 CPU 行为不同。每种 CPU 都有自己的内存序语义规则,掌握最弱语义下的技巧就能在更强的 CPU 上通用。
  • 缓存回顾:写操作更新 L1 中的副本,写回主存通常推迟(直写缓存可忽略)。
  • MESI 协议:每个缓存行处于 Modified(本地已改)、Exclusive(仅本核有)、Shared(多核有相同副本)、Invalid(失效) 之一;各核 L1 通过互连总线 (ICB) 相连,L1、更高级缓存与主存构成缓存一致性域。MOESI 多一个 Owned 状态,允许不先写回主存就共享已修改数据。示例:核 1 读 g_ready(E)→核 2 读,核 1 回应(双方 S)→核 1 写 1(本核 M,发 Invalidate,核 2 变 I)→核 2 再读时从核 1 取得新行(双方 S,并触发写回主存)。缓存结构见 计算机硬件与内存层次。
stateDiagram-v2
  [*] --> E: 核1首次读
  E --> S: 核2读同一行
  S --> M: 核1写(核2副本变为I)
  M --> S: 核2再读,从核1取得并写回主存

上图是书中例子里核 1 那一份缓存行的状态变化。

  • MESI 怎么出错:为降低延迟,协议的某些操作被推迟执行。例如核 1 已有 g_ready 所在的缓存行、还没有 g_data 的,g_ready = 1 就可能先于 g_data = 42 对其他核可见。结论:缓存一致性协议的优化会让两条读写指令在其他核看来以与实际执行相反的顺序发生。
  • 内存栅栏 (memory fence / memory barrier):一条指令「越过 (pass)」另一条有四种情况:读越读、读越写、写越写、写越读。理论上每种都可以有双向、正向、反向栅栏,共 12 种;实际 ISA 只提供几种组合。最强的是全栅栏 (full fence),双向阻止读写越过,但硬件代价高。所有栅栏都有两个副作用:充当编译器屏障,并阻止 CPU 乱序跨越栅栏。所以原子指令 + 内存栅栏就足以实现可靠的互斥锁、自旋锁和其他无锁算法。
  • 三种语义(内存序语义其实是读写指令的属性,栅栏只是赋予它们语义的手段):
    • 释放 (release):程序顺序中在它之前的读写不能越过这次共享写(write-release),只管正向。
    • 获取 (acquire):程序顺序中在它之后的读写不能越过这次共享读(read-acquire),只管反向。
    • 全栅栏:双向。
  • 何时用:生产者连续两次写(先 g_data 后 g_ready),把第二次写变成 write-release,在它之前放 release 栅栏(核等待之前的写全部提交到一致性域);消费者先读标志再读数据,把第一次读变成 read-acquire,在它之后放 acquire 栅栏(核等待其他核的写全部刷入一致性域)。即 g_data = 42; RELEASE_FENCE(); g_ready = 1; 与 while (!g_ready) PAUSE(); ACQUIRE_FENCE(); ASSERT(g_data == 42);。
sequenceDiagram
  participant P as 生产者(核1)
  participant C as 消费者(核2)
  P->>P: g_data = 42
  P->>P: release 栅栏
  P->>C: g_ready = 1(write-release)
  C->>C: 读到 g_ready(read-acquire)
  C->>C: acquire 栅栏
  C->>C: 读 g_data,保证为 42

上图说明 release 与 acquire 成对使用时,消费者看到标志后一定能看到数据。

  • CPU 内存模型:DEC Alpha 默认语义极弱,几乎处处要加栅栏;Intel x86 默认很强。

原书问题(4.9.4.9):书中称 x86 的 sfence 提供 release 语义、lfence 提供 acquire 语义。按 Intel SDM 第 3 卷「Memory Ordering」一节,x86(WB 内存)本身已保证普通读具有 acquire 语义、普通写具有 release 语义,唯一允许的重排是「后面的读越过前面写到不同地址的写」(StoreLoad),只有 mfence 或带 lock 前缀的指令能阻止它。sfence 主要用于给非临时 (non-temporal) 存储和写合并内存排序,lfence 是读顺序/指令派发序列化屏障(常用于 Spectre 缓解);在普通代码中实现 acquire/release 不需要它们,编译器对 memory_order_acquire/release 也只生成普通 mov。

ISA栅栏指令
Intel x86书中:sfence(release)、lfence(acquire)、mfence(全栅栏),见下方原书问题;lock 前缀使指令原子化并在执行前提供栅栏。默认强序,许多情况无需栅栏,但仍有需要的情况(见 Bartosz Milewski 的文章)
PowerPC弱序,通常需要显式栅栏:sync(全栅栏)、lwsync(轻量)、eieio(I/O 顺序)、isync(纯指令重排屏障,无内存序语义)
ARMisb(纯指令重排屏障)、dmb/dsb(全栅栏)、ldar(单向 read-acquire)、stlr(单向 write-release);ARM 把 acquire/release 语义并入读写指令本身

原子变量与 C++ 内存序(卷 I 4.9.5–4.9.6)

  • C++11 的 std::atomic<T> 几乎能把任何类型变成原子变量(std::atomic_flag 是原子布尔),默认提供全栅栏语义,从而同时排除三种数据竞争来源。把生产者-消费者例子的变量换成 std::atomic 即可变成无竞争程序。
  • 实现按大小特化:32/64 位类型可直接用原子指令无锁实现,更大的类型内部用互斥锁;可调用 is_lock_free() 检查。
  • std::memory_order 选项(作者的解释):
选项含义
relaxed只保证原子性,不用任何栅栏
consume书中:同线程内其他读写不能重排到此读之前;只防编译器与 CPU 重排,不管一致性域中的内存序(典型实现如 PowerPC isync)——见下方原书问题
release同线程其他读写不能重排到此写之后,且此写对读同一地址的其他线程可见;使用 release 栅栏
acquire包含 consume,并保证其他线程对同一地址的写对本线程可见;使用 acquire 栅栏
acquire/release书中称为默认且最安全,施加全栅栏
  • 原书问题(consume):按 C++ 标准,memory_order_consume 是「依赖序 (dependency-ordered)」的 acquire:只保证数据依赖于该读结果的后续操作能看到对方 release 之前的写,并不是「只防指令重排、不管一致性」;实际上所有主流编译器都把它直接提升为 acquire,P0371R1 起标准不鼓励使用,C++26 经 P3475R2 已将其弃用。
  • 原书问题(示例代码):4.9.5 示例 std::atomic_flag g_ready = false; 及之后的 g_ready = true、while (!g_ready) 无法编译——atomic_flag 没有从 bool 的构造/赋值和到 bool 的转换,只能用 test_and_set()/clear()(C++20 起还有 test()),初始化在 C++20 前需用 ATOMIC_FLAG_INIT;应改用 std::atomic<bool>(书中 4.9.5.1 的第二个示例正是这么做的)。同理,4.9.7.1 SpinLock 构造函数里的 m_atomic(false) 也不合法,C++20 起默认构造即为 clear 状态,之前应写 m_atomic = ATOMIC_FLAG_INIT。
  • 注意(一般知识,非本书内容):C++ 标准中 std::atomic 操作的默认内存序是 memory_order_seq_cst(顺序一致),memory_order_acq_rel 是另一个独立选项;书中把「acquire/release」说成默认值,与标准的表述不同,这里两说并列,以 C++ 标准文本为准。
  • 指定的内存序只是下限,某些硬件上会更强:例如 x86 上 relaxed 读实际具有 acquire 语义。使用时要从重载运算符改为显式 store()/load(),如 g_ready.store(true, std::memory_order_release) 与 g_ready.load(std::memory_order_acquire)。
  • 作者建议遵循 80/20 规则:宽松内存序很容易用错,只在性能分析证明确有必要、且改动确实带来预期收益时才使用非默认内存序。
  • 解释型语言:Java(JVM)和 C#(CLR)在虚拟机中运行,VM 像软件模拟的 CPU 和内核,自己管理线程调度,因此能提供不受硬件约束的同步设施。例如它们的 volatile 保证原子性,读写实际上直达主存。
    • 原书问题:这种说法不准确。按 Java 语言规范(JLS §17.4、§17.7)与 C# 规范,volatile 保证的是对该变量单次读或写的原子性(Java 中包括 long/double)以及可见性和顺序(Java 中 volatile 读写是顺序一致的同步动作;C# 中是 acquire/release),但 v++ 这类读-改-写不是原子的,需要 AtomicInteger/Interlocked。读写也并不绕过 CPU 缓存——可见性靠的是缓存一致性加 JIT 插入的栅栏。

自旋锁及其变体(卷 I 4.9.7)

  • 基本自旋锁:用 std::atomic_flag,TryAcquire() 调 test_and_set(std::memory_order_acquire),Acquire() 循环 TryAcquire() 并 PAUSE()(不支持 pause 的平台、预期争用高时可改用 std::this_thread::yield()),Release() 调 clear(std::memory_order_release)。获取用 acquire 是为了防止在别的线程尚未完全离开临界段时就观察到锁已释放;释放用 release 是为了保证解锁前的所有写不会被看成发生在解锁之后。
  • 作用域锁 (Scoped Lock):构造时加锁、析构时解锁,适用于任何提供 Acquire()/Release() 的锁,函数有多个返回点时尤其有用(即 错误处理与断言 中提到的 janitor/RAII 思路)。
  • 可重入锁:普通自旋锁中,同一线程重复获取会死锁(例如持锁的 B() 调用同样加锁的 A())。做法是在原子变量里存持有者线程 ID 的哈希,并加引用计数保证 Acquire/Release 成对:已持有就直接加计数;否则用 compare_exchange_weak(0 → tid) 自旋;计数归零时存 0 解锁。CAS 用 relaxed,另用 std::atomic_thread_fence 在获取后加 acquire 栅栏、释放前加 release 栅栏。
  • 读写锁 (readers-writer lock)(又称 shared-exclusive lock、push lock):允许多个读者并发,写者等所有读者结束后以独占模式获取。实现上在原子变量里存读者引用计数,保留一个值表示写者持有,例如 0–0x7FFFFFFF 表示读者数,0x80000000 表示写者。缺点是饥饿:写者持锁太久会饿死读者,读者太多会饿死写者。替代方案有顺序锁 (sequential lock),以及 Linux 内核中支持多读多写的 RCU(read-copy-update)。书的网站上有完整实现。
  • 「无需加锁」断言 (lock-not-needed assertion):游戏循环通常分阶段执行,若某共享数据在帧前段只被一个线程访问、后段又只被一个线程访问,按结构这两者不会重叠,理论上不需要锁。与其「以防万一加锁」或「干脆不管」,作者更倾向断言不需要锁:开销极低、发布前可剥离,还能在假设不成立或日后重构破坏假设时自动报警。检测不必 100% 可靠,90% 命中率就够,因为团队每天大量运行游戏总会有人触发。因此不用原子布尔(那和无争用自旋锁一样贵),而用 volatile bool:Acquire() 中 assert(!m_locked); m_locked = true;,Release() 中 assert(m_locked); m_locked = false;,再用 BEGIN/END_ASSERT_LOCK_NOT_NECESSARY 宏或 janitor 包装,在 ASSERTIONS_ENABLED 关闭时编译为空。作者说 Naughty Dog 实现了它,确实抓到了多处程序员以为不会重叠的关键操作重叠。

无锁事务与无锁链表(卷 I 4.9.8–4.9.9)

  • 从自旋锁本身的实现看,写自旋锁就是无锁编程的一个例子。无锁编程的目标是既不让线程睡眠,也不让它困在自旋锁的忙等循环里。
  • 事务思路:大部分工作在只有本线程可见的本地数据上完成,准备好后用一条原子指令(CAS 或 LL/SC)提交,成功就「发布」到共享数据结构;失败说明别的线程同时提交成功了,于是重试。因为一个线程失败必然意味着另一个成功,系统总有线程在前进,这正是无锁的定义。
  • 无锁单链表 push_front()(例子来自 Herb Sutter 在 CppCon 2014 的演讲):新建节点、填数据,pNode->m_pNext = m_head.load(relaxed);然后循环 m_head.compare_exchange_weak(pNode->m_pNext, pNode, std::memory_order_release, std::memory_order_relaxed),成功用 release,失败用 relaxed。失败时 compare_exchange_weak 会自动把 m_pNext 更新为当前头节点,所以代码里看不到显式重新初始化。
flowchart TD
  A["本地创建节点并填数据"] --> B["next 指向当前头节点"]
  B --> C{"CAS: 头指针仍等于 next?"}
  C -- "是" --> D["头指针换成新节点,提交成功"]
  C -- "否,next 被更新为新头" --> C

上图是这个两阶段过程:准备事务,然后用 CAS 原子提交,失败就重试。

延伸阅读(卷 I 4.9.10)

书中推荐:Herb Sutter 的 CppCon 2014 演讲、CMU Geoff Langdale 的讲座、Samy Al Bahra 的并发综述演讲、Mike Acton 关于并发思维的演讲、Jeff Preshing 的博客,以及 Linux 内存屏障资料。

PBRT 的视角:CAS、原子浮点与 MESI(PBRT 4e B.6.1–B.6.3)

  • compare-and-swap:书中示例用 compare_exchange_weak() 循环求多线程最大值;失败时 currentMax 会被更新为最新值再重试。无锁建树:线程在局部完整构造子节点,再用 CAS 把空指针替换为它;失败说明别的线程已抢先,删除自己的节点、改用对方的即可。
  • 原子浮点:pbrt 的 AtomicFloat 把浮点数位模式存在 std::atomic 整数中,用 CAS 循环实现加法(详见 数值表示与浮点数 中关于 C++20 的观点对照)。
  • MESI 与伪共享:写一个在别的缓存中处于 shared/modified/exclusive 状态的缓存行,需要发出 RFO (read for ownership) 使其他副本失效,可能停顿几十到上百个周期。pbrt 对渲染时会被修改的类使用 alignas(PBRT_L1_CACHE_LINE_SIZE),让它独占缓存行,避免伪共享 (false sharing)。
  • 内存屏障:编译器和硬件都可能重排写操作,单线程察觉不到,多线程则需要屏障;C++ 原子操作和同步原语通常已包含它们(Boehm 2005 指出线程无法仅以库的形式可靠实现,C++11/C11 解决了这一点)。
  1. Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
  2. Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 4 章 4.9 节
  3. PBRT 4e source excerpt: chapter B (Utilities)
  4. Pharr, Jakob, Humphreys《Physically Based Rendering: From Theory to Implementation》第 4 版(MIT Press, 2023)附录 B B.2–B.7 节与延伸阅读,pbr-book.org/4ed/Utilities

相关页面

游戏开发线程同步原语Thread Synchronization Primitives并发编程的中心问题是数据竞争 (Data Race):多个控制流交错地读改写同一共享对象。解决思路是让同一对象上的关键操作具备原子性(看起来瞬间完成,从而被串行化)。最简单可靠的工具是操作系统提供的线程同步原语:互斥锁、临界区、条件变量、信号量、Windows 事件。它们好用但昂贵(需要内核调用,上下文切换可超过 1000 个时钟周期)。游戏开发基于锁的并发问题Problems with Lock-Based Concurrency即使所有共享数据操作都用锁保护好了,基于锁的并发仍有四类问题:死锁 (Deadlock)、活锁 (Livelock)、饥饿 (Starvation)、优先级反转 (Priority Inversion)。哲学家就餐问题集中体现了它们。GEA 由此总结出经验法则:全局排序、事务式算法、尽量减少争用;并反对把所有接口都做成「100% 线程安全」,主张正视并发、少用锁。游戏开发并发与并行基础Concurrency & Parallelism FundamentalsGEA 区分两个正交的概念:并发 (Concurrency) 是软件层面多个控制流读写共享数据,核心难题是数据竞争;并行 (Parallelism) 是硬件层面两个以上部件同时工作。硬件并行又分为提升单线程速度的隐式并行(流水线、超标量、VLIW,即指令级并行 ILP)和为同时运行多条指令流而设计的显式并行(超线程、多核、多处理器、集群/云)。游戏开发计算机硬件与内存层次Computer Hardware & Memory Hierarchy从冯·诺依曼结构讲起,依次是 CPU 的组成(ALU、VPU、寄存器、控制单元、时钟)、总线与字长、机器语言、汇编和寻址方式,再到内存体系:内存映射、虚拟内存和 TLB,以及为了缩小“内存鸿沟”而设计的寄存器文件、多级缓存(缓存行、标签、组相联、替换和写策略、一致性协议)和 NUMA(PS3 的 SPU 本地存储、PS2 的 scratchpad)。对游戏程序员最重要的结论是:数据要连续、紧凑、顺序访问;热循环的代码要小。游戏开发错误处理与断言Error Handling & AssertionsGEA 把错误分为用户错误和程序员错误,两者处理方式相反:用户错误要尽量平稳地告知并继续,程序员错误应立即停机并给出详细的底层信息。“用户”又分玩家和制作游戏的开发者。实现手段有错误返回码、异常和断言。Naughty Dog、EA、Midway、Insomniac 等在运行时代码中通常关闭 C++ 异常,靠断言和静态断言 (staticassert) 捕获 bug,并用 RAII(Naughty Dog 称为 janitor)管理资源。游戏开发C++ 内存布局与对齐C++ Memory Layout & Alignment理解 C/C++ 程序在内存中的样子,是写高性能引擎代码的前提。主要内容包括:声明与定义、内部与外部链接;可执行映像的几个段(text、data、BSS、rodata)以及栈和堆;结构体成员的自然对齐和填充;继承和虚函数如何改变类的布局(vpointer 和 vtable)。GEA 也简要回顾了面向对象:多重继承容易产生致命菱形,所以通常只允许 mix-in;组合往往比继承更好。游戏开发音频引擎架构Audio Engine Architecture音频渲染软件和多数系统一样是分层的"栈":硬件 → 驱动 → 底层 API → 3D 音频引擎,功能集通常模仿录音棚的多通道调音台。每个 2D/3D 声音是一条语音 (voice),沿语音总线经过编解码、增益、预发送滤波、辅助发送分出干湿两路、混响、后发送滤波和声像,然后由主混音器合成多声道信号,再经主输出总线(预放、LFE 生成、EQ、压缩、主音量)送到扬声器。资产上分片段、音效提示 (cue)、声音库和流式声音;混音靠分组、闪避、总线预设与混音快照、实例限制和语音抢占(GEA4 卷 II 15.5)。游戏开发游戏对象并发更新Concurrent Game Object Updates游戏对象模型要和几乎必然是多线程的底层子系统打交道,所以对象代码必须异步思考:耗时操作用非阻塞请求尽早发起、尽晚取结果,必要时容忍一帧延迟。作业之间的依赖决定了并行度 (DOP),每个依赖都引入同步点,"优化并发设计的秘诀是延迟"。对象模型本身也能并行:Naughty Dog 在把引擎从 PS3 移植到 PS4(《最后生还者 重制版》)时,让每个桶内的对象更新成为作业;桶间访问无需加锁,桶内读取用对象快照,桶内修改尽量避免,或加锁、或放进请求队列、或交给下一桶中的同步作业(GEA4 卷 II 17.7)。游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。游戏数学数值表示与浮点数Numeric Representation & Floating Point游戏引擎里到处是数值,所以要清楚它们在机器中如何存储:二进制和十六进制、补码整数、定点数、IEEE-754 浮点数(量级与精度此消彼长、次正规数、机器 epsilon、ULP),以及多字节值的大小端问题和安全的类型双关 (type punning) 方法。书中一个直观的例子是:用 32 位浮点数记录以秒为单位的游戏时钟,大约 12.14 天后,加 1/30 秒就不再改变它的值。

输入关键词开始搜索

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