线程同步原语
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++11std::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 的说法一致:同步原语可靠但昂贵(内核调用、上下文切换),任务化设计和无锁技术是降低这一成本的两条路。
出处
- Game_Engine_Architecture_4Ed_-_Jason_Gregory.pdf
- Jason Gregory《Game Engine Architecture》第 4 版(2026)卷 I 第 4 章 4.5–4.6 节