原子操作与内存序
多线程编程中,数据竞争是最隐蔽的 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 编译器重排
编译器在单线程视角下,可能调整不相关指令的顺序:
// 源代码
ready = true; // A
value = 42; // B
// 编译器可能重排为:先执行 B 再执行 A(单线程看结果一样)
value = 42; // B
ready = true; // A单线程没问题,但多线程场景下,另一个线程可能看到 ready == true 但 value 还是旧值。
2.2 CPU 乱序执行
现代 CPU 采用超标量流水线,指令可能乱序执行。Store Buffer 和 Invalidate Queue 的机制让写操作异步完成,不同核心看到内存状态的时机不同。
内存序的本质:在特定位置插入屏障(barrier),阻止编译器和 CPU 把某些指令搬到屏障的另一边。
3. 内存序的四种模式
C++11 定义了六种 memory_order,但常用的就这四种:
3.1 memory_order_relaxed — 最宽松
只保证原子性,不保证顺序。适用于:
- 纯计数器(只关心最终值,不关心中间可见性)
- 统计类变量(如请求计数)
std::atomic<int> counter{0};
counter.fetch_add(1, std::memory_order_relaxed); // 只计数,无同步需求3.2 memory_order_acquire — 获取语义
用于读操作。建立一道"向上"的屏障:
- 保证本次读之后的所有读写,不会被重排到本次读之前
典型用法:读取一个标志位,然后访问该标志位保护的数据。
while (!flag.load(std::memory_order_acquire)) // ①
;
use(data); // ② 保证看到 data 的最新值3.3 memory_order_release — 释放语义
用于写操作。建立一道"向下"的屏障:
- 保证本次写之前的所有读写,不会被重排到本次写之后
典型用法:先准备好数据,再设置标志位通知其他线程。
data = 42; // ①
flag.store(true, std::memory_order_release); // ② 保证 data=42 先完成3.4 memory_order_acq_rel — 获取+释放
用于读-改-写操作(如 fetch_add)。同时具备:
- 作为"读"的 acquire 语义(阻止后续操作提前)
- 作为"写"的 release 语义(阻止前面操作延后)
// 典型场景:引用计数增减,同时需要看到之前的数据修改
// 也需要保证自己的修改对后续读者可见
ref_count.fetch_add(1, std::memory_order_acq_rel);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)