4. C++内存序详解:六种内存序的实战解读
内存序这个话题,说实话,是并发编程里最容易让人头疼的部分之一。我当年刚接触C++11的原子操作时,看着这六个memory_order,脑子里全是问号。今天咱们就一个一个掰开揉碎了讲清楚。
4.1 为什么需要内存序?
先想一个问题:多线程环境下,一个线程对变量的修改,另一个线程什么时候能看到?
你可能会说「立即看到啊」。但现实没那么简单。编译器会重排指令,CPU会乱序执行,缓存还会延迟写入。这些优化在单线程下没问题,到了多线程就变成了灾难。
内存序,说白了就是给这些优化上「缰绳」。你告诉编译器:这里别乱动,那里可以松一点。
4.2 memory_order_relaxed —— 最宽松的约束
这个模式只保证原子性,不保证任何顺序。我习惯叫它「裸奔模式」。
std::atomic<int> counter{0};
// 线程A
counter.store(1, std::memory_order_relaxed);
counter.store(2, std::memory_order_relaxed);
// 线程B
int val = counter.load(std::memory_order_relaxed);
// val 可能是0, 1, 或2
为什么?因为relaxed只保证这个操作本身是原子的,但其他线程看到的顺序可能和代码顺序不一样。我在项目中遇到过用它做统计计数器——只关心最终值,不关心谁先谁后,完美适用。
4.3 memory_order_acquire 和 memory_order_release
这两个是成对出现的,就像锁的加锁和解锁。release保证:在此之前的写操作,对其他线程的acquire可见。
嗯,这里要注意:acquire和release是「单向屏障」。
std::atomic<bool> ready{false};
int data = 0;
// 线程A - 生产者
data = 42; // 普通写
ready.store(true, std::memory_order_release); // 释放屏障
// 线程B - 消费者
while (!ready.load(std::memory_order_acquire)); // 获取屏障
// 此时 data 一定是42
我曾经在一个消息队列里用过这个模式。生产者写完数据后release,消费者acquire读取。简单高效,比互斥锁轻量多了。
4.4 memory_order_consume —— 被低估的优化选项
这个模式有点特殊。它和acquire类似,但只保证数据依赖的可见性。说白了,它只关心你「直接依赖」的那个变量。
std::atomic<int*> ptr{nullptr};
int value = 0;
// 线程A
value = 42;
ptr.store(&value, std::memory_order_release);
// 线程B
int* p = ptr.load(std::memory_order_consume);
if (p) {
int x = *p; // 这个读是安全的,因为依赖ptr
}
但说实话,我在实际项目中很少用consume。为什么?因为大多数编译器(包括GCC和Clang)实际上把它提升成了acquire。我记得C++17标准里也建议「暂时别用consume,等编译器实现成熟了再说」。
4.5 memory_order_acq_rel —— 读写屏障合体
这个模式是acquire和release的组合。它既保证之前的写操作可见,又保证之后的读操作能看到最新的值。
常用于read-modify-write操作,比如compare_exchange_weak。
std::atomic<int> flag{0};
// 期望的CAS操作
int expected = 0;
if (flag.compare_exchange_weak(expected, 1,
std::memory_order_acq_rel)) {
// 成功获取锁
}
我个人习惯在实现自旋锁时用acq_rel。它比seq_cst性能好,又比单纯的acquire/release更安全。
4.6 memory_order_seq_cst —— 最强约束
这是默认的内存序,也是最严格的。它保证所有线程看到的操作顺序完全一致。你可以理解为:所有线程都按照同一个全局时钟来执行。
std::atomic<int> x{0}, y{0};
// 线程A
x.store(1, std::memory_order_seq_cst);
// 线程B
y.store(1, std::memory_order_seq_cst);
// 线程C
int a = x.load(std::memory_order_seq_cst);
int b = y.load(std::memory_order_seq_cst);
// 不会出现 a==1 && b==0 且另一个线程看到 a==0 && b==1 的情况
你想想看,这个保证很强,但代价也大。在x86上还好,因为x86本身内存模型就比较强。但在ARM或PowerPC上,seq_cst会生成额外的同步指令,性能损失明显。
4.7 六种内存序对比
| 内存序 | 保证强度 | 性能开销 | 典型场景 |
|---|---|---|---|
| relaxed | 仅原子性 | 几乎为零 | 计数器 |
| consume | 数据依赖 | 低 | 指针解引用 |
| acquire | 读屏障 | 中 | 消费者 |
| release | 写屏障 | 中 | 生产者 |
| acq_rel | 读写屏障 | 中高 | CAS操作 |
| seq_cst | 全局顺序 | 高 | 默认选择 |
4.8 知识体系图
下面这张图展示了六种内存序的强度层级和关系:
4.9 实战建议
说了这么多,最后给几条实在的建议:
- 新手先用seq_cst —— 写对了再优化,别一开始就玩花活
- relaxed只用于计数器 —— 别用它做同步,你会后悔的
- acquire/release是黄金组合 —— 80%的场景都能用它们搞定
- consume暂时别碰 —— 等编译器支持好了再说
- acq_rel留给CAS —— 其他场景用acquire/release组合就够了
好了,这六种内存序就讲到这里。记住一个原则:先正确,再高效。并发编程里,正确性永远是第一位的。