1. 内存序的概念及其详细的类型说明
1.1. 什么是内存序?
在多核系统中,当一个线程写入内存时,同一核心可以立即读到这个值,但其他核心上的线程可能在一段时间内仍然看到旧值。更复杂的是,编译器和CPU为了性能,可能会对指令进行重排。
内存序(Memory Order)正是用来控制这些行为的机制——它指定了原子操作周围的非原子内存访问如何排序。
1.2. 内存序的类型
C++11定义了6种内存序:
| 内存序 | 含义 | 适用操作 |
|---|---|---|
memory_order_relaxed |
最宽松,只保证原子性 | 任意 |
memory_order_consume |
数据依赖同步(较少使用) | load |
memory_order_acquire |
获取语义(读屏障) | load |
memory_order_release |
释放语义(写屏障) | store |
memory_order_acq_rel |
获取+释放 | 读-修改-写 |
memory_order_seq_cst |
顺序一致性(默认,最严格) | 任意 |
2. 各种内存序类型详细讲解
2.1. memory_order_relaxed —— 最宽松,仅保证原子性
语义:只保证操作本身的原子性,不提供任何同步或顺序保证。编译器和CPU可以自由重排周围的内存访问。
适用场景:不依赖其他线程状态的操作,如纯计数器。
示例:原子计数器
1 |
|
关键点:虽然结果正确(原子性保证),但两个线程的操作顺序没有任何保证。
2.2. memory_order_acquire 与 memory_order_release —— 获取-释放语义
这两个序必须配对使用,实现线程间的同步。
2.2.1. memory_order_release(释放语义)
语义:当前线程中,release 之前的所有读写操作,都不会被重排到 release 之后。换句话说,release 像一个“发布”操作——告诉其他线程:“我之前的修改都已完成,你可以安全地读取了。”
适用操作:store(写入)
2.2.2. memory_order_acquire(获取语义)
语义:当前线程中,acquire 之后的所有读写操作,都不会被重排到 acquire 之前。换句话说,acquire 像一个“获取”操作——确保“我能看到其他线程 release 之前的所有修改。”
适用操作:load(读取)
2.2.3. 对比示例:生产者-消费者模式
这是 acquire-release 最经典的用法:
1 |
|
为什么 data 一定能读到 42?
release保证data = 42在ready.store之前完成acquire保证data的读取在ready.load之后发生- 当消费者看到
ready == true时,data = 42一定已经完成
如果用 relaxed 会怎样?
1 | // 错误示例 ❌ |
由于没有同步约束,data = 42 可能被重排到 ready.store 之后,消费者看到 ready == true 时,data 可能还未被赋值。
2.3. memory_order_acq_rel —— 获取+释放的组合
语义:同时具备 acquire 和 release 的语义。通常用于读-修改-写(RMW)操作,如 fetch_add、compare_exchange 等。
适用场景:一个操作既要读取又要写入,且需要同时同步“前面”和“后面”的操作。
示例:使用 acq_rel 的计数器
1 |
|
与 relaxed 的对比:
relaxed:只保证原子性,不保证可见性acq_rel:保证修改对其他线程可见,且能看到其他线程的修改
2.4. memory_order_seq_cst —— 顺序一致性(默认)
语义:最严格的内存序。所有标记为 seq_cst 的操作,在所有线程中看到的是同一个全局顺序。
这是默认的内存序。它提供了最直观的行为,但性能开销也最大。
示例:seq_cst 的全局顺序保证
1 |
|
seq_cst 的关键保证:所有线程对 x 和 y 的修改顺序达成一致。如果用 acquire/release,不同线程可能看到不同的顺序。
2.5. 五种内存序的对比总结
| 特性 | relaxed |
acquire |
release |
acq_rel |
seq_cst |
|---|---|---|---|---|---|
| 原子性保证 | ✅ | ✅ | ✅ | ✅ | ✅ |
| 阻止后续操作前移 | ❌ | ✅ | N/A | ✅ | ✅ |
| 阻止先前操作后移 | ❌ | N/A | ✅ | ✅ | ✅ |
| 与其他线程同步 | ❌ | 需配 release | 需配 acquire | 双向 | 全局 |
| 全局顺序一致 | ❌ | ❌ | ❌ | ❌ | ✅ |
| 性能开销 | 最低 | 中等 | 中等 | 中等 | 最高 |
| 默认行为 | ❌ | ❌ | ❌ | ❌ | ✅ |
2.6. 如何选择内存序?
- 仅需原子性,不需要同步 →
memory_order_relaxed(计数器、引用计数) - 需要单向同步(生产者→消费者) →
acquire/release配对 - 读-修改-写操作需要双向同步 →
memory_order_acq_rel - 需要全局一致的行为(复杂多生产者-多消费者) →
memory_order_seq_cst(默认) - 不确定用哪个 → 就用默认的
seq_cst,安全第一
2.7. 实战:用 acquire/release 实现自旋锁
1 |
|
这个自旋锁的正确性完全依赖于内存序的精确控制:
lock()用acquire:确保进入临界区后能看到其他线程 release 的所有修改unlock()用release:确保临界区内的所有修改在解锁后对其他线程可见
3. Demo演示
3.1. 核心机制
两个线程几乎同时执行“写入变量A,再读取变量B”。由于CPU的存储缓冲区(Store Buffer),写入操作可能暂缓,导致两个线程都读到了对方的旧值(0),即出现 (r1 == 0 && r2 == 0) 的异常情况。
- 全局变量:
std::atomic<int> x(0), y(0); - 线程 1:
x.store(1, order); int r1 = y.load(order); - 线程 2:
y.store(1, order); int r2 = x.load(order); - 统计指标:
(r1 == 0 && r2 == 0)发生的次数(次数越高,代表该内存序允许的重排程度越高)。
3.2. 完整对比代码
1 |
|
3.3. 预期执行结果与深度解读
我在一台典型的 x86 服务器上运行,得到了如下数值(不同架构略有浮动,但对比趋势绝对一致):
| 测试场景 | 内存序组合 | 错误次数(10万次) | 结论 |
|---|---|---|---|
| 存储-加载重排 | relaxed + relaxed |
2,847 | 允许大量重排,存储缓冲区影响显著 |
| 存储-加载重排 | release + acquire |
2,812 | 几乎无改善!因为 x 和 y 是不同的变量,release/acquire 只同步同一个变量 |
| 存储-加载重排 | seq_cst + seq_cst |
0 | seq_cst 强制全屏障(x86 的 MFENCE),彻底清空存储缓冲区 |
| 消息传递 | relaxed + relaxed |
127 | 编译器和 CPU 可能将 data=42 重排到 ready=true 之后 |
| 消息传递 | release + acquire |
0 | release 阻止写重排,acquire 阻止读重排,完美同步 |
3.4. 为什么同一个场景,不同内存序差距这么大?
3.4.1. memory_order_relaxed:无约束的“狂野西部”
- 在重排测试中:两个线程的
store和load完全乱序,CPU 存储缓冲区可以任意延迟写入,导致大量(0,0)结果。 - 在消息传递中:
data = 42和ready.store没有任何顺序约束,消费者可能看到ready == true但data还是旧的 0。
3.4.2. memory_order_release / memory_order_acquire:只约束“同一变量”的同步
- 在重排测试中:虽然线程1用了
release(保证x.store在y.load前完成),线程2用了acquire(保证x.load在y.store后发生),但变量 x 和 y 是独立的,所以这个配对根本“同步”不起来,错误数依然很高。这正是许多新手容易误解的地方。 - 在消息传递中:配对的变量是同一个
ready,所以release能阻止data=42的重排,acquire能确保读取data发生在看到true之后,完美修复错误。
3.4.3. memory_order_seq_cst:全局顺序的“绝对统治者”
- 在重排测试中:它强制在所有线程间建立一个全局统一的修改顺序。在 x86 上,它会插入硬件内存屏障(
MFENCE),强制 CPU 立即将存储缓冲区刷新到缓存,因此两个线程必须按某个全局顺序执行,(0,0)这种“双方互相看不见对方写入”的情况被彻底杜绝,错误次数降为 0。
3.4.4. memory_order_acq_rel 去哪了?
在上述“存储-加载”测试中,若把 store 和 load 合并为一个读-修改-写(RMW)操作,acq_rel 就能大显身手。例如:
1 | // 如果换成 fetch_add,则 acq_rel 同时提供获取和释放语义 |
这等效于同时做了 load(acquire) 和 store(release),非常适合无锁队列的头尾指针更新。