中EN
概念页

线程同步原语

Thread Synchronization Primitives

并发编程的中心问题是数据竞争 (Data Race):多个控制流交错地读改写同一共享对象。解决思路是让同一对象上的关键操作具备原子性(看起来瞬间完成,从而被串行化)。最简单可靠的工具是操作系统提供的线程同步原语:互斥锁、临界区、条件变量、信号量、Windows 事件。它们好用但昂贵(需要内核调用,上下文切换可超过 1000 个时钟周期)。

并发编程模型(卷 I 4.5–4.5.2)

  • Rob Pike 把并发定义为「独立执行的计算的组合」。形式包括 Linux/Windows 的管道命令链、共享虚拟内存的多线程进程、GPU 上数千线程组成的线程组、多机共享游戏状态的多人游戏。
  • 写并发程序的理由:多个半独立控制流更贴合问题本身;或者为了利用多核,即便问题本身更适合顺序设计。
  • 线程间通信有两种基本方式:
方式说明取舍
消息传递 (message passing)通过网络、管道或内存中的消息队列传数据并同步;单机或跨机都适用能减轻(不能消除)同步问题
共享内存 (shared memory)多个线程直接访问同一块物理内存;同进程线程天然共享虚拟地址空间,不同进程可映射同一物理页共享大量数据最高效(不用复制),但带来难以推理的同步问题

两者可以互相模拟:消息传递之上可实现分布式共享内存,共享内存中放消息队列可实现消息传递。书中聚焦共享内存并发,因为游戏引擎通常是单进程多线程程序(联网多人游戏大量使用消息传递是例外),而且学会它之后消息传递相对容易。

竞态条件与数据竞争(卷 I 4.5.3)

  • 竞态条件 (race condition):程序行为依赖时序。无害的竞态只让行为略有不同;关键竞态 (critical race) 会导致错误行为:间歇性/随机崩溃、错误结果、数据结构损坏、切到调试构建就消失、消失几天又回来(「通常是 E3 前一晚」)、加 printf 调试就消失——俗称海森堡 bug (Heisenbug)。
  • 数据竞争是两个以上控制流读写共享数据时相互干扰导致数据损坏的关键竞态。消除它要么小心控制对共享数据的访问,要么用各自私有的数据副本替代共享数据,把并发问题变回顺序问题。
  • 例:++g_count 编译为 mov eax,[g_count] / inc eax / mov [g_count],eax,是读-改-写 (RMW) 操作。两个线程各调用一次,期望结果 2,但三种情况会得到 1:单核上 A 读完后被抢占、B 也读到同一值;双核上两次 RMW 错开一条指令重叠;或完全同步重叠。

关键操作与原子性(卷 I 4.5.4)

  • 只有对同一共享对象的操作被同一对象上的另一操作打断时,才会发生数据竞争(书中脚注:严格说要求两个对象位于不同缓存行)。能读或改某个共享对象的操作叫该对象的关键操作 (critical operation);关键操作彼此不可打断时,它就是原子操作。
  • 调用与响应:有限时长的操作无法排序,只能谈瞬时事件。每个操作分解为调用 (invocation) 与响应 (response) 两个瞬时事件,按程序顺序 (program order) 把代码分为前导段 (preamble)、临界段 (critical section)、后续段 (postamble)。
  • 原子性定义:如果一个对象上的关键操作,其调用与响应之间没有插入同一对象上另一关键操作的调用或响应,就称它原子地执行了。被非关键操作或其他对象的操作打断完全没关系。保证原子性的办法是让操作在其他线程看来是瞬间完成的。
  • 最简单的做法是用互斥锁:在调用前加锁、响应后解锁,操作就显得是瞬时的。
  • 原子性即串行化:原子化使同一时刻只有一个线程执行该操作,乱序重叠的操作变成某个顺序序列,但我们无法控制这个顺序。
  • 这属于以数据为中心的一致性模型 (data-centric consistency model):数据存储与使用它的线程之间的契约。保证原子性的数据存储称为可线性化的 (linearizable)。

同步原语总览(卷 I 4.6)

同步原语提供两类服务:(1) 让关键操作原子化,从而在线程间共享资源;(2) 同步多个线程——让线程睡眠等待资源或其他线程完成任务,并让运行中的线程唤醒睡眠线程。代价:它们由内核提供,每次交互都要内核调用,上下文切换可超过 1000 个时钟周期,所以有人自己实现原子性工具或改用无锁编程(见 无锁编程与内存序)。

互斥锁 (Mutex)(卷 I 4.6.1)

  • 两种状态:未锁/已锁(也叫 released/acquired、signaled/nonsignaled)。核心保证是同一时刻只有一个线程持有锁,因此被它包裹的操作互斥 (mutual exclusion)。
  • API:create()/init()、destroy()、lock()/acquire()(阻塞)、try_lock()/try_acquire()(非阻塞)、unlock()/release()(多数系统只允许加锁线程解锁)。解锁时若有线程在等,内核挑一个唤醒。Windows 可用 WaitForSingleObject()/WaitForMultipleObjects() 显式等待内核对象变为 signaled。
  • 三种写法:POSIX pthread_mutex_lock/unlock(需自行 pthread_mutex_init/destroy);C++11 std::mutex(构造/析构管理内核对象,更方便);Windows 用 WaitForSingleObject(g_hMutex, INFINITE) == WAIT_OBJECT_0 加锁、ReleaseMutex() 解锁。

临界区 (Critical Section)(卷 I 4.6.2)

  • 多数系统的互斥锁可跨进程共享,由内核管理,所以即使无人争用,每次操作也要内核调用。
  • Windows 的临界区本质是低成本互斥锁:InitializeCriticalSection、DeleteCriticalSection、EnterCriticalSection、TryEnterCriticalSection、LeaveCriticalSection。它先用廉价的自旋锁等待(无需进内核,比互斥锁便宜几千个周期),忙等太久才让线程睡眠。之所以能这样,是因为它不能跨进程。Linux 的 futex 作用类似。

条件变量 (Condition Variable)(卷 I 4.6.3)

  • 生产者-消费者问题中,用全局布尔 g_ready 加互斥锁轮询能工作,但消费者忙等浪费 CPU。
  • 条件变量并不存储条件,而是一个等待线程队列加唤醒机制(作者说叫「wait queue」更贴切),借助程序提供的互斥锁和内核原子地完成睡眠与唤醒。API:create/init、destroy、wait()(阻塞)、notify()(非阻塞)。
  • 用法:消费者先 pthread_mutex_lock,while (!g_ready) pthread_cond_wait(&g_cv, &g_mutex);;生产者设 g_ready = true 后 pthread_cond_signal(&g_cv)。
  • 两个要点:(1) 看起来持锁睡眠(通常会导致死锁),但内核在线程睡下后替它解锁,醒来时再替它重新持锁;(2) 仍要用 while 循环检查,因为内核可能虚假唤醒 (spurious wakeup)。

信号量 (Semaphore)(卷 I 4.6.4)

  • 互斥锁像原子布尔,信号量像永不小于 0 的原子计数器,可看作允许多个线程同时获取的互斥锁。例:HUD 和菜单的离屏缓冲因内存只能分配 4 个,用初值为 4 的信号量限制最多 4 个线程同时渲染。
  • API:init()(设初值)、destroy()、take()/wait()(大于 0 则减 1 返回,否则阻塞)、give()/post()/signal()(加 1 并唤醒等待者)。计数大于 0 为 signaled。有的文献用 p()/v(),来自荷兰语 proberen(测试)和 verhogen(增加)。
  • 互斥锁 vs 二值信号量:初值为 1 的二值信号量也只允许一个持有者,但关键区别是互斥锁只能由加锁线程解锁,信号量可以由一个线程 give、另一个线程 take。因此互斥锁用于原子化,二值信号量用于线程间发信号。例:生产者-消费者用 g_semUsed(初值 0)和 g_semFree(初值 1):生产者 sem_wait(&g_semFree) 后入队再 sem_post(&g_semUsed),消费者反之;生产与消费本地数据无需原子化。
  • 实现:信号量可以用一个互斥锁、一个条件变量和一个整数实现:Take() 加锁后 while (m_count == 0) pthread_cond_wait(...),再减 1;Give() 加锁、加 1,若计数从 0 变为 1 则 pthread_cond_signal。所以信号量是比互斥锁和条件变量更「高层」的构件。书中类还提供 Wait/Post/Signal/Down/Up/P/V 等别名。
    • 原书问题:书中 Give() 只在 m_count == 1(从 0 变 1)时才 pthread_cond_signal,存在丢失唤醒 (lost wakeup):两个线程在 Take() 中等待时,连续两次 Give() 只发出一次 signal(第二次时计数已是 2),被唤醒的线程取走一个后计数仍为 1,另一个等待者却一直睡着。正确写法是每次 Give() 都无条件 pthread_cond_signal(或在计数大于 0 时 signal);std::counting_semaphore(C++20)等标准实现也不依赖「仅在 0→1 时唤醒」。另外示例类缺少调用 pthread_mutex_destroy/pthread_cond_destroy 的析构函数。
  • 信号量计数大于 0 为 signaled、等于 0 为 nonsignaled,这正是某些 API 把 take/give 命名为 wait()/signal() 的原因。用初值 N 的信号量即可实现最多 N 个线程同时访问的资源池。

Windows 事件(卷 I 4.6.5)

功能类似条件变量但更简单:CreateEvent 创建,WaitForSingleObject() 睡眠等待,另一个线程 SetEvent() 唤醒。生产者-消费者可用 g_hUsed(初始 nonsignaled)与 g_hFree(初始 signaled)两个事件实现,结构与信号量版本相同;书中用 CreateEvent(nullptr, false, …) 创建的是自动重置 (auto-reset) 事件——等待成功后事件自动回到 nonsignaled,这正是它能像二值信号量一样乒乓工作的前提(手动重置事件需要显式 ResetEvent)。

原书问题:4.6.5 的示例代码不能按原样编译:类型应为 HANDLE 而非 Handle;WaitForSingleObject 需要两个参数(句柄与超时,如 WaitForSingleObject(g_hFree, INFINITE)),且应传句柄本身而不是 &g_hFree;SetEvent 同样应传 g_hUsed 而非 &g_hUsed;CreateThread 要求线程函数签名为 DWORD WINAPI f(LPVOID),而示例中是 POSIX 风格的 void* f(void*)。依据:Microsoft Learn 中 WaitForSingleObject、SetEvent、CreateThread/ThreadProc 的函数原型。

选择对照

原语本质典型用途
互斥锁内核对象,独占锁,只有持有者能解锁让关键操作原子化,可跨进程
临界区 / futex先自旋再睡眠的廉价锁进程内的短临界段
条件变量等待队列 + 互斥锁等待某个条件成立再继续
信号量非负原子计数器限制访问有限资源的线程数;二值形式用于线程间发信号
Windows 事件可等待、可设置的内核对象简单的线程间通知

与其他来源的关系

UE5 任务系统 官方文档认为互斥锁会阻塞线程、争用时代价大,建议用「管道 (Pipe)」串行访问共享资源、用依赖关系而非阻塞等待来组织任务。这与 GEA 的说法一致:同步原语可靠但昂贵(内核调用、上下文切换),任务化设计和无锁技术是降低这一成本的两条路。

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

相关页面

游戏开发并发与并行基础Concurrency & Parallelism FundamentalsGEA 区分两个正交的概念:并发 (Concurrency) 是软件层面多个控制流读写共享数据,核心难题是数据竞争;并行 (Parallelism) 是硬件层面两个以上部件同时工作。硬件并行又分为提升单线程速度的隐式并行(流水线、超标量、VLIW,即指令级并行 ILP)和为同时运行多条指令流而设计的显式并行(超线程、多核、多处理器、集群/云)。游戏开发进程、线程与纤程Processes, Threads & Fibers并发编程依赖操作系统提供的服务:内核态与用户态、中断、系统调用、抢占式多任务。进程只是为线程提供运行环境(虚拟地址空间与资源),线程才是操作系统的基本执行单位(调用栈 + 寄存器)。线程由内核抢占调度;纤程 (Fiber) 由程序协作调度,适合实现引擎任务系统;用户级线程与协程完全在用户空间切换上下文,更轻量。游戏开发 · UE5任务系统Tasks SystemUE::Tasks 是 UE5 的任务级并行库,是对 TaskGraph 的改进,与 TaskGraph 共用同一套调度器和工作线程。它支持启动任务、等待或取结果、声明前置依赖、嵌套任务、管道 (Pipe) 和任务事件。官方建议用依赖关系把工作连成图,而不是阻塞等待;5.5 起用「超额订阅」取代了容易死锁的忙等待。游戏开发多线程渲染与任务系统Multithreaded Rendering & Task SystemsCPU 时钟频率约在 2003 年停在 3.4 GHz 附近后,性能来自多核。应用阶段的优化一是让代码与内存访问更缓存友好,二是利用多核:多处理器流水线(时间并行,提高吞吐但增加延迟)、并行处理(空间并行,延迟更低)以及两者结合的任务系统;图形 API 用命令缓冲让多个线程并行录制绘制命令,新一代 API(DirectX 12、Vulkan、Metal)把更多责任交给应用以降低驱动开销。游戏开发基于锁的并发问题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 把它们封装成可移植接口。在此基础上可以写出自旋锁、可重入锁、读写锁,以及基于「本地准备 + 一次原子提交」事务的无锁数据结构。游戏开发容器与哈希表Containers & Hash Tables容器的职责都是容纳和管理零个或多个元素,差别在于实现方式与取舍。GEA 概览了常见容器与操作、迭代器、大 O 复杂度与内存布局的综合考量,讨论引擎为何常自研容器、C++ 标准库 / Boost / Folly / Loki 的利弊,并重点讲了动态数组的增长策略与哈希表(开放哈希与封闭哈希、哈希函数、线性/二次探测、Robin Hood 哈希)。游戏开发引擎文件系统与异步文件读写Engine File System & Asynchronous File IO资源管理器大量依赖文件系统。引擎通常把操作系统的文件 API 包装成自己的 API:一是跨平台、屏蔽硬件差异;二是操作系统缺少引擎需要的功能(如流式加载),主机还要统一访问记忆棒、硬盘、蓝光盘、网络文件系统等介质。引擎文件系统 API 通常涵盖:文件名与路径操作、打开/关闭/读写文件、扫描目录、处理异步 I/O 请求(流式加载)。游戏开发作业系统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)。

输入关键词开始搜索

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