C++ 六种 memory_order:从原子性到线程间可见性.md

C++ 六种 memory_order

  • [C++ 六种 memory_order:从"原子性"到线程间可见性](#C++ 六种 memory_order:从“原子性”到线程间可见性)
    • [一、atomic 只保护变量是原子的](#一、atomic 只保护变量是原子的)
    • [二、怎样让 stage == 1 真正代表结果已经就绪](#二、怎样让 stage == 1 真正代表结果已经就绪)
    • [三、六种 memory_order 分别怎么用](#三、六种 memory_order 分别怎么用)
      • [3.1 memory_order_relaxed:只保证原子变量本身](#3.1 memory_order_relaxed:只保证原子变量本身)
      • [3.2 memory_order_release:发布此前完成的写入](#3.2 memory_order_release:发布此前完成的写入)
      • [3.3 memory_order_acquire:接收已经发布的数据](#3.3 memory_order_acquire:接收已经发布的数据)
      • [3.4 memory_order_acq_rel:既接收数据,又发布数据](#3.4 memory_order_acq_rel:既接收数据,又发布数据)
      • [3.5 memory_order_seq_cst:再加一条全局一致顺序](#3.5 memory_order_seq_cst:再加一条全局一致顺序)
      • [3.6 memory_order_consume:工程中通常不再使用](#3.6 memory_order_consume:工程中通常不再使用)
    • 五、实际写代码时应该怎么选
    • [六、memory_order 和内存屏障是什么关系](#六、memory_order 和内存屏障是什么关系)

C++ 六种 memory_order:从"原子性"到线程间可见性

先来看一个再普通不过的问题。

两个线程同时给同一个整数加一,最后的结果一定是 2 吗?

cpp 复制代码
int count = 0;

// 线程 A                 // 线程 B
++count;                  ++count;

答案是不一定。

++count 看起来只有一行,背后却包含"读取旧值、加一、写回新值"三个步骤。两个线程可能同时读到 0,然后分别写回 1。更准确地说,多个线程在没有同步的情况下读写 count 会产生数据竞争,程序行为未定义。

把它改成原子变量以后,问题就解决了:

cpp 复制代码
std::atomic<int> count{0};

count.fetch_add(1);

无论多少线程同时执行 fetch_add,这次"读取---修改---写回"都不会被其他线程从中间打断。

看到这里,一个新的问题就出现了:既然 atomic 已经保证操作不会被打断,为什么它还要提供下面这个参数?

cpp 复制代码
count.fetch_add(1, std::memory_order_relaxed);

为什么 C++ 还设计了 relaxedacquirereleaseacq_relseq_cstconsume 六种不同的内存序?

要回答这个问题,得先看看 atomic 没有解决什么。


一、atomic 只保护变量是原子的

计数器只关心 count 自己,所以保证自增不丢失就够了。但真实程序经常会用一个原子变量表示执行阶段,再根据这个阶段读取其他数据。

例如,线程 A 先把计算结果改成 100,再把阶段改成 1;线程 B 看到阶段 1 后输出结果:

cpp 复制代码
int result = 0;
std::atomic<int> stage{0};

// 线程 A
result = 100;
stage.store(1, std::memory_order_relaxed);

// 线程 B
if (stage.load(std::memory_order_relaxed) == 1)
    std::cout << result;

现在请先不要往下看,想一想:

线程 B 既然已经看到了 stage == 1,它输出的 result 一定是 100 吗?

很多人的第一反应是"一定"。因为在线程 A 的代码里,result = 100 明明写在 stage = 1 前面。

但 C++ 给出的答案是:不能这样推断。

stage 的读写确实是原子的,它不会被读坏。但是 relaxed 只保护 stage 自己,没有在线程 A 写 result 和线程 B 读 result 之间建立同步。对普通变量 result 的这组访问存在数据竞争,程序行为未定义。

这并不是说程序"一定会输出旧值 0"。未定义行为意味着任何结果都不能依赖。根本原因在于:线程 A 中的源代码顺序,只能说明这两行在当前线程里的关系,并没有规定线程 B 必须按照相同顺序观察它们。编译器可能重排指令,CPU 也可能让不同写入以不同时间对其他核心可见。

原来,正确的并发程序需要同时解决三件事:

  • 原子性:一次操作不能做到一半就被另一个线程插入;
  • 有序性:编译器和 CPU 的重排不能破坏程序依赖的先后关系;
  • 可见性:一个线程完成的写入,要能被另一个线程安全地观察到。

atomic 首先解决原子性,memory_order 则进一步描述这次原子操作如何约束前后的内存访问,以及它能否在线程之间建立同步。

C++ 一共提供六种内存序:

它们并不是简单的六档"强弱开关"。acquire 负责接收数据,release 负责发布数据,二者的方向就不同。选择哪一种,取决于这个原子操作在多线程协作中承担什么职责。

那么,怎样才能让 stage == 1 真正代表"结果已经准备好了"?


二、怎样让 stage == 1 真正代表结果已经就绪

我们需要在线程 A 和线程 B 之间完成一次数据交接:

cpp 复制代码
// 线程 A:发布结果
result = 100;
stage.store(1, std::memory_order_release);

// 线程 B:接收结果
if (stage.load(std::memory_order_acquire) == 1)
    std::cout << result;

release 表示:在发布 stage = 1 之前,本线程前面的写入已经准备好一起交给其他线程。

acquire 表示:当我读到 stage == 1 后,可以接收发布这个值的线程在此前完成的写入。

当 acquire load 读取到 release store 写入的 1 时,完整关系是:

text 复制代码
result = 100
    ↓
release store
    ↓ 跨线程同步
acquire load
    ↓
读取 result

于是,线程 A 的 result = 100 happens-before 线程 B 对 result 的读取。此时线程 B 看到的不只是一个阶段数字,还包括线程 A 在发布这个数字之前完成的修改。

这就是 memory_order 真正解决的问题:一个原子变量不仅可以保存自己的值,还可以成为其他数据跨线程传递的交接点。

不过,release 和 acquire 并不是只要同时出现就能自动配对。acquire 必须读取到相应 release 发布的值,这次交接才真正发生。


三、六种 memory_order 分别怎么用

3.1 memory_order_relaxed:只保证原子变量本身

relaxed 是最弱的内存序,但"最弱"不等于"不安全"。它仍然保证原子性,也保证同一个原子对象的修改存在统一的先后顺序。

它不负责同步周围的普通数据。

最典型的场景是统计计数:

cpp 复制代码
std::atomic<int> requests{0};

requests.fetch_add(1, std::memory_order_relaxed);

我们只希望每次加一都不丢失,并不根据 requests 的值判断其他数据是否已经准备完成。因此只要原子性就够了。

适合使用 relaxed 的场景包括:

  • 请求次数、命中次数等统计数据;
  • 不承担同步职责的引用计数;
  • 唯一编号或事件序号的生成;
  • 只关心原子对象自身最终结果的操作。

如果一个原子变量还是其他数据的"就绪信号",通常就不能只使用 relaxed。

3.2 memory_order_release:发布此前完成的写入

release 用在发布数据的一端。

cpp 复制代码
result = 100;
stage.store(1, std::memory_order_release);

这里的含义是:当其他线程通过 acquire 读取到 stage == 1 时,也应该能够看到 result = 100

常见场景包括:

  • 生产者写入就绪标志;
  • 发布已经完成初始化的对象;
  • 把任务或节点交给另一个线程;
  • 释放锁。锁内部通常也需要类似 release 的语义。

release 可以用于原子 store 和读---改---写操作,不能用于普通原子 load,因为 load 没有新值可以发布。

3.3 memory_order_acquire:接收已经发布的数据

acquire 用在接收数据的一端。

cpp 复制代码
if (stage.load(std::memory_order_acquire) == 1)
    use(result);

如果这次 load 读取到了 release 发布的值,那么发布之前的写入就对当前线程可见。

常见场景包括:

  • 消费者读取就绪标志;
  • 读取另一个线程发布的对象指针;
  • 获取锁;
  • 读取某个阶段已经完成的状态。

acquire 可以用于原子 load 和读---改---写操作,不能用于普通原子 store。

release 和 acquire 经常配合使用,可以把它们理解成一次交接:release 负责把数据交出去,acquire 负责把数据接进来。

3.4 memory_order_acq_rel:既接收数据,又发布数据

有些原子操作会先读取旧值,再写入新值,例如 exchangefetch_add 和 CAS。这类操作统称为读---改---写操作。

如果它既要接收上一个线程发布的数据,又要把当前线程的数据发布给下一个线程,就需要 acq_rel

cpp 复制代码
state.compare_exchange_strong(
    expected, next, std::memory_order_acq_rel);

CAS 成功时:

  • acquire 部分负责获取旧状态关联的数据;
  • release 部分负责发布新状态以及此前完成的写入。

常见场景包括:

  • 无锁数据结构中的状态更新;
  • 多个线程依次推进任务阶段;
  • 同时承担"接收上一棒"和"交出下一棒"的 CAS 或 exchange

acq_rel 只能用于读---改---写操作,不能直接用于普通 load 或 store。

3.5 memory_order_seq_cst:再加一条全局一致顺序

seq_cst 是 sequentially consistent 的缩写,中文通常称为顺序一致性。

它包含 acquire/release 的同步能力,还要求所有 seq_cst 原子操作能够放进一条全局一致的顺序中。不同线程可能不知道真实的执行时间,但它们必须对这些原子操作的先后形成一致认识。

更重要的是,seq_cst 是 C++ 原子操作的默认内存序:

cpp 复制代码
stage.store(1);   // 默认 seq_cst
int value = stage.load();

适合使用 seq_cst 的场景包括:

  • 并发代码的第一个正确版本;
  • 同时涉及多个原子变量的复杂协议;
  • 正确性和可维护性比极限性能更重要的代码;
  • 还不能证明较弱内存序一定正确的场景。

它可能比弱内存序限制更多优化,但实际代价与 CPU 架构和操作类型有关,不能简单地认为 seq_cst 一定很慢。

在没有性能数据之前,不要为了"看起来更底层"而主动削弱内存序。

3.6 memory_order_consume:工程中通常不再使用

consume 原本想提供一种比 acquire 更弱的保证:只约束依赖于原子加载结果的后续操作。

cpp 复制代码
Node* node = head.load(std::memory_order_consume);
use(node->value);

这里对 node->value 的访问依赖于先读取到的 node。理论上,编译器只需要维护这条数据依赖链,不必约束所有后续操作。

问题在于,编译器很难在复杂优化中可靠追踪这种依赖。主流实现通常直接把 consume 按 acquire 处理,它也已经在 C++26 中弃用。

因此它没有值得推荐的日常应用场景。理解设计目的即可,新代码通常直接使用 memory_order_acquire

五、实际写代码时应该怎么选

实际工程中,可以按照下面的顺序判断:

  1. 先使用默认的 seq_cst 保证正确性。
  2. 如果只关心原子对象本身,考虑 relaxed
  3. 如果原子变量负责发布其他数据,写端使用 release
  4. 如果原子变量负责接收已发布的数据,读端使用 acquire
  5. 如果读---改---写操作同时需要获取和发布,使用 acq_rel
  6. 新代码通常不使用 consume
  7. 只有性能测试确认这里是瓶颈,并且能够证明同步关系仍然正确时,才削弱内存序。

可以把最常用的选择压缩成一句话:

只管自己用 relaxed,发布用 release,接收用 acquire,两者都要用 acq_rel,拿不准就用 seq_cst。


六、memory_order 和内存屏障是什么关系

memory_order 是 C++ 语言层面的规则,内存屏障则是编译器和 CPU 实现这些规则时可能使用的手段。

同一个 memory_order_release,在不同处理器上可能生成不同的机器指令。x86 的内存模型相对较强,某些 acquire load 和 release store 不需要额外的硬件屏障;ARM 的内存模型更弱,编译器可能选择带有 acquire/release 语义的指令。

所以,把 release 简单理解成"刷新缓存"、把 acquire 理解成"重新读取内存"并不准确。写 C++ 并发代码时,首先应该判断是否建立了正确的 happens-before 关系,而不是猜测某一款 CPU 会怎样操作缓存。

相关推荐
一直C19 分钟前
Linux系统编程|进程间通信IPC全套详解(1)(管道、信号)
linux·c语言·开发语言·网络·青少年编程·visual studio code
十年Java程序媛20 分钟前
SpringBoot3 + JDK17 实战复盘:HikariCP 连接耗尽,虚拟线程场景额外注意事项
java·spring boot
会周易的程序员28 分钟前
企业私有 AI 算力服务器架构设计:异构四节点 + QUIC 微服务
运维·服务器·c++·人工智能·微服务·架构
m0_7345717630 分钟前
深入理解C++ 类型转换<三>const_cast
开发语言·c++
luj_176831 分钟前
合法避税与投资评估实战指南
c语言·开发语言·网络·经验分享·算法
库玛西37 分钟前
Linux网络编程:HTTP/HTTPS协议核心技术全解析
linux·运维·服务器·网络·c++·http·https
sxd20011 小时前
Vmware和multicast
linux·windows·vmware·无线桥接·multicast
暴力求解1 小时前
Linux网络---NAT代理服务、内网穿透
linux·网络·智能路由器
二十雨辰1 小时前
[Java]-JVM面试题
java·开发语言·jvm