C++ 内存序与乱序执行(高密度实战版)
面向“已经在写 lock-free / 多线程,希望彻底搞懂内存序”的自己。
目标:
- 看一段并发代码,30 秒内判断是否有 data race / 顺序问题;
- 知道该选哪种
memory_order,以及为什么。
一、主线:各种 memory_order 的关系
先把大局排清楚,再看细节。
-
从“约束强度”上,可以粗略排成一条链:
[ \text{relaxed} ;<; \text{acquire/release} ;<; \text{acq_rel} ;<; \text{seq_cst} ]
-
但要注意:
acquire/release不是谁比谁“更强”,而是方向不同:release:约束“前面的别跑到后面”;acquire:约束“后面的别跑到前面”。
acq_rel:两边都管(典型用于 RMW 原子操作)。seq_cst:在 acquire/release 之上,再加一个全局统一顺序感。
可以用一组“承诺”来记忆:
relaxed:我只承诺这次访问本身是原子的,别拿我做同步边界。release:我承诺把我之前的状态按顺序发布出去,但不负责接收别人。acquire:我承诺一旦看到了别人发布的信号,我之后的读都要跟上,但我不负责往外发布。acq_rel:我两边都承诺(典型是“读旧状态 + 写新状态”的边界)。seq_cst:不仅两边承诺,还尽量让大家看到一个单一的全局顺序。
二、四层顺序模型:从源码到别人眼里
所有内存序问题,最后都要落到“别的线程看到的顺序”。先把四层分开:
- Program Order(源码顺序)
- 你写的先后:
data = 1; flag = true;
- 你写的先后:
- Compiler Order(编译器重排)
- 只要不破坏单线程可观察行为,编译器可以随意重排 load/store。
- CPU / Memory Order(CPU + 内存子系统)
- out-of-order 执行、store buffer、cache coherence 延迟等。
- Observed Order(其他核心实际观察到的顺序)
- 真正决定并发正确性的,是别的线程最终以什么顺序看到这些写。
memory_order 的使命:在第 2/3 层上画红线,让第 4 层看起来像你期望的顺序。
三、五个基础概念:race / atomicity / ordering / visibility / sync
3.1 Data Race(数据竞争)
- 条件(简化版):
- 至少两个线程;
- 并发访问同一内存位置;
- 至少一个是写;
- 且没有同步。
- 结果:C++ 直接 UB,所有推理全部失效。
3.2 Atomicity(原子性)
- 单次访问不可撕裂,遵守 atomic 类型规则。
- 解决的是“这次读/写是否合法的并发访问”。
- 原子性 ≠ 正确同步:
counter.fetch_add(1, memory_order_relaxed)是合法并发;- 但它不提供任何“谁先谁后看到更新”的顺序保证。
3.3 Ordering(顺序约束)
- 约束当前线程内普通读写相对于某次 atomic 的先后关系是否允许重排:
- 防止“payload 写到 flag 之后”、“先读 flag 再读到旧 payload”之类的问题。
3.4 Visibility(可见性)
- 让别的线程在命中某个同步条件时(例如读到某个值),必然看到你之前的写。
3.5 Synchronization(同步 / happens-before)
- 通过
release/acquire、锁、join、条件变量等建立 happens-before:- 若 A happens-before B,则 B 必须看到 A 的所有效果。
- 只要这条边存在,语言就保证顺序 + 可见性;没有,就别相信自己的“肉眼直觉”。
四、每种 memory_order 到底在限制什么?
核心思路:
memory_order限制的是“这个 atomic 周围的访问允许怎样重排,以及跨线程如何形成同步”,
不是只管这个 atomic 自己,而是管它附近的普通读写能不能穿过去。
4.1 memory_order_relaxed
给你:
- ✅ 原子性;
- ✅ 避免 data race(前提:所有访问都通过 atomic);
- ✅ 单独看这次访问是合法并发。
不给你:
- ❌ 跨线程同步;
- ❌ 发布其他数据;
- ❌ 建立 happens-before;
- ❌ 任意顺序保证。
所以 relaxed 的本质是:
“这个变量要原子访问,但我不拿它当同步点。”
典型适用场景:
- 纯统计计数器:请求数 / 丢包数 / 命中次数 / debug 计数;
- 这些值只是“自己要准”,不是“看到它变化就说明别的东西也准备好了”。
4.2 memory_order_release
- 常用于 store。
- 约束:
- 当前线程中,release 之前的读写不能被重排到 release 之后。
- 本质作用:
- 发布之前准备好的状态。
可以读作:
“我在这个 release store 之前做的那些写,现在可以一起对外宣布了。”
典型场景(发布 ready flag / pointer / 状态):
payload = compute();
metadata = ...;
flag.store(true, std::memory_order_release);
语义:任何通过相应 acquire 看到 flag == true 的线程,都必须也能看到这些 payload / metadata 的写。
4.3 memory_order_acquire
- 常用于 load。
- 约束:
- 当前线程中,acquire 之后的读写不能被重排到 acquire 之前。
- 本质作用:
- 接收别人发布的状态。
可以读作:
“如果我通过这个 acquire load 观察到了某个已发布信号,那后续访问必须能看到那批被发布的数据。”
典型场景(等待 ready flag / 读取已发布指针等):
if (flag.load(std::memory_order_acquire)) {
use(payload); // payload 必须是发布方 release 前写好的版本
}
4.4 release + acquire 的配对关系
release负责“推出去”,acquire负责“接进来”;- 它们配合后形成的不是“感觉上同步”,而是真正的 happens-before 边。
理解方式:
release= 发布完成;acquire= 确认接收完成。
没有这对边,绝大多数“flag 通知 + 读数据”的写法都不合法。
4.5 memory_order_acq_rel
- 用于既读又写的原子操作(RMW):
fetch_add/exchange/compare_exchange等。
- 同时具备:
acquire:接收之前别人的发布;release:把自己之前的写继续发布出去。
典型场景:
- lock-free 队列 / freelist 更新头指针;
- 自旋锁状态切换;
- work stealing 队列的 ticket 递增。
当一个原子操作既是“观察旧状态”,又是“写入新状态”的边界时,acq_rel 通常是合理默认。
4.6 memory_order_seq_cst
- 最强,也最接近人脑直觉。
- 包含 acquire/release 语义,且额外要求:
- 所有
seq_cst原子操作在全局上形成一个单一一致的顺序。
- 所有
可以这么理解:
“所有
seq_cst原子访问排成了一条全局时间线,所有线程看到的顺序是一致的。”
适用场景:
- 第一次实现 / 调试阶段:先写对再说;
- 全局标志 / 全局状态切换;
- 对可读性和保守正确性优先的代码。
seq_cst 减少推理负担,是最容易“想清楚”的内存序。
五、通信模式与推荐写法
5.1 单向“发布数据 + 就绪标志”(最常见)
// 生产者
payload = compute();
ready.store(true, std::memory_order_release);
// 消费者
while (!ready.load(std::memory_order_acquire)) { /* spin */ }
use(payload);
保证:
- 生产者:
payload写在 release 之前,不能被重排到store(release)之后; - 消费者:所有在 acquire 之后的读(包括
payload)不能被重排到 acquire 之前; - 若
load(acquire)看到的是发布方store(release)写入的那个值,则消费者必须看到发布方 release 之前的所有写。
这是最常见、最重要的通信模式:发布-订阅。
5.2 只要计数,不做同步
std::atomic<uint64_t> requests{0};
void on_request() {
requests.fetch_add(1, std::memory_order_relaxed);
}
- 只关心“最终计数是否合理”,不拿这个计数做“是否可以开始处理”的判断;
relaxed即可:原子性 + 最小顺序约束。
5.3 双缓冲 / 指针切换读取配置
struct Config { /* ... */ };
std::atomic<Config*> global_cfg{nullptr};
// 更新线程
void reload() {
Config* cfg = new Config(load_from_file());
global_cfg.store(cfg, std::memory_order_release);
}
// 工作线程
void worker() {
Config* cfg = global_cfg.load(std::memory_order_acquire);
if (cfg) use(*cfg);
}
- 指针是“信号 + 入口”,配置结构体是 payload;
- release/acquire 确保只要看到新指针,就能看到新配置的完整内容。
5.4 自旋锁(示意)
std::atomic<bool> locked{false};
void lock() {
bool expected = false;
while (!locked.compare_exchange_weak(
expected, true,
std::memory_order_acquire, // 成功获得锁:需要 acquire
std::memory_order_relaxed // 失败:只是重试
)) {
expected = false;
}
}
void unlock() {
locked.store(false, std::memory_order_release);
}
- 获得锁的一刻,需要 acquire,把临界区之前的写“接收”进来;
- 释放锁时需要 release,把临界区的写发布给后续持锁者;
- RMW 操作常用 acq_rel,这里用“成功 acquire / 失败 relaxed”更精确表达意图。
六:默认 seq_cst vs 显式 memory_order:到底在“优化”什么?
6.1 默认不写时是什么?
如果你写:
x.store(v);
x.load();
x.fetch_add(1);
不写 memory_order,默认就是 seq_cst(最强)。
6.2 写上显式 memory_order 在干什么?
当你写:
x.store(v, std::memory_order_release);
x.load(std::memory_order_acquire);
counter.fetch_add(1, std::memory_order_relaxed);
你在告诉编译器和 CPU:
“我不需要
seq_cst那么强,只需要这些更窄的保证。”
本质上就是:
- 减少约束 → 编译器可重排空间更大,必要的 fence / 屏障更少 → 潜在性能更好。
6.3 不是所有“explicit”都是在优化
区分三类:
- A. 默认
seq_cst→ 显式seq_cst- 只是写明白,并未放松约束,不是优化。
- B. 默认
seq_cst→acquire/release/acq_rel- 在放松约束,通常有性能收益,也更贴近真实语义。
- C. 默认
seq_cst→relaxed- 大幅度放松约束,常用在性能敏感的计数/指标,但也最容易写错。
所以“写 explicit”≠“一定更快”;
真正优化的是“把同步语义放弱到刚好够用”。
6.4 为什么放弱能优化?
强顺序意味着:
- 编译器可做的重排更少;
- CPU 需要更强的排序约束;
- 某些平台上要插更重的 barrier;
seq_cst还要维护全局线性顺序。
在 ARM / Power 等弱内存模型平台,这些开销差异会很明显;
在 x86 上有时差异没那么大,但语义差异依旧存在,不能因此忽略。
6.5 什么时候值得从 seq_cst 往下调?
最好同时满足:
- 你已经明确知道同步协议是什么:
- 哪个变量在发布,哪个在接收。
- 这是热点路径:
- 高频队列、撮合、计数核心循环等。
- 你能证明更弱语义仍然正确:
- 不是“测起来没问题”,而是能画出 happens-before 图,自证正确。
否则,先用 seq_cst 非常合理。
七、架构差异与“在我机器上没问题”的陷阱
7.1 x86:偏强模型,容易误导
- x86 TSO:
- 很多写-读重排在硬件层面本就禁止;
- 不加 acquire/release 的代码,经常“看起来”也能跑。
- 导致典型误解:
- “普通变量也差不多”;
- “
relaxed其实也 OK”; - “barrier/fence 不重要”。
一旦:
- 换 ARM / Power / RISC-V;
- 或编译器优化更激进;
这些“在我机器上没问题”的代码会直接炸。
7.2 正确姿势:以“语言内存模型”为准
永远只问两件事:
- C++ 标准是否保证这段代码没有 data race?
- C++ 标准是否通过 happens-before,保证我期望的可见性和顺序?
能在这两点上自证正确,你的代码才算真正可移植。
八、常见错误模式速览
8.1 “只把 flag 改成 atomic 就完事了”
std::atomic<bool> ready{false};
int data = 0;
void producer() {
data = 42; // 普通写
ready.store(true, std::memory_order_relaxed);
}
void consumer() {
while (!ready.load(std::memory_order_relaxed)) {}
use(data); // 希望是 42,但没有任何语言保证
}
问题:
ready虽然原子,但data完全没参与同步;- 使用
relaxed,没有任何 happens-before 边。
正确方式:至少需要 release + acquire 把 data 的写绑定到 flag 上。
8.2 “读多几次,总会看到最新的”
在 data race / 缺少同步的场景下:
- “多读几次就 eventually 一致”没有任何标准依据;
- 只有在“无 data race + 存在 happens-before”前提下,才谈得上“最终看到更新”。
8.3 把 relaxed 用在协议变量上
任何充当:
- 条件变量;
- 状态机节点;
- 发布/完成标志;
的变量,不能随便用 relaxed,除非你能证明:
- 它只是一个“旁路信息”,真正的同步边在别的地方。
经验规则:
- “看这个变量的值来决定是否可以访问某块数据”的场景:
- 默认假设要
release+acquire; - 再推理是否有其他同步工具已经提供了同样的 happens-before。
- 默认假设要
九、写并发代码时的一条“内存序决策链”
每当你拿起一个 std::atomic,可以按这个流程决策:
- 这只是保护它自己,还是承担同步协议?
- 只保护自己(例如纯计数器):可以考虑
relaxed或直接seq_cst; - 承担协议:必须考虑 acquire/release / acq_rel / seq_cst。
- 只保护自己(例如纯计数器):可以考虑
- 是否需要“看到它变化后,也看到别的数据的更新”?
- 需要:至少要
release/acquire配对; - 不需要:
relaxed有可能足够。
- 需要:至少要
- 这段代码是不是热点?是否懒得细抠?
- 不热 / 懒得细抠:用
seq_cst; - 热点 / 在乎性能:在证明正确的前提下,往
acquire/release/acq_rel/relaxed调低。
- 不热 / 懒得细抠:用
结合上一节,再压缩成“三类场景记忆法”:
- 第一类:纯原子计数 →
relaxed候选。 - 第二类:发布-接收(flag / pointer / state) →
release/acquire候选。 - 第三类:复杂 RMW / 锁实现 / 不想踩雷 →
acq_rel或seq_cst候选。
这三类基本覆盖了绝大多数实际代码。
十、一句话总结
现代编译器和 CPU 为了跑得更快,会在不破坏单线程语义的前提下任意重排;
而多线程正确性要求:某些顺序必须对其他线程肯定成立。
memory_order 用来在语言层面声明这些必须成立的顺序;
锁、原子操作与 fence 则在实现层面,保证任何合法执行都遵守这份“顺序契约”。