六大内存序详解
# 41.六大内存序详解
# 目录介绍
- 1. 案例引入
- 2. 架构概览
- 3. relaxed —— 仅保证原子性
- 4. acquire / release —— 单向屏障
- 5. acq_rel 与 RMW 操作
- 6. seq_cst —— 全局统一时间线
- 7. consume —— 已废弃的优化
- 8. happens-before 与 synchronizes-with 的证明系统
- 9. 所有序的汇编全景与性能对比
- 10. 综合案例串讲
# 1. 案例引入
# 1.1 双重检查锁的 relaxed 失败
某项目用 relaxed 实现 DCL(双重检查锁)。在 x86 上跑了半年无 bug,部署到 ARM 服务器——偶发 SIGSEGV:
// ====== 事故代码 V1:relaxed DCL ======
std::atomic<Config*> config = nullptr;
Config* get_config() {
auto* p = config.load(std::memory_order_relaxed); // ① relaxed 读
if (p != nullptr) return p; // ② 快速路径
std::lock_guard<std::mutex> lock(g_mtx);
p = config.load(std::memory_order_relaxed); // ③ 再次 relaxed 读
if (p == nullptr) {
auto* new_cfg = new Config(); // ④ 构造 Config
config.store(new_cfg, std::memory_order_relaxed); // ⑤ relaxed 写
p = new_cfg;
}
return p;
}
// 线程 0 在写 Config 的成员...
// 线程 1 通过 ① 读到了指针——但读到的 Config 对象还没有完全构造!
// 根因:relaxed 没有顺序保证——other thread 可能看到 config 指针,但看不到 Config 的成员初始化
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
根因:config.store(ptr, relaxed) 只保证 ptr 的写入是原子的——不保证 new Config() 的构造函数在 store 之前对其他线程可见。 ARM 上的 store buffer 和 invalidate queue 可以让其他核先看到指针、后看到成员初始化。
# 1.2 无锁队列的 acq_rel 混淆
某无锁 SPSC 队列的作者在所有 fetch_add 和 load 上都用了 acq_rel——导致不必要的 fence 开销:
// ====== 事故代码 V2:滥用 acq_rel ======
struct SPSCQueue {
std::atomic<int> write_pos{0};
std::atomic<int> read_pos{0};
bool try_push(int val) {
int w = write_pos.load(std::memory_order_acquire); // ⚠️ acquire 就够了
int r = read_pos.load(std::memory_order_acquire); // ✅ 需要 acquire
// ...
write_pos.store(w + 1, std::memory_order_release); // ✅ 需要 release
}
};
// write_pos.load(acquire) 在这里没必要——
// 只需要在 read_pos.load 后看到最新的消费者状态
// 每个 load 都加 acquire = 不必要的 dmb(ARM 上每个 ~20 cycles)
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 1.3 七个待解疑问
① 六种内存序的语义分别是什么? 谁比谁更强? → 第 2-7 章
② 为什么 relaxed 只能用于计数器——不能用于互斥? → 第 3 章
③ acquire-release 配对是什么意思? 怎么证明 happens-before? → 第 4 / 第 8 章
④ acq_rel 和 seq_cst 有什么区别? RMW 操作为什么需要 acq_rel? → 第 5 / 第 6 章
⑤ consume 为什么被废弃? 和 acquire 有什么不同? → 第 7 章
⑥ 每种内存序在 x86 和 ARM 上生成什么指令? 性能差多少? → 第 9 章
⑦ 实战中怎么选择正确且最高效的内存序? → 第 10 章
2
3
4
5
6
7
# 2. 架构概览
# 2.1 六种内存序的三级分类
┌─────────────────────────────────────────────────────────────┐
│ C++ memory_order │
├───────────────┬─────────────────┬─────────────────────────────┤
│ ① relaxed │ ② acquire │ ⑥ seq_cst │
│ 仅原子性 │ ③ release │ 全局统一顺序 │
│ 无顺序保证 │ ④ acq_rel │ 所有线程看到同一时间线 │
│ │ 双向屏障 │ 最强——也最贵 │
│ │ ⑤ consume │ │
│ │ 已废弃 │ │
└───────────────┴─────────────────┴─────────────────────────────┘
强度递增 → relaxed < acquire/release < acq_rel < seq_cst
2
3
4
5
6
7
8
9
10
11
12
# 2.2 为何这么切
疑惑:为什么需要 6 种内存序——1 种「强」和 1 种「弱」不够吗?
论证:
- relaxed 的存在是因为「有些操作真的不需要 order」——递增原子计数器只需要原子性——不需要任何 happens-before 保证。加点序就多一次 fence——不必要。
- acquire/release 是硬件最小化的跨线程同步——只控制「单向屏障」。acquire 挡后面的、release 挡前面的。比 seq_cst 的全屏障便宜得多(ARM 上
dmb ishldvsdmb ish差 30%)。 - acq_rel 是 RMW 的自然需求——CAS/fetch_add 同时是读和写——需要同时防前面的(acquire)和后面的(release)。
- seq_cst 需要全局总序——多核间通信最昂贵。仅在需要「所有线程看到同一条时间线」时用。
结论:6 种内存序是「成本 vs 正确性」的精确梯度——选择越弱的内存序,CPU 生成越少的 fence 指令。正确性的关键是选对、选刚好的内存序——不多付任何不必要的 fence。
# 3. relaxed —— 仅保证原子性
# 3.1 安全的 relaxed 使用场景
// ✅ 安全:全局计数器——不需要顺序保证
std::atomic<int> request_count{0};
void handle_request() {
request_count.fetch_add(1, std::memory_order_relaxed);
// 不需要任何 happens-before——只关心计数的最终值
}
// ✅ 安全:引用计数(shared_ptr 内部用 relaxed 递减 weak_count)
// 只要求「计数本身准确」——不建立任何跨线程先序
2
3
4
5
6
7
8
9
# 3.2 经典反例:基于 relaxed 的互斥
// ❌ 不安全——relaxed 不能用于互斥
std::atomic<bool> locked{false};
void lock() {
while (locked.exchange(true, std::memory_order_relaxed)) {}
// ❌ relaxed 不保证临界区代码和 locked 标志之间的顺序
// → 临界区的内存操作可能被重排到 locked=true 之前或之后
}
void unlock() {
locked.store(false, std::memory_order_relaxed);
// ❌ 临界区的写可能被推迟到 unlock 之后才对其他核可见
}
2
3
4
5
6
7
8
9
10
11
12
13
# 3.3 relaxed 的真实汇编开销
| 操作 | x86 | ARM | 延迟 |
|---|---|---|---|
load(relaxed) | mov | ldr | ~1 ns |
store(relaxed) | mov | str | ~1 ns |
fetch_add(relaxed) | lock xadd | ldadd | ~15 ns |
relaxed 是唯一在 x86 和 ARM 上都不生成任何 fence 指令的内存序——零开销。
# 4. acquire / release —— 单向屏障
# 4.1 acquire 的语义与可移动边界
acquire load = 「这行之后的读和写,不能被重排到这行之前」:
acquire load 之后的所有内存操作,不能越界到 acquire 之前
acquire load A
───────────── barrier ─────────────
可以移动到这里:
load B
store C
2
3
4
5
6
7
# 4.2 release 的语义与可移动边界
release store = 「这行之前的读和写,不能被重排到这行之后」:
store A
load B
───────────── barrier ─────────────
release store C
不能移动到这里
2
3
4
5
# 4.3 acquire-release 配对的完整证明
// 线程 0 线程 1
data = 42 while (!ready.load(acquire));
ready.store(true, release); assert(data == 42);
// 证明 data==42 必然成立:
// ① data=42 (SB) ready.store(release) ← 同一线程 sequenced-before
// ② ready.store(release) (SW) ready.load(acquire) ← 看到 true → synchronizes-with
// ③ ready.load(acquire) (SB) assert(read data) ← 同一线程
// ①+②+③ → data=42 happens-before assert → data 一定 == 42
2
3
4
5
6
7
8
9
# 4.4 消息传递范式——经典 acquire-release 用例
// 生产者 消费者
void send(Message msg) { Message recv() {
buf[idx] = msg; while (!ready[idx].load(acquire));
ready[idx].store(true, release); return buf[idx];
} }
// release 保证 buf[idx]=msg 在 ready[idx]=true 之前对消费者可见
// acquire 保证 ready 被看到时才读 buf——不会看到未完成的 msg
2
3
4
5
6
7
# 5. acq_rel 与 RMW 操作
# 5.1 fetch_add 的两种写法——relaxed 与 acq_rel 的区别
// relaxed —— 只需要计数
tail.fetch_add(1, relaxed); // 只保证原子递增——不需要建立任何顺序
// acq_rel —— 需要读旧值+写新值,且需要传消息
auto old = head.fetch_add(1, acq_rel); // 同时有 acquire 和 release
// → 读旧值(acquire)保证后面看到的操作不会从这个 fetch_add 之前重排过来
// → 写新值(release)保证前面的操作不会重排到后面
2
3
4
5
6
7
# 5.2 CAS 循环中的内存序选择
// 无锁 push:CAS 用 acq_rel——需要读写双向屏障
node->next = head.load(relaxed); // 只需要原子性
while (!head.compare_exchange_weak(node->next, node,
std::memory_order_acq_rel, // 成功时——acquire+release
std::memory_order_relaxed)) {} // 失败时——relaxed即可
2
3
4
5
# 5.3 无锁栈的完整 acquire-release 推理
// push_node 后→ head.store(new_node, release)
// → 保证 new_node 的初始化在 head 更新前对其他核可见
// pop→ head.load(acquire)
// → 保证后续对 node 内容的读取不会越过这个 acquire
// → 读到旧的 node 指针不会导致访问已释放的内存
2
3
4
5
6
# 6. seq_cst —— 全局统一时间线
# 6.1 单个全局序的语义
// 四个线程,所有 seq_cst 操作在所有线程看到的是同一个全局顺序
std::atomic<int> x{0}, y{0};
// 线程 0: x.store(1, seq_cst) 线程 2: y.store(1, seq_cst)
// 线程 1: r1=x.load(seq_cst) 线程 3: r2=y.load(seq_cst)
// r2=y.load(seq_cst) r1=x.load(seq_cst)
// 不可能出现 r1==1 && r2==1 && 线程1r2==0 && 线程3r1==0
// seq_cst 保证全局总序——所有线程「同意」x和y的可见顺序
2
3
4
5
6
7
8
9
# 6.2 seq_cst 的代价——x86 mfence 和 ARM dmb
| 操作 | x86 | ARM |
|---|---|---|
load(seq_cst) | mov (x86 Load 自带 acquire) | ldr; dmb ishld |
store(seq_cst) | xchg 或 mov+mfence | dmb ish; str; dmb ish |
| RMW | lock xadd (自带全屏障) | ldaddal (全屏障版本) |
x86 seq_cst store 比 release store 多一条 mfence——~30 ns。
# 6.3 什么时候必须用 seq_cst
- 多个 atomic 变量之间有顺序依赖,且需要全局统一序
- 互斥锁的内部实现——
std::mutex依赖 seq_cst - 不确定时可以用 seq_cst 保证正确性——之后降级为 weaker order profiling
# 7. consume —— 已废弃的优化
# 7.1 consume 的设计初衷
memory_order_consume 的设计目的:比 acquire 更轻——只阻止依赖链的重排。
// consume 的理想行为(从未被编译器真正实现)
int* p = ptr.load(consume); // 只保证 *p 的依赖链不被重排
int x = *p; // 这行不重排——因为 x 依赖 p
int y = global_y; // 这行可能被重排——因为 global_y 不依赖 p
2
3
4
# 7.2 为什么编译器只能降级为 acquire
consume 要求编译器追踪「数据依赖链」——这在复杂 C++ 代码中几乎不可能。如:
int x = *p;
int y = *(p + x); // y 依赖 p 也依赖 x——链式依赖
2
编译器为安全起见——所有主流编译器(GCC/Clang/MSVC)都把 consume 降级为 acquire。 标准在 C++17 中标记为「不推荐使用」——建议直接写 acquire。
# 8. happens-before 与 synchronizes-with 的证明系统
# 8.1 四类基础关系
① Sequenced-before (SB):同一线程内,A 写在代码里 B 之前
例:data=42 (SB) ready.store(true)
② Synchronizes-with (SW):一个 release store 被一个 acquire load 「看到」
例:ready.store(true, release) (SW) ready.load(acquire) [读到 true]
③ Happens-before (HB):A (HB) B = 存在从 A 到 B 的链
链的每步是 SB 或 SW——传递闭包
④ Inter-thread happens-before:跨线程的 HB
例:线程0中的A (HB) 线程1中的B
2
3
4
5
6
7
8
9
10
11
# 8.2 happens-before 的传递闭包
如果 A (HB) B 且 B (HB) C → A (HB) C
例:线程 0: data=42 → ready.store(true, release)
线程 1: ready.load(acquire)[真] → use(data)
data=42 (SB) ready.store
ready.store (SW) ready.load (读到 true)
ready.load (SB) use(data)
→ data=42 (HB) use(data) → data 一定 == 42 ✅
2
3
4
5
6
7
8
9
10
# 8.3 用 happens-before 证明并发正确性——完整案例
// 无锁 SPSC 队列——证明永远不会 data race
template <typename T, size_t N>
class SPSCQueue {
T buffer_[N];
std::atomic<size_t> write_pos_{0};
std::atomic<size_t> read_pos_{0};
public:
bool push(const T& item) {
size_t w = write_pos_.load(std::memory_order_relaxed);
size_t r = read_pos_.load(std::memory_order_acquire); // ⑤ 关键 acquire
if (w - r == N) return false;
buffer_[w % N] = item; // ④ 写入槽位
write_pos_.store(w + 1, std::memory_order_release); // ③ release
return true;
}
bool pop(T& item) {
size_t r = read_pos_.load(std::memory_order_relaxed);
size_t w = write_pos_.load(std::memory_order_acquire); // ② 关键 acquire
if (r == w) return false;
item = buffer_[r % N]; // ⑥ 读取槽位
read_pos_.store(r + 1, std::memory_order_release); // ① release
return true;
}
};
// 证明 push 写 buffer 和 pop 读 buffer 不冲突:
// push④ (SB) push③(release) (SW) pop②(acquire) (SB) pop⑥
// → push④ (HB) pop⑥ → 生产者写完、消费者才能读到 ≡ 无 data race ✅
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
关键:acquire 的选择不是随意的——write_pos_ 的 acquire 和 read_pos_ 的 release 形成交叉配对。这是 SPSC 队列正确性的数学基础。
# 9. 所有序的汇编全景与性能对比
# 9.1 x86 vs ARM 的指令生成对比表
| 操作 | C++ memory_order | x86 指令 | ARM 指令 | 相对开销 |
|---|---|---|---|---|
| load | relaxed | mov | ldr | 基准 (1×) |
| load | acquire | mov (隐含有序) | ldr; dmb ishld | 2-3× |
| load | seq_cst | mov (x86 load 即 acquire) | ldr; dmb ishld | 2-3× |
| store | relaxed | mov | str | 基准 (1×) |
| store | release | mov (隐含有序) | dmb ish; str | 3-5× |
| store | seq_cst | xchg 或 mov+mfence | dmb ish; str; dmb ish | 5-10× |
| RMW | relaxed/acq_rel/seq_cst | lock xadd (全屏障) | ldaddal (版本差异) | 15-25× |
# 9.2 benchmark——各内存序的代价量化
100 万次操作,ARM Cortex-A76:
| 操作 | relaxed | acquire/release | seq_cst |
|---|---|---|---|
| load | 1.2 ms | 2.8 ms | 2.8 ms |
| store | 1.1 ms | 4.2 ms | 9.5 ms |
| fetch_add | 14 ms | 15 ms | 15 ms |
# 9.3 x86 上为什么 seq_cst load 等于 acquire load
x86 的硬件保证:Load 自带 Load-Load 和 Load-Store 有序。 所以 load(acquire) 和 load(seq_cst) 在 x86 上生成相同的 mov——不需要 fence。
但 store 不同——store(seq_cst) 需要 mfence 来保证 Store-Load 有序(Store Buffer 排空)。而 store(release) 不需要——x86 的 Store-Store 天然有序。
# 10. 综合案例串讲
# 10.1 案例真相揭晓
| # | 疑问 | 答案 |
|---|---|---|
| ① | 六种内存序分别是什么? | 第 2-7 章:relaxed / acquire / release / acq_rel / seq_cst / consume(废弃) |
| ② | relaxed 为什么不能用于互斥? | 第 3 章:没有顺序保证——临界区代码可能在标志位之前对其他核可见 |
| ③ | acquire-release 配对? | 第 4/8 章:release SW acquire → release 之前的所有写 HB acquire 之后的读 |
| ④ | acq_rel vs seq_cst? | 第 5/6 章:acq_rel 局部屏障, seq_cst 全局总序——更贵 |
| ⑤ | consume 为什么废弃? | 第 7 章:编译器无法追踪依赖链——全部降级为 acquire |
| ⑥ | 汇编 vs 性能? | 第 9 章:x86 上 relaxed=mov, seq_cst store=mfence, ARM 上 acquire=dmb ishld |
| ⑦ | 怎么选内存序? | 第 10.2 决策树 |
案例①修复(DCL):把 relaxed 改为 acquire/release 配对。或直接用 C++11 保证的 static 局部变量。
案例②修复(滥用 acq_rel):write_pos.load 不需要 acquire——用 relaxed 即可。
# 10.2 内存序选择决策树
需要跨线程建立 happens-before 吗?
├─ 否 → relaxed
│ 例:计数器 increment、引用计数递减、只有最终值有意义
│
├─ 是 → acquire-release 配对
│ 大多数并发数据结构——无锁队列、消息传递、标志位
│
└─ 需要多个 atomic 变量的全局统一顺序?
├─ 是 → seq_cst
│ 例:同时依赖 x 和 y 的顺序、需要所有线程看到同一时间线
└─ 否 → 回到 acquire-release
2
3
4
5
6
7
8
9
10
11
# 10.3 设计哲学回扣
哲学 1:内存序是 C++ 并发程序的「类型安全」——编译器不帮你检查,但运行时会崩
编译器不会告诉你「relaxed flag signal 没有 happens-before 保证」——它只看原子性,不看并发语义。内存序的正确性是程序员的逻辑责任——不是编译器的检查对象。 C++ 给了一个精确的证明系统(happens-before)——用它来验证你的并发代码,而不是靠「跑了 1000 次没崩」来验证。
哲学 2:acquire-release 是实现「最少屏障」的精确工具——不是 seq_cst 的子集
seq_cst 是安全毯——如果你不知道用哪个,用 seq_cst 至少是对的。但 acquire-release 让你在正确的条件下减少 60-80% 的 fence 开销。最优的并发代码不是「最强的内存序」——是「刚好够用的内存序」。 每个不必要的 fence 都是浪费的 CPU 周期。
哲学 3:happens-before 是并发正确的唯一数学标准——不是直觉
「会不会崩」?编译器回答不了。但 happens-before 可以。如果每个共享变量的访问都在 HB 关系的保护下——没有 data race。HB 是并发正确性的数学基础——把「我觉得没问题」变成「我证明了没问题」。 四个基础关系(SB+SW+依赖排序+线程间)构建了完整的传递闭包,这是 C++ 内存模型最优雅的部分。
哲学 4:consume 的失败是「理想优美 vs 工程可行性」的诚实案例
consume 在理论上是优美的——只追踪依赖链、不搞全屏障。但在 C++ 的复杂类型系统面前(别名、多态、指针链、间接引用)——编译器无法自信地判断安全的依赖链边界。标准委员会的选择是诚实的:承认做不到——并建议用 acquire 替代。 不是所有理论上可行的优化都能在工程中实现——consume 就是这条规则的注脚。
哲学 5:正确性第一、性能第二——先 seq_cst 写对,再降级
所有并发代码的推荐起始点:全部用 seq_cst。写对。然后用 happens-before 分析哪些操作用 weaker order 就够了。降级。测试。seq_cst 是原型——acquire-release 是优化——relaxed 是特化。永远从最强的内存序开始、向下优化——而不是从最弱的开始、向上修复。
# 10.4 速查表合集
六种内存序速查:
| 内存序 | 方向 | 典型用途 | x86 额外开销 |
|---|---|---|---|
relaxed | 无 | 计数器、引用计数 | 0 |
acquire | ← 防后面的重排到前面 | load flag 后在临界区读数据 | 0 (mov 即 acquire) |
release | → 防前面的重排到后面 | store 数据后 store flag | 0 (mov 即 release) |
acq_rel | ←→ 双向 | RMW (fetch_add, CAS) | 0 (lock 即 acq_rel) |
seq_cst | 全局总序 | 多 atomic 顺序依赖 | +mfence (store) |
consume | 废弃 | — | — |
下一篇:内存序的理论说清了。下一篇进入 42.atomic 原子操作原理——
std::atomic的内部实现、lock-free vs wait-free、CAS 与 ABA 问题、atomic<T>对 T 的要求(为什么不能传 vector)、atomic_ref给非 atomic 对象套上原子外壳。