11、内存模型与内存序:C++内存模型基础、happens-before关系、六种内存顺序详解
聊到并发编程,很多人第一反应就是加锁。锁确实好用,但锁不是万能的。你想想看,锁的本质是「互斥」,它保证同一时间只有一个人干活。但有时候我们需要的不是互斥,而是「顺序」——比如线程A写了一个数据,线程B要能正确地读到它。
这就引出了我们今天的主角:内存模型与内存序。说实话,我刚开始接触这块时也是一头雾水,什么 acquire、release、sequentially consistent,感觉像在看天书。直到我在项目中踩了一个多线程数据竞争的大坑,才真正意识到——不懂内存序,写出来的并发代码就是在碰运气。
11.1 C++内存模型基础
C++11 开始,标准库引入了正式的内存模型。说白了,它定义了多线程环境下,不同线程对共享变量的访问规则。没有这个模型,编译器、CPU 都可能对你的代码做各种「优化」,导致你看到的结果完全不符合直觉。
举个例子:
int x = 0;
bool ready = false;
// 线程A
x = 42;
ready = true;
// 线程B
if (ready) {
assert(x == 42); // 一定会成立吗?
}
直觉上,线程B看到 ready == true 时,x 肯定已经被赋值为 42 了。但实际呢?不一定。编译器可能把 x = 42 和 ready = true 的顺序重排,CPU 也可能乱序执行。结果就是:线程B看到 ready == true,但 x 还是 0。
C++ 内存模型的核心目标就是:消除数据竞争。它通过定义 happens-before 关系,让程序员能够精确控制线程间的可见性和顺序性。
11.2 happens-before 关系
happens-before 是理解内存序的关键。它表示两个操作之间的偏序关系:如果操作 A happens-before 操作 B,那么 A 的结果对 B 是可见的,并且 A 的执行顺序在 B 之前。
happens-before 关系由以下几种方式建立:
- 同一个线程内:按代码顺序,前面的操作 happens-before 后面的操作。
- 锁的释放与获取:释放锁 happens-before 获取同一个锁。
- 原子操作的内存序:通过指定内存序,建立跨线程的 happens-before 关系。
- 线程的创建与汇合:
std::thread::join()建立 happens-before 关系。
我个人习惯把 happens-before 想象成「因果链」。A happens-before B,意味着 A 是 B 的「因」,B 是 A 的「果」。只要因果链不断,数据就是一致的。
11.3 六种内存顺序详解
C++ 标准库提供了六种内存顺序,定义在 std::memory_order 枚举中。它们从弱到强排列,控制着原子操作的可见性和顺序性。
| 内存顺序 | 含义 | 典型用途 |
|---|---|---|
memory_order_relaxed |
最弱,只保证原子性,不保证顺序 | 计数器、统计量 |
memory_order_consume |
数据依赖顺序(目前不建议使用) | 几乎不用 |
memory_order_acquire |
读操作,后续读写不能重排到它前面 | 读取标志位 |
memory_order_release |
写操作,前面的读写不能重排到它后面 | 写入标志位 |
memory_order_acq_rel |
同时具备 acquire 和 release 语义 | 读-改-写操作 |
memory_order_seq_cst |
最强,全局顺序一致 | 默认值,通用场景 |
下面我们逐个来看,每个都配合代码示例,让你真正理解它们的区别。
11.3.1 memory_order_relaxed
这是最弱的内存序。它只保证原子操作本身是原子的,但不提供任何顺序保证。不同线程对同一个变量的 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 来统计请求次数。因为计数器只关心最终值,不关心顺序,用 relaxed 性能最好。但要注意:不要用它来同步数据。
11.3.2 memory_order_acquire 与 memory_order_release
这两个是成对出现的。release 写操作保证:该操作之前的所有写操作,对后续 acquire 读操作可见。acquire 读操作保证:该操作之后的所有读/写操作,不会重排到它前面。
说白了,release 是「发布」,acquire 是「订阅」。发布者把数据准备好,然后发布一个标志;订阅者看到标志后,就能安全地读取数据。
std::atomic<bool> ready{false};
int data = 0;
// 线程A(生产者)
data = 42; // 1
ready.store(true, std::memory_order_release); // 2
// 线程B(消费者)
while (!ready.load(std::memory_order_acquire)); // 3
assert(data == 42); // 一定成立!因为 1 happens-before 3
这里的关键是:ready.store(release) 和 ready.load(acquire) 之间建立了 happens-before 关系。所以线程B看到 ready == true 时,data 的值一定是 42。
11.3.3 memory_order_acq_rel
这个就是 acquire 和 release 的合体。常用于 read-modify-write 操作,比如 fetch_add、compare_exchange 等。它既保证前面的写操作不会被重排到后面,也保证后面的读操作不会被重排到前面。
std::atomic<int> val{0};
// 线程A
int prev = val.fetch_add(1, std::memory_order_acq_rel);
// 这个操作既「获取」了之前的写入,又「释放」了本次写入
我记得有一次调试一个无锁队列,就是因为用了 memory_order_relaxed 导致数据不一致。改成 memory_order_acq_rel 后问题就解决了。嗯,这种细节真的很容易忽略。
11.3.4 memory_order_seq_cst
这是最强的内存序,也是 std::atomic 的默认值。它保证所有线程看到的操作顺序完全一致,就像所有操作都在一个全局时钟下执行一样。
std::atomic<bool> flag1{false};
std::atomic<bool> flag2{false};
// 线程A
flag1.store(true, std::memory_order_seq_cst);
// 线程B
flag2.store(true, std::memory_order_seq_cst);
// 线程C
if (flag1.load(std::memory_order_seq_cst)) {
// 此时 flag2 可能为 true 或 false
}
// 线程D
if (flag2.load(std::memory_order_seq_cst)) {
// 此时 flag1 可能为 true 或 false
}
seq_cst 保证了全局一致性,但代价是性能开销最大。在 x86 架构上,seq_cst 的 store 操作会强制插入 mfence 指令,比 relaxed 慢不少。
11.3.5 memory_order_consume
这个比较特殊,它比 acquire 更弱,只保证数据依赖关系。但问题是,目前主流编译器(GCC、Clang、MSVC)都把 consume 实现成了 acquire,而且 C++17 也建议不要使用它。所以我的建议是:直接忽略它,用 acquire 代替。
11.4 知识体系总览
说了这么多,我们来画一张图,把整个知识体系串起来。这样你脑子里能有个整体框架。
11.5 如何选择内存序?
这个问题没有标准答案,但我可以分享一些经验:
- 默认用 seq_cst:先保证正确性,不要过早优化。seq_cst 最容易推理,也最不容易出错。
- 性能敏感时降级:如果分析发现 seq_cst 是瓶颈,再考虑换成 acq_rel 或 acquire-release 对。
- 计数器用 relaxed:只关心最终值的场景,比如统计请求次数、记录日志序号等。
- 标志位用 acquire-release:生产者-消费者模式,用 release 写标志,acquire 读标志。
- 无锁数据结构用 acq_rel:read-modify-write 操作,比如无锁栈、无锁队列的 push/pop。
最后说一句:内存序是 C++ 并发编程中最难的部分之一,但也是最值得花时间搞懂的部分。你想想看,一旦掌握了它,你就能写出既高效又正确的无锁代码,这在很多高性能场景下是巨大的优势。
好了,这一章的内容就到这里。记住:理解 happens-before,你就掌握了并发编程的灵魂。