原子操作与内存序
多线程编程中,数据竞争是最隐蔽的 bug 来源。C++11 的
std::atomic提供了原子变量,但真正的复杂性藏在memory_order参数里。本文从 CPU 缓存和编译器优化的视角,解释为什么原子操作需要内存序,以及 acquire/release/relaxed 等序的区别。
1. 为什么需要原子操作
1.1 非原子操作的风险
一个简单的自增操作 counter++,在 C++ 层面是一行代码,编译后却是三步:
1. LOAD counter 到寄存器
2. ADD 寄存器 + 1
3. STORE 寄存器回 counter两个线程同时执行时,指令可能交错:
时间线:
T0: counter = 0
T1 线程A: LOAD counter (0)
T2 线程B: LOAD counter (0) ← 读到相同的值
T3 线程A: ADD → 1
T4 线程A: STORE counter = 1
T5 线程B: ADD → 1
T6 线程B: STORE counter = 1 ← 覆盖了线程A的结果
结果: counter = 1,但期望是 2这种读-改-写操作需要硬件级别的原子保证。std::atomic 强制使用 CPU 的原子指令(如 x86 的 LOCK XADD),确保中间不会被其他线程打断。
1.2 原子操作的基本形式
std::atomic<T> 提供三类核心操作:
| 操作 | 方法 | 语义 |
|---|---|---|
| 原子读 | load() | 读取完整值,不会看到半新半旧 |
| 原子写 | store(x) | 写入完整值,对其他线程立即可见(带序约束) |
| 读-改-写 | fetch_add(x) / fetch_sub(x) / exchange(x) | 原子地完成读、计算、写回 |
fetch_add 返回的是修改前的旧值,这个设计让调用者可以判断是否触发了某种边界条件(如从 0 变 1,或从 1 变 0)。
2. 原子性之外:为什么需要内存序
原子操作解决了数据完整性问题(不会读到撕裂值),但没解决可见顺序问题。编译器和 CPU 为了性能,会在不改变单线程结果的前提下重排指令。
2.1 As-If 规则:单线程的屏障
C++ 标准规定:只要程序的执行结果与严格按照代码顺序执行的结果一致,编译器和运行时(CPU)可以任意优化。
这就是 As-If 规则(as if the abstract machine were fully obeyed)。
int a = 1; // ①
int b = 2; // ②
int c = a + b; // ③编译器可以:
- 先执行 ② 再执行 ①(无数据依赖)
- 合并成
c = 3直接初始化 - 甚至删除
a和b的存储,只用寄存器
单线程视角:这些优化完全不可见,程序行为一致。
多线程视角:问题出现了——另一个线程可能在 ① 完成前就看到了 ② 的结果,或者 c 的计算看不到最新的 a 和 b。
2.2 编译器重排:跨函数的视野
编译器在单线程视角下,会激进地重排不相关指令:
// 源代码
void thread_a() {
value = 42; // ①
ready = true; // ②
}
void thread_b() {
while (!ready) ; // ③
assert(value == 42); // ④
}编译器分析 thread_a 时发现:① 和 ② 之间没有数据依赖(不读写同一变量),单线程重排不影响结果。于是生成:
// 编译后可能的顺序
void thread_a() {
ready = true; // ② 先执行!
value = 42; // ① 后执行
}多线程执行时,线程 B 可能在 ① 完成前就看到 ready == true,导致 ④ 断言失败。
关键洞察:As-If 规则只保证单线程视角正确,不管多线程视角。
2.3 CPU 乱序执行:硬件层面的重排
现代 CPU 采用超标量流水线,指令级并行(ILP)追求每个时钟周期执行多条指令:
时钟周期: 1 2 3 4 5
指令①: LOAD ADD STORE
指令②: LOAD ADD STORE指令 ② 可能在指令 ① 之前完成,只要它们操作不同地址。
Store Buffer 与 Invalidate Queue:
CPU 不直接写内存,而是先写入 Store Buffer(写缓冲区):
- 写操作异步完成,CPU 继续执行后续指令
- 另一个核心通过缓存一致性协议(MESI)获取最新值时,可能有延迟
内存序的本质:
内存序不是为了控制单线程内的执行顺序(As-If 规则已经保证单线程结果正确),而是为了控制跨线程的可见性——即线程 A 的操作,何时能被线程 B 看到。
| 维度 | 保证来源 | 控制范围 |
|---|---|---|
| 单线程执行顺序 | As-If 规则 | 代码逻辑 |
| 跨线程看到操作的顺序 | 内存序 | 可见性 |
| 跨线程看到数据的值 | acquire/release 配对 | 数据同步 |
内存序在特定位置插入屏障(barrier),阻止编译器和 CPU 把某些指令搬到屏障的另一边,从而建立跨线程的 happens-before 关系。
3. 六种内存序
C++11 定义了六种 memory_order,按约束强度从弱到强排列:
relaxed < consume < acquire < release < acq_rel < seq_cst3.1 memory_order_relaxed — 最宽松
只保证原子性,不保证任何顺序。
编译器和 CPU 可以随意重排 relaxed 操作周围的指令,只要单线程语义正确即可。
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed);适用场景:
- 纯计数器(只关心最终值,不关心中间状态何时可见)
- 性能统计(如请求数、延迟直方图)
3.2 memory_order_consume — 数据依赖(C++17 起弃用)
特殊且已弃用,但了解它有助于理解内存序的设计。
consume 是 acquire 的弱化版:只保证依赖于该原子值的读写不会被重排,无关操作可以乱序。
// 典型模式:用指针传递数据
std::atomic<Node*> head;
Node* node = head.load(std::memory_order_consume); // 只约束对 node 的依赖操作
// use(node->data) 保证看到正确值
// use(other_data) 可以乱序为什么弃用:
- 实际硬件(ARM、PowerPC)实现 consume 和 acquire 开销相同
- 编译器优化 consume 的复杂度远高于收益
- C++17 标记为废弃,建议直接用
acquire替代
3.3 memory_order_acquire — 获取语义
用于读操作。建立一道屏障:
- 后续的所有读写,不能重排到本次读之前
- 即:看到标志后,能看到标志之前准备的所有数据
while (!flag.load(std::memory_order_acquire))
;
use(data); // 保证看到 data 的最新值3.4 memory_order_release — 释放语义
用于写操作。建立一道屏障:
- 前面的所有读写,不能重排到本次写之后
- 即:准备好所有数据后,再发信号通知
data = 42;
flag.store(true, std::memory_order_release); // 保证 data=42 对其他线程可见3.5 memory_order_acq_rel — 获取+释放
用于读-改-写操作(fetch_add, fetch_sub, exchange, compare_exchange)。
同时具备:
- acquire 语义:作为"读"阻止后续操作提前
- release 语义:作为"写"阻止前面操作延后
// 引用计数增减的典型用法
ref_count.fetch_add(1, std::memory_order_acq_rel);3.6 memory_order_seq_cst — 顺序一致性
最强约束。默认序(不写参数时就用它)。
提供两个保证:
- 原子性:操作是原子的
- 全局顺序:所有线程对 seq_cst 操作的执行顺序有一致的看法
flag.store(true); // 默认 seq_cst
if (flag.load()) // 默认 seq_cst
use(data);开销来源:
- x86 需要
MFENCE或LOCK前缀,刷新 Store Buffer - ARM 需要
dmb ish屏障,同步到所有核心 - 禁止编译器重排任何相关指令
对比表:
| 序 | 原子性 | 编译器重排 | CPU 乱序 | 全局一致 | 典型开销 |
|---|---|---|---|---|---|
| relaxed | ✓ | 任意 | 任意 | ✗ | 无额外开销 |
| consume | ✓ | 依赖外可排 | 依赖外可乱 | ✗ | 同 acquire |
| acquire | ✓ | 后不能上前 | 后不能上前 | ✗ | 轻量屏障 |
| release | ✓ | 前不能下后 | 前不能下后 | ✗ | 轻量屏障 |
| acq_rel | ✓ | 双向约束 | 双向约束 | ✗ | 两个屏障 |
| seq_cst | ✓ | 完全禁止 | 完全禁止 | ✓ | 全局同步 |
4. Acquire-Release 配对:同步的本质
单用 acquire 或 release 没有意义,必须配对使用才能建立跨线程的 happens-before 关系。
4.1 一个完整的同步示例
线程 A 准备数据,线程 B 等待并消费:
std::atomic<bool> ready{false};
int data = 0;
// 线程 A: 生产者
void producer() {
data = 42; // ①
ready.store(true, std::memory_order_release); // ② release
}
// 线程 B: 消费者
void consumer() {
while (!ready.load(std::memory_order_acquire)) // ③ acquire
;
assert(data == 42); // ④ 保证成立
}为什么 ④ 一定成立?
- ② 的 release 保证 ① 不会排到 ② 之后
- ③ 的 acquire 保证 ④ 不会排到 ③ 之前
- 当 ③ 读到
true时,happens-before 链已经形成:① → ② → ③ → ④
4.2 用围栏(fence)直观理解
线程 A 视角:
data = 42
↓
[release fence] ← 前面的不能下去
↓
ready.store(true)
线程 B 视角:
ready.load() → 读到 true
↓
[acquire fence] ← 后面的不能上来
↓
use(data)两道围栏在原子变量处"咬合",形成同步点。
5. 常见陷阱
5.1 混合使用不同序
// 危险:A 用 release,B 用 relaxed
flag.store(true, std::memory_order_release);
// ...
if (flag.load(std::memory_order_relaxed)) // 可能看不到正确的 data
use(data);relaxed 读无法与 release 写建立同步,data 的可见性无保证。
5.2 忘记序参数(默认 seq_cst)
flag.store(true); // 默认 memory_order_seq_cstseq_cst(顺序一致性)确实最安全,但开销最大(需要全局总线同步)。大多数场景用 acquire/release 已经足够。
5.3 用 volatile 替代 atomic
volatile int counter = 0;
counter++; // 不是原子的!只是告诉编译器不要优化volatile 只影响编译器优化,不提供:
- 原子性(CPU 仍可能拆成多条指令)
- 内存序(无屏障)
多线程场景必须用 std::atomic。
6. 快速选择指南
| 场景 | 推荐序 | 说明 |
|---|---|---|
| 纯计数器 | relaxed | 只关心最终数值 |
| 读取标志位后访问数据 | acquire | 与 release 写配对 |
| 设置标志位通知其他线程 | release | 与 acquire 读配对 |
| 引用计数增减 | acq_rel | 既要看到旧状态,也要让新状态可见 |
| 多生产者多消费者队列 | seq_cst | 复杂同步,保守选择 |
Copyright
Copyright Ownership:Pray0
License under:Attribution 4.0 International (CC-BY-4.0)