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 = 42ready = 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 的「果」。只要因果链不断,数据就是一致的。

小技巧: 写并发代码时,我经常问自己一个问题:「这个变量的写操作,和另一个线程的读操作之间,有没有 happens-before 关系?」如果没有,那就是数据竞争,得改。

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。

核心理解: release-acquire 组合是「单向屏障」。release 防止前面的写操作跑到它后面,acquire 防止后面的读操作跑到它前面。两者配合,就形成了一个同步点。

11.3.3 memory_order_acq_rel

这个就是 acquire 和 release 的合体。常用于 read-modify-write 操作,比如 fetch_addcompare_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 慢不少。

避坑指南: 我曾经在一个高性能网络库中,把所有原子操作都用了 seq_cst。结果压测时发现性能比预期差很多。后来分析发现,大部分场景其实只需要 acquire-release 语义。换成 acq_rel 后,性能提升了 30%。所以我的建议是:默认用 seq_cst 保证正确性,性能瓶颈时再考虑降级

11.3.5 memory_order_consume

这个比较特殊,它比 acquire 更弱,只保证数据依赖关系。但问题是,目前主流编译器(GCC、Clang、MSVC)都把 consume 实现成了 acquire,而且 C++17 也建议不要使用它。所以我的建议是:直接忽略它,用 acquire 代替

11.4 知识体系总览

说了这么多,我们来画一张图,把整个知识体系串起来。这样你脑子里能有个整体框架。

C++ 内存模型与内存序知识体系 C++ 内存模型 原子性 · 可见性 · 顺序性 happens-before 关系 六种内存顺序 relaxed(最弱) consume(不推荐) acquire(读) release(写) acq_rel(读写) seq_cst(最强) 越往上越弱(性能好),越往下越强(保证多) 选择原则:够用就好,不要过度保证

11.5 如何选择内存序?

这个问题没有标准答案,但我可以分享一些经验:

  • 默认用 seq_cst:先保证正确性,不要过早优化。seq_cst 最容易推理,也最不容易出错。
  • 性能敏感时降级:如果分析发现 seq_cst 是瓶颈,再考虑换成 acq_rel 或 acquire-release 对。
  • 计数器用 relaxed:只关心最终值的场景,比如统计请求次数、记录日志序号等。
  • 标志位用 acquire-release:生产者-消费者模式,用 release 写标志,acquire 读标志。
  • 无锁数据结构用 acq_rel:read-modify-write 操作,比如无锁栈、无锁队列的 push/pop。
个人经验: 我一般先写出最直观的 seq_cst 版本,跑通所有测试用例。然后做性能分析,如果发现原子操作是热点,再逐个分析能否降级。每次降级后都要重新跑一遍所有测试,确保没有引入数据竞争。这个过程虽然繁琐,但很稳妥。

最后说一句:内存序是 C++ 并发编程中最难的部分之一,但也是最值得花时间搞懂的部分。你想想看,一旦掌握了它,你就能写出既高效又正确的无锁代码,这在很多高性能场景下是巨大的优势。

好了,这一章的内容就到这里。记住:理解 happens-before,你就掌握了并发编程的灵魂


公众号:蓝海资料掘金营,微信deep3321