CppCon 2014 学习:HOW UBISOFT MONTREAL DEVELOPS GAMES FOR MULTICORE
多核处理器(Multicore Processor) 的基本特性,下面是对每点的简要说明:
🔹 Multicore(多核)
指一个物理处理器上集成了 多个 CPU 核心,每个核心可以独立执行指令。
🔸 特性解析
-
Same instruction set(相同的指令集)
- 每个核心都执行相同的指令集架构(如 x86、ARM),因此编译后的程序可以在任何核心上运行,无需修改。
-
Same address space(共享地址空间)
- 所有核心共享同一个内存地址空间(虚拟或物理),多个线程/任务可以访问相同的数据,这有利于并发计算,但也带来同步问题。
-
Existing threads can run on any core(线程可在任意核心上调度)
- 操作系统的调度器可以将线程动态分配到任意可用的核心上执行,从而提高并发性能。
- 注意:这也意味着如果线程访问共享数据,必须考虑并发控制(如 mutex、atomic)。
在多核处理器上发挥并行性能 的三种常见线程模式(Threading Patterns)。下面是对每种模式的简明解释:
🔹 1. Pipelining Work(流水线式工作)
-
概念:将一项工作拆分为多个阶段,每个阶段由一个线程处理,像工厂生产线一样。
-
关键点:
- 各线程之间通过队列(如
std::queue
)传递数据。 - 每个线程处理某个阶段的任务,接力完成整个工作流。
- 各线程之间通过队列(如
-
优势:提高吞吐量(多个数据项可以同时处在不同处理阶段)。
-
适合场景:图像处理、数据解析、压缩/解压缩等。
🔹 2. Dedicated Threads(专用线程)
-
概念:为不同的任务类型或资源分配专门线程,线程长期保持运行。
-
关键点:
- 每个线程只负责一类职责,如日志、IO、网络、UI 等。
- 减少上下文切换,提高响应性。
-
优势:稳定、延迟低、便于管理状态。
-
适合场景:服务端程序、游戏引擎、实时系统等。
🔹 3. Task Schedulers(任务调度器)
-
概念:将大任务切分成多个小任务(task),由调度器动态分配到多个线程池中的线程上运行。
-
关键点:
- 使用工作窃取(work stealing)或工作共享(work sharing)策略来负载均衡。
- 标准库中如
std::async
、std::thread
配合线程池、TBB(Threading Building Blocks)、OpenMP 都用到了这个思想。
-
优势:弹性好、可伸缩,适合 CPU 密集型并行任务。
-
适合场景:并行排序、图像渲染、大数据处理等。
总结:
模式 | 优势 | 适合任务 |
---|---|---|
Pipelining | 高吞吐 | 多阶段连续处理任务 |
Dedicated Threads | 响应快、稳定 | 实时/职责明确的服务 |
Task Schedulers | 高并发、可扩展 | 大量独立任务并行执行 |
并发编程中一个核心概念:Concurrent Objects(并发对象)。
定义:
Concurrent Object(并发对象):多个线程并发地操作同一个对象,其中至少有一个线程修改对象的状态。
这与“只读共享对象”不同,并发对象必须保证线程安全。
构建并发对象的工具链(Pattern):
-
Platform Primitives(平台原语)
底层支持,比如:std::mutex
,std::condition_variable
std::atomic
, 内存屏障(memory fences)- 操作系统的线程同步机制
-
Atomic Operations(原子操作)
- 单步执行,不可中断
- 如:
std::atomic<int>::fetch_add()
,compare_exchange_strong()
- 实现无锁算法(lock-free programming)的基础
-
Concurrent Object(并发对象)
基于上述工具,设计数据结构或类,使其:- 可被多个线程访问
- 在状态被修改时不会出现竞态条件
- 保证可见性和顺序性
示例:线程安全的计数器
#include <atomic>class ConcurrentCounter {std::atomic<int> count{0};
public:void increment() {count.fetch_add(1, std::memory_order_relaxed);}int get() const {return count.load(std::memory_order_relaxed);}
};
这个 ConcurrentCounter
就是一个并发对象,因为:
- 它的状态(
count
)可以被多个线程同时读写 - 操作是原子的,线程安全
更高级的并发对象例子:
concurrent_queue
(线程安全队列)shared_mutex
+ 读写锁的对象- 并发哈希表(如
tbb::concurrent_hash_map
)
“Pipelined Graphics(图形流水线)” 概念涉及到多线程图形渲染流程的优化模式,通常应用在游戏引擎或实时图形处理中。
一、原始模型(未使用同步机制)
Frame 1: Engine → Graphics
Frame 2: Engine → Graphics
Frame 3: Engine → Graphics
- 每帧中,**引擎(Engine)先处理逻辑,然后图形系统(Graphics)**渲染。
- 这是串行执行,导致空闲资源浪费。
二、流水线优化模型(Pipelined)
Frame 1: Engine
Frame 2: Graphics
Frame 2: Engine
Frame 3: Graphics
Frame 3: Engine
Frame 4: Graphics
- 引擎和图形处理被解耦,可以并行进行不同帧的任务。
- 引擎处理第 N 帧时,图形系统可以同时渲染第 N−1 帧。
- 提升了吞吐量,但也引入了同步挑战。
三、加上 Semaphore(信号量)控制
Frame 1: Engine ─▶ Semaphore ─▶ Graphics
Frame 2: Engine ─▶ Semaphore ─▶ Graphics
Frame 3: Engine ─▶ Semaphore ─▶ Graphics
- 引入了 信号量(Semaphore) 用于同步:
- 保证图形处理在引擎任务完成后才开始;
- 避免数据竞争或使用未准备好的资源;
- 是一个典型的生产者-消费者模式,信号量是调度机制。
总结
模型 | 特点 |
---|---|
串行模型 | 简单、无并行、资源利用差 |
流水线模型 | 并行帧处理、高吞吐、需小心同步 |
流水线 + 信号量 | 最优结构、并发 + 控制、保证正确性和效率 |
在 Pipelined Graphics(图形流水线)中,为了避免 Engine 与 Graphics 并发修改 game objects(游戏对象),需要采取 同步和隔离机制。以下是核心思想:
问题背景
在流水线架构中:
- Engine 线程 在更新游戏对象(位置、状态、输入等);
- Graphics 线程 同时读取这些对象来渲染;
- 如果两者同时操作同一对象(如位置坐标),会发生 数据竞争(data race),导致渲染错误或崩溃。
常见解决方案:避免并发修改
1. 双缓冲(Double Buffering)或帧快照
原理:
- Engine 写入一个副本(如 frame N);
- Graphics 读取另一个副本(frame N−1);
- 每帧切换指针或引用,不共享写入和读取的对象。
图示:
Engine → [ GameState A ]↑
Graphics ← [ GameState B ]
实现方式(伪代码):
GameState stateA, stateB;
GameState* writeState = &stateA;
GameState* readState = &stateB;Engine thread:update(*writeState);Graphics thread:render(*readState);每帧结束时:std::swap(writeState, readState);
2. 只读视图 + 写时复制(Copy-on-Write)
- Graphics 线程只拿到 Engine 生成的只读版本;
- Engine 若要修改对象,先复制再改;
- 避免写入已共享的内存区域。
3. 任务管线 + 异步提交(Command Buffers)
- Engine 不直接操作图形数据;
- 而是将修改或渲染意图打包成命令队列;
- Graphics 线程只执行这些命令,不访问共享数据。
总结
方法 | 原理 | 是否安全 |
---|---|---|
双缓冲 | 引擎写入副本,图形读旧副本 | 安全 |
写时复制 | 写入时复制对象,保持只读安全 | 安全 |
渲染命令队列(push) | 引擎不直接改对象,发送渲染命令 | 安全 |
共享指针 + 互斥锁 | 保护共享资源,但降低性能 | 慎用 |
这段结构体定义展示了在 Double-Buffered Graphics State(双缓冲图形状态) 中的一种实现方式:
双缓冲结构解释
struct Object
{ ... Matrix xform[2]; // 两个变换矩阵缓冲区(用于双缓冲)...
};
意图:
xform[0]
和xform[1]
分别表示 两个时间点或两个线程 使用的变换状态(如:位置、旋转、缩放等)。- 典型用法是在渲染引擎中防止 数据竞争(data race)或 脏读/写。
使用方式示例
在游戏主循环中:
Engine
线程每帧更新xform[writeIndex]
Graphics
线程每帧读取xform[readIndex]
int frameIndex = 0;update(Object& obj)
{obj.xform[frameIndex % 2] = computeNewTransform();
}render(const Object& obj)
{draw(obj.xform[(frameIndex + 1) % 2]); // 读取前一帧数据
}每帧结束后:frameIndex++;
优点
- 无锁并发:避免需要 mutex 或原子操作;
- 性能友好:两个独立缓冲区,线程之间不会竞争;
- 一致性保证:Graphics 总是读取已完成更新的一帧。
注意事项
- 所有读/写操作必须严格基于索引切换;
- 数据切换需同步,不能提前或滞后;
- 有些对象需要深拷贝,不能只复制指针。
这是另一种实现图形状态更新与渲染解耦的方式,称为 分离图形对象(Separate Graphic Objects)。
结构说明
struct Object
{ ...Matrix xform; // 游戏逻辑更新的变换GraphicObject* gfxObject; // 指向图形对象...
};struct GraphicObject
{ ...Matrix xform; // 图形线程渲染用的变换...
};
工作机制
- 每帧开始时,由 Engine 线程 将逻辑对象的
xform
拷贝到gfxObject->xform
。 - 之后,Graphics 线程 使用
GraphicObject::xform
来渲染。 - 不再共享同一内存,避免并发修改的问题。
优点
- 解耦逻辑与图形:清晰划分更新与渲染的职责。
- 避免数据竞争:图形线程只读
GraphicObject
,逻辑线程只写Object
。 - 拷贝语义明确,容易插入同步或版本控制。
注意事项
- 需要确保 拷贝发生在渲染前;
- 若对象多,拷贝可能成为性能瓶颈,可优化为 只拷贝变更对象。
示例:
// 游戏逻辑线程
void update(Object& obj) {obj.xform = computeNewTransform();
}// 每帧开始同步
void syncToGraphics(Object& obj) {obj.gfxObject->xform = obj.xform;
}// 渲染线程使用
void render(const GraphicObject& gfx) {drawWithTransform(gfx.xform);
}
这是关于 Dedicated Threads(专用线程) 模式在游戏中的一个典型应用场景 —— 内容流(Content Streaming)。
为什么要内容流加载(Streaming)?
- 游戏世界通常很大,无法将所有资源(地图、纹理、模型、声音等)一次性加载进内存。
- 为了节省内存和加快启动,游戏通常只加载当前区域资源,边玩边加载其它部分。
专用线程的作用
使用一个或多个 专用线程(Dedicated Threads) 来:
- 异步读取磁盘上的数据;
- 解压缩、解析资源;
- 在后台构建纹理、网格等图形数据;
- 在主线程需要时再提交到图形或物理系统。
这样可以避免:
- 主线程卡顿;
- 加载过程打断玩家体验。
示例流程
[Main Thread]↓ requests
[Streaming Thread] → Load data from disk→ Decode/Decompress→ Prepare assets↑ notify ready
[Main Thread] → Use loaded asset
优势
- 主线程专注于游戏逻辑和渲染;
- 更好利用多核 CPU;
- 流畅过渡,玩家几乎感知不到加载过程。
这是对 Dedicated Loading Thread(专用加载线程) 模式的简化描述,展示了它在内容流加载中的核心行为。
专用加载线程的工作流程
这个线程是 持续运行的后台线程,专门负责从磁盘加载游戏世界的部分内容(chunk):
[Main Thread]↓ 提出加载请求(request)
[Loading Thread]- 检查是否有新的加载任务- 如果有:→ 读取磁盘数据(Load chunk of world content)→ 构造内存数据结构- 如果没有:→ sleep(节省 CPU)
工作循环(伪代码)
void LoadingThread() {while (gameIsRunning) {if (!loadQueue.empty()) {auto chunkRequest = loadQueue.pop();loadChunk(chunkRequest); // 从磁盘加载 & 解析} else {std::this_thread::sleep_for(std::chrono::milliseconds(10));}}
}
重点理解
- request: 主线程发出加载请求(加入队列)。
- load: 后台线程异步处理请求,加载数据。
- sleep: 无事可做时休眠,节省资源。
这种模式广泛应用于:
- 游戏地图动态加载
- 资源异步解码(声音、纹理)
- 场景/关卡无缝切换
这是一个经典的**专用加载线程(Dedicated Loading Thread)**设计,配合事件机制(Event
)实现线程高效唤醒与等待。
整体机制概览
专用加载线程通过一个线程安全队列接收请求(ThreadSafeQueue<Request>
),并使用事件(workAvailable
)来决定何时处理队列:
工作流程解析:
主线程(Engine Thread)逻辑:
requests.push(r); // 把加载请求推入线程安全队列
workAvailable.signal(); // 通知加载线程有任务要处理
加载线程(Loading Thread)逻辑:
for (;;) {workAvailable.waitAndReset(); // 阻塞等待事件触发while (r = requests.tryPop()) { // 非阻塞地从队列中取出请求processLoadRequest(r); // 处理加载逻辑}
}
核心组件说明:
组件 | 作用 |
---|---|
ThreadSafeQueue<Request> | 多线程安全地存取请求数据 |
Event workAvailable | 用于线程同步:唤醒等待的线程 |
signal() | 设置事件为 signaled 状态,唤醒等待线程 |
waitAndReset() | 等待事件,并重置为 non-signaled |
事件行为说明:
- Event signaled → 加载线程从阻塞状态恢复,进入处理循环。
- Event reset → 如果没有新的请求,线程会阻塞等待下一个 signal。
优点总结:
- 减少 CPU 空转(比用
sleep()
高效得多) - 响应及时(任务一来立即 signal)
- 高并发安全(线程安全队列 + 同步原语)
在专用加载线程模式中,改进队列设计时,可以考虑以下几点:
- 自定义队列:根据具体需求设计,比如支持批量处理、避免锁竞争等。
- 取消请求:允许未处理的请求被取消,避免无用加载,节约资源。
- 中断请求:能及时打断正在处理的请求(比如玩家转场,加载优先级更高的新内容)。
- 重新排序请求:支持动态调整请求优先级,确保重要内容先加载。
这些改进能让加载线程更加灵活、高效,适应复杂的游戏或系统环境。
Task Schedulers(任务调度器)用于支持细粒度并行(Fine-Grained Parallelism),它的动机包括:
- 将复杂的任务(如输入处理、逻辑计算、物理模拟、动画更新)拆分成小任务单元。
- 这些小任务可以被多个工作线程(Worker)并行执行,充分利用多核CPU。
- 通过调度器自动分配任务,提升系统的整体性能和响应能力。
任务调度器能够动态管理和分配工作负载,避免线程空闲或过载,实现高效的多核并行处理。
这段代码描述了一个**简单任务队列(Simple Task Queue)**的工作原理,配合多线程处理:
- 有一个线程安全的任务队列
tasks
,用于存放待执行的任务(Task
)。 - 对于每个工作线程,有一个对应的事件对象
workAvailable[numThreads]
。 - 提交任务线程将任务
t
推入任务队列,然后逐个触发所有工作线程对应的事件workAvailable.signal()
,通知它们有任务可做。 - 工作线程进入无限循环:
- 先等待自己的事件
workAvailable[thread].waitAndReset()
被触发(收到任务信号)。 - 然后不断从任务队列尝试取出任务
tasks.tryPop()
,执行任务t->Run()
。
- 先等待自己的事件
这种设计保证:
- 任务被提交后,所有工作线程都会被唤醒去抢占任务。
- 工作线程空闲时等待事件,不占用CPU资源。
- 任务通过线程安全队列保证并发安全。
当然,下面是加了详细注释的版本:
// 线程安全的任务队列,存放待执行任务
ThreadSafeQueue<Task> tasks;// 每个工作线程对应的事件数组,用来通知线程有任务可做
Event workAvailable[numThreads];// 提交任务的线程执行:
tasks.push(t); // 将新任务放入任务队列
for (int i = 0; i < numThreads; i++) workAvailable[i].signal(); // 逐个触发所有工作线程的事件,唤醒它们// 工作线程的主循环:
for (;;)
{// 等待属于自己的事件被触发(任务到达信号),并自动重置事件状态workAvailable[thread].waitAndReset();// 当任务队列不为空时,取出任务并执行while (Task* t = tasks.tryPop()) {t->Run(); // 执行任务}// 如果队列为空,则继续等待事件信号
}
tasks
:线程安全,保证多线程读写无竞态。workAvailable
:每个线程一个事件,唤醒线程处理任务。waitAndReset()
:线程等待事件,事件触发后自动重置,防止重复唤醒。tryPop()
:尝试从队列取任务,空时返回空指针,结束循环。- 提交线程负责唤醒所有工作线程,工作线程轮询执行任务。
任务组 (Task Groups) 设计思想
- 将许多小工作单元(
Item
)分组,构成一个大的任务组 (TaskGroup
)。 - 多个线程并行处理同一个任务组里的不同工作单元。
- 通过原子递增索引,避免任务冲突和重复执行。
- 这已经不是简单的任务队列了,而是更复杂的多线程分发。
代码结构和注释
class TaskGroup
{
private:std::vector<Item*> m_Items; // 存放要并行处理的任务项指针数组volatile int m_Index; // 用于原子递增的索引,指示当前被处理的任务项位置public:void Run(){for (;;){// 原子递增索引,获取一个唯一任务项索引int index = AtomicIncrement(m_Index);// 如果索引超出任务项总数,说明所有任务已经被分配完,退出循环if (index >= static_cast<int>(m_Items.size()))break;// 执行当前索引对应的任务项m_Items[index]->Run();}}
};
关键点说明
AtomicIncrement(m_Index)
:保证多个线程在并行调用时,每个线程拿到的索引都是唯一的,避免重复执行同一任务。- 任务组中所有任务项保存在
m_Items
里,线程共享访问。 - 多线程同时调用
Run()
,通过索引递增方式动态分配任务。 - 不再使用简单的单队列任务调度,而是需要针对每个线程维护“尾部索引”(tail)来避免冲突,可能是每个线程独立的“尾指针”。
关于尾指针和头指针
- “tail 0, tails 1, 2, 3, head” 意味着多线程队列结构可能设计成每个工作线程有自己的“尾部”指针,避免争用,提升并发性能。
- 这种设计比传统的单一队列复杂,但可以更高效地分发任务。
依赖管理的任务组 (TaskGroup) 设计
- 多个任务组间有依赖关系,比如:
- 物理任务必须等逻辑任务全部完成后才能开始。
- 利用计数器
m_RemainingCount
来追踪未完成的任务数量。 - 线程调用
Run()
,处理任务项后减少计数。 - 当最后一个任务完成时,触发依赖任务组的调度。
代码示例及注释
class TaskGroup
{
private:std::vector<Item*> m_Items; // 任务项列表volatile int m_Index; // 当前被分配的任务索引(原子递增)volatile int m_RemainingCount; // 剩余未完成任务计数(初始为 m_Items.size())void AddDependencies(); // 触发依赖任务组的调度(未展示实现)public:void Run(){int count = 0; // 当前线程完成的任务数计数器for (;;){int index = AtomicIncrement(m_Index); // 获取唯一任务索引if (index >= static_cast<int>(m_Items.size())) // 任务完成判断break;m_Items[index]->Run(); // 执行任务count++;}// 如果当前线程完成了任务,且所有任务已完成(原子减少剩余计数到0)if (count > 0 && AtomicSubtract(m_RemainingCount, count) == 0){AddDependencies(); // 通知依赖任务组,可以开始调度执行}}
};
设计要点说明
m_RemainingCount
用来追踪当前任务组未完成的任务数量。- 每个线程执行任务后,累计
count
,最后通过AtomicSubtract
减去完成任务数。 - 当
m_RemainingCount
减到0,表示该任务组全部任务执行完毕,调用AddDependencies()
通知后续任务组。 - 这种机制允许实现任务间的依赖链,保证“物理任务”在“逻辑任务”全完成后再开始。
任务调度器的改进设计选择
-
Custom(定制)
根据应用需求设计专属的任务调度策略和结构,不必局限于通用方案。 -
Centralized / Per-thread Task List(集中式 / 每线程任务列表)
- 集中式任务队列:所有线程共享一个任务队列,管理简单但可能成为瓶颈。
- 每线程任务列表:每个工作线程维护独立任务队列,减少竞争,提升并发。
-
Priorities(优先级)
给任务设置优先级,确保关键任务优先执行。 -
Affinities(亲和性)
将任务绑定到特定线程或CPU核心,提高缓存命中率和性能。 -
Batching(批处理)
将多个小任务合并成批处理,减少调度开销。 -
Profiler Integration(性能分析集成)
集成性能分析工具,实时监控任务调度性能,辅助优化。 -
“Pin” Threads to Cores(线程绑核)
将线程固定在特定CPU核心上运行,减少线程切换和缓存失效。
你说的内容是关于游戏开发中使用的原子操作(Game Atomics),以及典型跨平台原子库所涵盖的几个关键操作类别:
GAME ATOMICS 主要内容
类型 | 示例代码 | 说明 |
---|---|---|
声明 | volatile int A; | 声明一个带有 volatile 的整型变量,防止编译器优化 |
加载 / 存储 | A = 1; int a = A; | 直接写入和读取变量 |
排序保证 | LIGHTWEIGHT_FENCE(); FULL_FENCE(); | 内存屏障,保证指令顺序或内存访问顺序 |
读-改-写操作 | AtomicIncrement(A); AtomicCompareExchange(A, …, …); | 原子地修改变量,防止竞态条件 |
解释
- volatile 并不保证原子性或内存顺序,只是防止编译器对变量的访问做优化重排。
- LIGHTWEIGHT_FENCE() 轻量级内存屏障,保证某些操作顺序,不完全阻塞CPU执行。
- FULL_FENCE() 全内存屏障,保证所有之前的内存操作完成后,后续的内存操作才能开始,防止乱序执行。
- AtomicIncrement 和 AtomicCompareExchange 是常见的读-改-写原子操作,用于实现锁和同步。
这是在讲内存屏障(Fence)宏的作用和区别,特别是在多线程环境中,内存操作的顺序性保证:
FENCE 宏的区别
宏名 | 作用描述 | 对应的 C++11 原子屏障 |
---|---|---|
LIGHTWEIGHT_FENCE() | - 只保证加载(load)和存储(store)操作的顺序。 - 常用于保证内存操作不被乱序执行,但不强制所有操作完成。 | 等价于 atomic_thread_fence(memory_order_acquire) 和 memory_order_release 或 memory_order_acq_rel 的组合(部分顺序保证) |
FULL_FENCE() | - 除了 LIGHTWEIGHT_FENCE 的所有保证外,- 还保证“先前的所有存储操作完成后,才开始接下来的加载操作”。 - 保证最严格的内存顺序。 | 等价于 atomic_thread_fence(memory_order_seq_cst) (顺序一致性,最强保证) |
解释
- LIGHTWEIGHT_FENCE() 更轻量,性能更好,但只保证部分内存操作顺序。
- FULL_FENCE() 是强内存屏障,保证了所有线程都能看到一致的操作顺序,防止乱序访问导致的数据错误。
- 现代 C++ 中,
std::atomic_thread_fence
用于实现这些屏障,参数决定了屏障的强度。
这段内容是在讲内存屏障和原子操作在不同处理器架构(x86/x64、PowerPC、ARMv7)上的具体实现方式。重点包括:
1. 变量声明和基本操作
volatile int A;
A = 1; // Store
int a = A; // Load
用 volatile
保证编译器不会随意优化读写。
2. 屏障(Fence)和编译器屏障(Compiler Barrier)
-
LIGHTWEIGHT_FENCE():
- x86/x64:通常不需要特别的指令,因为 x86 自带强顺序(但还是有编译器屏障
COMPILER_BARRIER()
,防止编译器乱序) - PowerPC:使用
lwsync
(轻量同步指令) - ARMv7:使用
dmb
(数据内存屏障,轻量)
- x86/x64:通常不需要特别的指令,因为 x86 自带强顺序(但还是有编译器屏障
-
FULL_FENCE():
- x86/x64:用
mfence
指令,强制全内存屏障,确保所有存储提交完成再执行后续操作 - PowerPC:用
hwsync
(硬件同步指令,强屏障) - ARMv7:用
dmb
(数据内存屏障,强)
- x86/x64:用
-
COMPILER_BARRIER():
- 防止编译器重排序,但不发出 CPU 指令
3. 原子读-改-写操作(Read-Modify-Write)
-
x86/x64:
lock inc
:带锁前缀的递增,保证原子性lock cmpxchg
:比较并交换指令,核心原子操作
-
PowerPC:
lwarx
:加载并保留(load with reservation)stwcx
:条件存储(store conditional)
-
ARMv7:
ldrex
:加载保留(load exclusive)strex
:存储条件(store exclusive)
总结
- x86/x64 架构自带较强的内存顺序保证,轻量级屏障多靠编译器屏障和
mfence
实现强屏障。 - PowerPC 和 ARMv7 架构相对弱顺序,需要显式指令(
lwsync
,hwsync
,dmb
)来保证内存顺序。 - 原子操作实现机制不同:x86 用锁前缀,PowerPC/ARM 用保留/条件存储机制。
原子操作(Atomic Operations)如何成为游戏行业中处理并发对象的一个重要模式,以及它们在行业中的应用背景和演变过程。
关键点解析
-
Pattern(模式)
开发者需要设计能安全支持多线程访问和修改的并发对象(Concurrent Object),而原子操作正是实现这一目标的关键工具。 -
Concurrent Object(并发对象)
多线程环境中,对象的状态可能被多个线程同时访问和修改,传统锁机制可能导致性能瓶颈,原子操作提供了轻量级、低延迟的同步手段。 -
Atomic Operations(原子操作)
作为最基本的同步构建块,它们保证对共享数据的读写操作在多线程中不被打断,实现了线程安全。 -
Game Industry(游戏行业)
游戏开发对性能要求极高,特别是在多核CPU普及后,合理使用原子操作优化多线程并发成为行业的普遍实践。
总结
游戏行业并发编程的发展路径:
需求 → 并发对象设计 → 原子操作使用 → 行业标准实践
下面是固定大小单生产者单消费者无锁队列(CappedSPSCQueue
)的完整示例代码,包含tryPush
和tryPop
的实现,并附详细注释说明:
#include <atomic> // 用于 std::atomic
#include <iostream>
template <class T, int size>
class CappedSPSCQueue
{
private:T m_items[size]; // 固定大小的存储数组std::atomic<int> m_writePos; // 写指针,生产者和消费者都读,只有生产者写int m_readPos; // 读指针,只有消费者读写public:CappedSPSCQueue() : m_writePos(0), m_readPos(0) {}// 尝试写入元素bool tryPush(const T& item){int currentWrite = m_writePos.load(std::memory_order_relaxed);int nextWrite = (currentWrite + 1) % size;// 队列满时,nextWrite == m_readPos,写失败if (nextWrite == m_readPos)return false;m_items[currentWrite] = item; // 写入元素// 更新写指针,确保写入数据对消费者可见m_writePos.store(nextWrite, std::memory_order_release);return true;}// 尝试读取元素bool tryPop(T& item){int currentRead = m_readPos;// 读取写指针,查看是否有数据可读int currentWrite = m_writePos.load(std::memory_order_acquire);// 队列空时,读指针 == 写指针,读失败if (currentRead == currentWrite)return false;item = m_items[currentRead]; // 读取元素m_readPos = (currentRead + 1) % size; // 移动读指针return true;}
};// 示例使用
int main()
{CappedSPSCQueue<int, 4> queue;// 生产者线程模拟for (int i = 1; i <= 5; ++i){if (queue.tryPush(i))std::cout << "Pushed: " << i << "\n";elsestd::cout << "Queue full, failed to push: " << i << "\n";}// 消费者线程模拟int value;while (queue.tryPop(value)){std::cout << "Popped: " << value << "\n";}return 0;
}
代码说明
m_writePos
使用std::atomic<int>
,用memory_order_release
保证写入元素后写指针才更新;读时用memory_order_acquire
保证读取指针之前看到写入的数据。m_readPos
由消费者线程独占访问,不需要原子。- 环形缓冲区结构,用模运算实现循环索引。
- 队列满的条件是写指针的下一个位置是读指针。
- 队列空的条件是读指针和写指针相等。
你提供的例子是一个**固定大小(capped)单生产者单消费者(SPSC)无锁队列(wait-free queue)**的模板类,关键成员包括:
m_items[size]
:存储元素的固定大小数组。volatile int m_writePos
:写入位置索引(写指针),通常需要是原子或带有合适内存序的变量,确保多线程安全。int m_readPos
:读取位置索引(读指针),只由消费者线程操作。
工作原理简述:
-
tryPush(const T& item)
由生产者线程调用,尝试写入元素到队列中。成功时更新写指针,否则返回失败(队列满)。 -
tryPop(T& item)
由消费者线程调用,尝试从队列读取元素。成功时更新读指针,否则返回失败(队列空)。
设计重点
- 单生产者单消费者模型简化了同步,生产者只写
m_writePos
,消费者只读m_writePos
并写m_readPos
。 - 利用
volatile
或原子变量确保写读状态对另一线程可见。 - 无锁、无阻塞,适合对延迟敏感的实时应用。
bool tryPush(const T& item)
{int w = m_writePos; // ① 读取写位置if (w >= size) // ② 判断是否越界return false;m_items[w] = item; // ③ 写入数据 —— 这里的写操作可能被后移(重排到后面)m_writePos = w + 1; // ④ 更新写索引 —— 这里的写操作可能被提前(重排到前面)return true;
}
- 重排风险:③写入数据 和 ④更新写索引两条语句的执行顺序可能被 CPU 或编译器重排。
- 如果更新写索引的操作(④)提前执行,另一个线程会先看到写索引的变化,但数据(③)还没写入完成,导致读线程读取了无效或旧数据。
bool tryPop(T& item)
{int w = m_writePos; // ⑤ 读取写索引if (m_readPos >= w) // ⑥ 判断是否空return false;item = m_items[m_readPos]; // ⑦ 读取数据 —— 这里的读操作可能被提前(重排到前面)m_readPos++; // ⑧ 更新读索引return true;
}
- 重排风险:⑤读取写索引 和 ⑦读取数据之间的操作可能被重排。
- 如果读取数据(⑦)提前执行,可能读到未写入完成的数据。
- 也可能
m_readPos++
(⑧)更新提前或滞后,影响下次读取逻辑。
总结
- 写线程中:
m_items[w] = item;
(③) 可能被重排到m_writePos = w + 1;
(④) 之后。 - 读线程中:读取数据
item = m_items[m_readPos];
(⑦) 可能在确认写索引有效之前执行。
解决方案
- 写线程:写完数据后,使用带release语义的原子写入写索引,阻止重排。
- 读线程:读取写索引时使用带acquire语义的原子操作,确保读到最新数据。
代码中加入了LIGHTWEIGHT_FENCE();
内存屏障,作用是限制CPU和编译器的重排序,保证写线程写数据后才更新写指针,读线程先读写指针再读数据,避免乱序带来的可见性问题。
具体分析:
bool tryPush(const T& item)
{ int w = m_writePos; if (w >= size) return false; m_items[w] = item; // 先写入数据LIGHTWEIGHT_FENCE(); // 屏障,禁止之前的写入操作后移m_writePos = w + 1; // 再更新写指针return true;
}
这里屏障保证了m_items[w] = item;
不会被重排序到m_writePos = w + 1;
之后,从而确保消费者线程不会提前看到更新的写指针。
bool tryPop(T& item)
{ int w = m_writePos; if (m_readPos >= w) return false; LIGHTWEIGHT_FENCE(); // 屏障,禁止之后的读操作提前item = m_items[m_readPos]; // 先读取数据m_readPos++; // 更新读指针return true;
}
这里屏障保证了读线程在读取m_items[m_readPos]
前,先读取最新的m_writePos
值,避免先读数据后读写指针导致读到旧数据。
总结:
- 加入
LIGHTWEIGHT_FENCE()
使得读写操作顺序被强制执行,减少乱序风险。 - 但是这仍是弱保证,真正安全的并发队列应使用
std::atomic
配合memory_order_release/acquire
等更严格的内存序。 - 你这段代码已经很接近正确的单生产者单消费者队列实现了。
总结一下
- 三大线程模式(Pipelining、Dedicated Threads、Task Schedulers)用来充分利用多核优势。
- 大量定制的并发对象,针对游戏场景设计专用的数据结构和同步机制。
- 高争用对象使用原子操作,确保性能和正确性。
- 实践中不断积累经验和改进,通过“做中学”提升并发编程水平。
这是第二部分,讲 C++11 标准库中的原子操作(Atomic Operations)。
重点包括:
- Atomic operations:C++11 提供的底层原子操作接口,用来实现并发安全。
- Pattern:利用原子操作实现的并发对象设计模式。
- Concurrent Object:多线程安全的对象结构设计。
- Portable principles:跨平台可移植的并发编程原则,C++11 标准库抽象了不同平台的底层原子实现。
在 C++11 中,如果多个线程同时访问同一个变量,并且至少有一个线程在修改它,所有对该变量的访问都必须使用 C++11 提供的原子操作,否则程序会产生数据竞争(data race),导致未定义行为。
例如:
int X; // 如果多个线程读写这个变量,没有使用atomic,会有数据竞争,程序不安全
必须改成:
std::atomic<int> X; // 使用C++11的atomic保证多线程安全
这样,读写 X
的操作就是原子的,避免数据竞争。
这段话说明了“撕裂写”(torn write)的问题,是数据竞争造成的一个具体危害:
- 变量X是32位整数,但硬件一次只能写入16位(半字)数据。
- 线程1给X写入了
0x80004
(32位值),这个值需要两个16位写操作完成。 - 线程2同时读X,可能在写操作“中间”读到数据,得到一个不完整的值,比如
0x80000
(只写入了高16位,低16位未写或旧值)。 - 这种情况称为撕裂写(torn write),数据不一致,结果错误。
- 这也是数据竞争带来的未定义行为之一。
总结:
-
数据竞争导致未定义行为,不仅是抽象的程序逻辑错误,还有硬件层面的实际写入不一致(撕裂)。
-
C++11通过强制使用原子操作,确保访问同一变量时,读写都是完整的、不可分割的(即不会撕裂),保证数据一致性。
-
C++11规定: 如果多个线程同时访问同一个变量,且至少有一个线程修改它,所有访问都必须用C++11原子操作,否则就是数据竞争(data race),导致未定义行为。
-
例子中用了
volatile int X;
,但这并不保证线程安全。volatile
只是防止编译器优化,不保证原子性。 -
实际项目中常“破坏”这个规则,使用普通的int(非atomic)进行多线程访问,因为他们“知道”某些平台上的
int
访问本身就是原子的(比如大多数现代处理器上对32位int的读写是原子的)。 -
但这样做是有风险的,不符合标准,也可能在某些平台出错。
总结:
- 标准建议用
std::atomic<int>
来保证原子性和可见性。 - 现实中工程上有时用非标准手段(如
volatile int
)绕开规则,但要非常小心平台兼容性和潜在错误。
C++11 原子操作库实际上包含了两种风格的原子操作,它们都暴露在统一的 API 下,但概念和用法上有区别:
1. Sequentially Consistent Atomics(顺序一致性原子操作)
- 类似 Java 的
volatile
,保证程序中所有线程看到操作的顺序一致。 - 语义简单、易于理解,关注的是“操作的交错顺序”(interleaving statements)。
- 适合写书籍、理论分析,代码清晰,但性能开销较大。
- 默认的 C++11 原子操作即是这种模式。
2. Low-Level Atomics(低级原子操作)
- 类似传统的 C/C++
volatile
,更接近底层硬件操作。 - 更难理解和使用,需要考虑更多细节,比如内存屏障、缓存一致性等。
- 速度可能更快(因为可以避免一些不必要的同步开销),但代码复杂且易错。
- 这类原子操作是游戏开发中经常使用的“游戏原子”,追求高性能。
总结:
- C++11 的原子库表面是一套API,底层其实同时支持“易用但慢”的顺序一致原子和“复杂但快”的低级原子操作。
- 选择哪个取决于具体应用需求:是想简单正确,还是追求极致性能。
C++11 顺序一致性(Sequential Consistency)原子操作 的行为,下面我来帮你总结和解释:
场景描述
std::atomic<int> A(0);
std::atomic<int> B(0);
两个线程:
Thread 1
A = 1; // store
c = B; // load
Thread 2
B = 1; // store
d = A; // load
问题
我们关心的是,最终 c
和 d
这两个变量的可能取值组合是多少?
图中列出的三种 可能的 interleavings(交错执行)
顺序执行语句 | 结果 (c, d) |
---|---|
A = 1; c = B; B = 1; d = A; | (0, 1) |
A = 1; B = 1; c = B; d = A; | (1, 1) |
B = 1; d = A; A = 1; c = B; | (1, 0) |
这些都是合法的,因为它们可以被一个全局一致的执行顺序解释。
不可能的组合:(c = 0, d = 0)
图右下角红线标出的 (0, 0)
是不可能出现的,因为:
Thread 1
读到了B = 0
,说明Thread 2
的B = 1
还没执行;Thread 2
读到了A = 0
,说明Thread 1
的A = 1
也还没执行;- 那么这两个 store 都没执行,但它们都在执行顺序前?这就矛盾了!
换句话说:如果 c == 0
,说明 B = 1
没发生;如果 d == 0
,说明 A = 1
没发生。但这两个 store 总得有一个先执行。这种“互相看不到彼此”的状态在顺序一致模型中是不允许的。
顺序一致性的保证
所有的原子操作在所有线程中表现得像是以某个确定顺序执行的,这个顺序对所有线程一致。
所以图中(c, d) == (0, 0)
就不可能出现在这个“全局一致顺序”中。
小结
你“理解”的标注是完全正确的!
- 图中
(0, 0)
是 Impossible - 其他三种
(0,1)、(1,0)、(1,1)
都是 合法的 interleaving - 这个例子很好地说明了 为什么默认的
memory_order_seq_cst
是强一致的,并防止数据竞争
如果你想进一步理解其他 weaker memory orders(如relaxed
、acquire/release
)在这个例子中会不会允许(0, 0)
出现,也可以告诉我,我可以画出它们的行为模型给你看。
低层级原子操作(Low-Level Atomics) 使用 memory_order_relaxed
所带来的行为差异。下面是详细解析:
示例代码
std::atomic<int> A(0);
std::atomic<int> B(0);
两个线程:
Thread 1:
A.store(1, memory_order_relaxed); // 写 A
c = B.load(memory_order_relaxed); // 读 B
Thread 2:
B.store(1, memory_order_relaxed); // 写 B
d = A.load(memory_order_relaxed); // 读 A
问题焦点
使用 memory_order_relaxed
表示我们不要求顺序一致性,也不要求任何同步,只保证原子性(不会 torn write、data race 等)。
可能的值组合(c, d)
c | d | 解释 |
---|---|---|
0 | 0 | 可能!因为 Thread 1 没看到 B=1,Thread 2 也没看到 A=1 |
0 | 1 | Thread 1 没看到 B=1,Thread 2 看到了 A=1 |
1 | 0 | Thread 1 看到了 B=1,Thread 2 没看到 A=1 |
1 | 1 | 都看到了 |
这与顺序一致性(seq_cst
)的区别:
在顺序一致(上一页图)中,(0, 0)
是 不可能的 —— 因为必须存在一个全局操作顺序,不允许“两个线程互相看不到对方写的值”。
但现在我们使用 relaxed
,就表示:
“我不关心你怎么重排,只要是原子就行”
因此编译器或 CPU 可能把指令 乱序执行,导致(0, 0)
是可能的。
🛡 如何防止?
加上一个 全序内存栅栏(memory fence):
atomic_thread_fence(memory_order_seq_cst);
这个 fence 会强制在所有线程之间建立同步关系,确保访问不会发生重排,从而恢复顺序一致性的效果,防止 (0, 0)
的出现。
总结
memory_order_relaxed
仅保证原子性,但允许乱序(会导致 (0,0))- 如果你需要全局一致性,就要使用:
memory_order_seq_cst
原子操作- 或在关键处插入
atomic_thread_fence(memory_order_seq_cst)
- 低层级原子操作让你可以获得更高性能,但需要程序员手动保证正确性(容易出错)
你提到的这页内容解释的是 顺序一致原子操作(Sequentially Consistent Atomics) 在 C++11 中的写法和它们之间的等价性。我们来详细解析下:
关键概念:顺序一致性 (memory_order_seq_cst
)
这是 C++11 原子操作的默认内存序。它提供最强的内存顺序保证,即所有原子操作在所有线程中表现得像是以某个全局顺序执行。
示例代码及含义
std::atomic<int> A;
写法一:
A.store(1, std::memory_order_seq_cst);
c = A.load(std::memory_order_seq_cst);
写法二(简写):
A.store(1); // 默认就是 memory_order_seq_cst
c = A.load(); // 默认也是 memory_order_seq_cst
写法三(运算符重载):
A = 1; // 等价于 store(1)
c = A; // 等价于 load()
这三种写法完全等价,都表示进行一个顺序一致的原子写和读。
背后的语言设计逻辑
C++11 中的 std::atomic<T>
提供了运算符重载(如 operator=
和 operator T()
),所以你可以像对普通变量一样使用它。但这些操作默认使用最强的内存序(memory_order_seq_cst
),确保安全。
注意点
虽然你可以简写为 A = 1;
,但它不是普通赋值!
它仍然是一个原子操作,其背后是:
A.store(1, std::memory_order_seq_cst);
这对于防止 data race 和 乱序执行问题 是很重要的。
总结
写法 | 实质操作 | 默认行为 |
---|---|---|
A.store(1); | 原子写操作 | 顺序一致(seq_cst) |
c = A.load(); | 原子读操作 | 顺序一致(seq_cst) |
A = 1; | 重载了 operator= ,等同于上面两者 | 顺序一致 |
c = A; | 重载了 operator T() ,等同于上面 | 顺序一致 |
如果你理解了这点,那么你已经掌握了使用 C++11 高层级原子操作最关键的一部分!
关于**顺序一致性(Sequentially Consistent)**的并发示例,强调了原子操作(atomic)的行为和可能的内存操作交错(interleaving)。以下是详细分析:
图表内容
代码和线程操作
- 初始状态:
- 两个原子变量:
atomic<int> A(0);
和atomic<int> B(0);
(初始值为0)。
- 两个原子变量:
- 线程操作:
- Thread 1(两个存储操作):
A = 1;
B = 1;
- Thread 2(两个加载操作):
c = B;
d = A;
- Thread 1(两个存储操作):
可能的交错(Possible Interleavings)
- 图中列出了线程操作交错后可能的结果:
- A = 1; B = 1; c = B; d = A;
结果:c = 1, d = 1
(Thread 1 先完成所有操作,Thread 2 后执行)。 - A = 1; c = B; B = 1; d = A;
结果:c = 0, d = 1
(Thread 2 在 Thread 1 的两个操作之间执行)。 - c = B; d = A; A = 1; B = 1;
结果:c = 0, d = 0
(Thread 2 先执行,Thread 1 后执行)。 - c = B; A = 1; d = A; B = 1;
结果:c = 0, d = 1
(Thread 2 在 Thread 1 的两个操作之间执行,与第二种情况类似)。
- A = 1; B = 1; c = B; d = A;
结果矩阵
- 图中有一个表格,展示了Thread 2 中变量
c
和d
的可能值:c = 0, d = 0
:可能(第三种交错)。c = 0, d = 1
:可能(第二种和第四种交错)。c = 1, d = 0
:不可能(用红线标记为 “Impossible!”)。c = 1, d = 1
:可能(第一种交错)。
分析
顺序一致性(Sequentially Consistent)
- 顺序一致性是并发模型中的一种内存模型,要求所有线程的操作看起来像是按照某种全局顺序(total order)执行的,且每个线程内部的操作顺序保持不变。
- 在此例中:
- Thread 1 的操作顺序是固定的:
A = 1
必须先于B = 1
。 - Thread 2 的操作顺序也是固定的:
c = B
必须先于d = A
。 - 原子变量(
atomic<int>
)确保操作是原子的,不会被中断。
- Thread 1 的操作顺序是固定的:
为什么 c = 1, d = 0
不可能?
- 要得到
c = 1, d = 0
的结果:c = B
必须在B = 1
之后执行(因为c = 1
)。d = A
必须在A = 1
之前执行(因为d = 0
)。
- 但这与线程的执行顺序矛盾:
- 在 Thread 1 中,
A = 1
先于B = 1
。 - 在 Thread 2 中,
c = B
先于d = A
。 - 如果
c = B
在B = 1
之后(即c = 1
),那么A = 1
一定已经发生(因为A = 1
在B = 1
之前)。 - 因此,
d = A
不可能在A = 1
之前执行(否则d
应该为 1,而不是 0)。
- 在 Thread 1 中,
- 结论:
c = 1, d = 0
违反了顺序一致性模型的要求,因此是不可能的。
其他结果的合理性
c = 0, d = 0
:Thread 2 在 Thread 1 的任何操作之前执行,符合顺序一致性。c = 0, d = 1
:Thread 2 在A = 1
之后但B = 1
之前执行,符合顺序一致性。c = 1, d = 1
:Thread 2 在 Thread 1 的所有操作之后执行,符合顺序一致性。
总结
- 该示例展示了顺序一致性模型下原子变量的并发行为。
- Thread 1 和 Thread 2 的操作可能以不同方式交错,但由于顺序一致性和线程内操作的固定顺序,某些结果(如
c = 1, d = 0
)是不可能的。 - 这强调了顺序一致性模型在并发编程中的重要性,确保了操作的可预测性和正确性。
该图展示了一个低层次原子操作中的并发问题。两个线程操作两个原子变量A
和B
,初始值为0:
- 线程1:执行
A.store(1, memory_order_relaxed)
和B.store(1, memory_order_relaxed)
,将A
和B
设为1。 - 线程2:执行
c = B.load(memory_order_relaxed)
和d = A.load(memory_order_relaxed)
,读取B
和A
的值。
由于使用了memory_order_relaxed
,没有强制内存顺序,线程1的写入和线程2的读取可能乱序,导致c
和d
的值出现意外组合。表格显示了可能的c
和d
值:
c=0, d=0
:线程2先读取,线程1后写入。c=0, d=1
:线程2读取B
时值为0,但读取A
时已为1(可能!)。c=1, d=0
:线程2读取B
时值为1,但读取A
时为0。c=1, d=1
:线程2读取时线程1已完成写入。
问题在于c=0, d=1
的组合,表明内存操作顺序不一致。解决方法是使用“轻量级栅栏”:
atomic_thread_fence(memory_order_acquire)
:确保后续读取操作看到之前的写入。atomic_thread_fence(memory_order_release)
:确保之前的写入操作对后续操作可见。
通过添加这些栅栏,可以强制内存操作顺序,避免不一致的结果。
你提供的内容涉及**低层级原子操作(low-level atomics)**的可视化理解,特别是在多核 CPU 和各自缓存存在的上下文中,以及各种架构下的原子同步指令。以下是详细解释与总结:
核心概念:低层级原子操作与缓存一致性
① 低层级原子操作 = Relaxed Atomics
C++11 中的:
A.store(1, std::memory_order_relaxed);
c = B.load(std::memory_order_relaxed);
和
B.store(1, std::memory_order_relaxed);
d = A.load(std::memory_order_relaxed);
是完全不保证同步顺序的原子操作。它们保证原子性(不会撕裂),但不保证任何可见性顺序或一致性顺序。
② 每个线程/核心有私有缓存
- 在现代多核架构中,每个核心有自己的 L1/L2 缓存。
- 所以 Thread 1 看到的
A
和 Thread 2 看到的A
,可能是不同的值,直到某种同步机制触发数据同步。
c = 0, d = 0 为什么是可能的?
Thread 1:
A.store(1, relaxed); // 把 A = 1 放进自己缓存
c = B.load(relaxed); // 从自己缓存或主存读取 B = 0Thread 2:
B.store(1, relaxed); // 把 B = 1 放进自己缓存
d = A.load(relaxed); // 从自己缓存或主存读取 A = 0
结果是:
c = 0, d = 0 合法
因为两线程互相的写入都没“同步”到彼此那边去。
缓存模型和同步传播
这展示了一个典型的写缓存在本地未同步到其他核心的情况。最终数据会传播(propagate),但传播的时间不可预测,可能晚于多个操作之后才完成。
架构差异
不同硬件平台对内存同步的支持是不同的:
架构 | 同步指令 | 特点说明 |
---|---|---|
x86/64 | lock xchg , mfence , etc. | 顺序一致性保证较强,store/load 默认就有顺序性。 |
PowerPC | lwsync , hwsync , isync | 弱顺序模型,需要显式同步指令。 |
ARMv7 | dmb , ldr , str | 弱内存模型,读写需要 dmb 同步指令。 |
ARMv8 | ldar , stlr (acquire/release) | 提供较好的语义清晰支持(新 ARM 标准)。 |
Itanium | ld.acq , st.rel | 类似 ARMv8,弱一致性,需要语义标记。 |
总结理解
内容点 | 解释 |
---|---|
Relaxed atomic | 仅保证原子性,不保证顺序,不触发缓存同步。 |
CPU Cache 可见性差异 | 每个核心可能看到不同的变量值,尤其在使用 relaxed 时。 |
同步传播不确定 | 最终一致性可能达成,但时间点不可控。 |
需要 Memory Fence | 使用如 memory_order_seq_cst 或手动加 fence() 才能控制顺序。 |
如果你在开发游戏引擎、低延迟系统或高并发系统,这种底层行为就非常关键 —— 否则会出现难以重现的“幽灵 bug”。 |
你这段内容是关于 如何将游戏引擎中常见的自定义原子操作(Game Atomics) 转换为 C++11 标准中的低层级原子操作(Low-Level Atomics)。下面我会结合你给的对照表,用 完整示例代码 来帮助你更好地理解两者的转换关系。
转换总览:Game Atomics → C++11 Atomics 对应关系
Game Atomics | Low-Level C++11 Atomics |
---|---|
volatile int A; | std::atomic<int> A; |
A = 1; | A.store(1, std::memory_order_relaxed); |
int a = A; | int a = A.load(std::memory_order_relaxed); |
LIGHTWEIGHT_FENCE(); | std::atomic_thread_fence(std::memory_order_acquire); or release |
FULL_FENCE(); | std::atomic_thread_fence(std::memory_order_seq_cst); |
AtomicIncrement(A); | A.fetch_add(1, std::memory_order_relaxed); |
AtomicCompareExchange(A, old, new); | A.compare_exchange_strong(old, new, std::memory_order_relaxed); |
示例对比:实用场景转换
Game Code(伪代码):
volatile int A = 0;
void gameThread() {A = 1;LIGHTWEIGHT_FENCE();int x = A;AtomicIncrement(A);
}
转换为 C++11:
#include <atomic>
std::atomic<int> A(0);
void gameThread() {A.store(1, std::memory_order_relaxed);std::atomic_thread_fence(std::memory_order_release);int x = A.load(std::memory_order_relaxed);A.fetch_add(1, std::memory_order_relaxed);
}
示例:Compare-And-Swap
Game 原子操作:
volatile int status = 0;
if (AtomicCompareExchange(status, 0, 1)) {// 成功从 0 设置为 1,做些事
}
C++11 版本:
std::atomic<int> status(0);
int expected = 0;
if (status.compare_exchange_strong(expected, 1, std::memory_order_relaxed)) {// 成功从 0 设置为 1,做些事
}
注意事项
-
volatile
并不等于atomic
!volatile
只防止编译器优化,不提供原子性。 -
memory_order_relaxed
保证操作是原子的,但不保证顺序 —— 高性能,但也高风险。 -
若需更强的同步语义,使用:
memory_order_acquire
:读取后保证同步memory_order_release
:写入前同步memory_order_seq_cst
:强一致性,最保守
小结
- 游戏中为了性能经常会使用非常轻量的原子操作和内存屏障。
- C++11 原子库能精确表达这些意图,关键在于正确选择
memory_order
。 - 通常推荐:用
relaxed
写性能敏感代码,但要非常清楚同步逻辑!
你贴出的这段内容是关于 如何在单生产者单消费者(SPSC)队列中使用 C++11 原子操作和内存序(memory ordering)来正确同步写入和读取操作。下面我会按顺序逐步解析你提供的示例,并解释各部分的同步语义和作用。
队列结构:CappedSPSCQueue
template <class T, int size>
class CappedSPSCQueue {
private:T m_items[size]; // 环形缓冲区std::atomic<int> m_writePos; // 写指针:由生产者更新,消费者只读int m_readPos; // 读指针:由消费者更新,生产者只读(非原子)public:CappedSPSCQueue() : m_writePos(0), m_readPos(0) {}bool tryPush(const T& item);bool tryPop(T& item);
};
这是一个有界的、无锁的单生产者单消费者队列。它利用以下特性确保线程安全:
- 写入方(生产者)只更新
m_writePos
- 读取方(消费者)只更新
m_readPos
- 只有
m_writePos
是原子变量,因为它会被两个线程同时读写
第一版:使用 atomic_thread_fence
tryPush (写入线程,生产者):
bool tryPush(const T& item) {int w = m_writePos.load(std::memory_order_relaxed);if (w >= size)return false;m_items[w] = item;std::atomic_thread_fence(std::memory_order_release); // 发布屏障m_writePos.store(w + 1, std::memory_order_relaxed); // 使写入可见return true;
}
tryPop (读取线程,消费者):
bool tryPop(T& item) {int w = m_writePos.load(std::memory_order_relaxed); // 读取写指针if (m_readPos >= w)return false;std::atomic_thread_fence(std::memory_order_acquire); // 获取屏障item = m_items[m_readPos];m_readPos++;return true;
}
解释:
release fence
保证在store(m_writePos)
之前写入的数据(m_items[w]
)对其他线程可见acquire fence
保证在读到更新后的m_writePos
之后,后续的读取(m_items[...]
)一定看得到这之前的写入- 这种 fence + relaxed 的组合更接近底层硬件,效率更高,但需要手动同步,非常容易出错
第二版:使用 store/load 的 memory_order
这一版本把同步逻辑移到了原子变量本身,不再单独调用 atomic_thread_fence
。
tryPush:
bool tryPush(const T& item) {int w = m_writePos.load(std::memory_order_relaxed);if (w >= size)return false;m_items[w] = item;m_writePos.store(w + 1, std::memory_order_release); // 释放顺序写入写指针return true;
}
tryPop:
bool tryPop(T& item) {int w = m_writePos.load(std::memory_order_acquire); // 获取顺序读取写指针if (m_readPos >= w)return false;item = m_items[m_readPos];m_readPos++;return true;
}
解释:
store(..., memory_order_release)
保证前面的写入先完成(如m_items[w]
)load(..., memory_order_acquire)
保证如果读到了写入的值,那么之后读取的所有内容都可见- 这对 release/acquire 是一组同步点:当消费者读取到了更新后的
m_writePos
,他一定能看到对应的m_items[w]
总结:何时同步、为何正确
当
tryPop()
读取到由tryPush()
写入的m_writePos
值后,通过 acquire/release 保证它也能看到m_items[...]
的内容。
这种方式非常适合单生产者单消费者模型(SPSC):
- 因为只有一个线程写
m_writePos
,不需要 compare_exchange m_readPos
非原子也没问题,因为没有并发读写它- 比 mutex 更轻量,效率高,适合游戏、音频等场景
如你需要我扩展为 环形缓冲区形式的 SPSC 队列(循环使用),或升级成 多线程安全版本(MPSC、MPMC),我也可以继续帮你写。是否需要?
你这段内容是在讲 如何实现一个“Capped(有界)SPSC(单生产者单消费者)队列”,并用 C++11 原子操作(std::atomic
) 来保证线程安全、避免数据竞争(data race)。以下是对内容的详细 逐句理解和解释:
示例代码分析
bool tryPop(T& item)
{ int w = m_writePos; // 从原子变量中读取写指针(无 memory_order,表示假设默认是 seq_cst)if (m_readPos >= w) // 如果读指针追上写指针,说明队列为空return false; item = m_items[m_readPos]; // 读取数据m_readPos++; // 增加读指针return true;
} bool tryPush(const T& item)
{ int w = m_writePos; // 当前写位置if (w >= size) // 队列满了return false; m_items[w] = item; // 写入数据m_writePos = w + 1; // 更新写指针(atomic 写入)return true;
}
内存模型解释
When the load reads from the store, they synchronize-with each other (§29.3.1).
这是指:
- 如果
tryPop()
中的m_writePos
的读取(load)观察到了tryPush()
中的m_writePos
的写入(store); - 那么这两次操作 形成“同步关系”(synchronize-with);
- 根据 C++11 的内存模型,这种同步意味着:如果写线程在写入
m_items[...]
之后执行了m_writePos.store(...)
,并且读线程从m_writePos.load(...)
看到这个值,那么读线程也一定能看到m_items[...]
的写入结果。
前提是:
m_writePos
是std::atomic<int>
类型,并使用了适当的 memory order(例如store(..., memory_order_release)
和load(..., memory_order_acquire)
)。
alignas(64) 的作用
alignas(64) int m_readPos;
这是一个 性能优化:
m_writePos
和m_readPos
被两个不同线程频繁读写;- 如果它们落在同一个 cache line 中,就可能造成 false sharing(伪共享);
alignas(64)
强制m_readPos
单独对齐在一个新的 cache line 上(通常一行为 64 字节),避免两个线程竞争同一个缓存行,提高并发性能。
为什么其他变量可以是非原子的?
All other variables can remain non-atomic because there is no data race.
m_readPos
只由消费者线程访问m_items[...]
的每个位置仅在写入之后被读取,而且通过m_writePos
的同步机制可见- 所以不需要把这些变量声明为
std::atomic
,从而减少开销
总结:你的理解重点应该是
点 | 内容 |
---|---|
数据同步 | 通过对 m_writePos 使用 std::atomic 并配合 memory_order 保证 item 写入的可见性 |
避免 false sharing | 使用 alignas(64) 隔离 m_readPos |
安全性来源 | SPSC 模型下的写/读职责明确,避免了数据竞争 |
高性能 | 无锁、无 CAS,使用 minimal 原子操作实现高效队列 |