C++ 多线程内存模型与 memory_order 详解
C++11 起,标准在语言层面定义了内存模型 :在多线程环境下,哪些并发访问是合法的、原子操作提供哪些原子性 与顺序 保证。本文从数据竞争 与可见性/顺序 两条线展开,说明 std::atomic 与 std::memory_order 的语义、典型误用与选型建议,并联系多核缓存与屏障的直观图景(实现细节因 CPU/编译器而异,以标准与实现文档为准)。
目录
- [1. 两类核心问题](#1. 两类核心问题)
- [2. 同步手段与标准术语](#2. 同步手段与标准术语)
- [3. 数据竞争与 std::atomic](#3. 数据竞争与 std::atomic)
- [4. 仅有原子性仍不够:重排与可见性](#4. 仅有原子性仍不够:重排与可见性)
- [5. 硬件直觉:缓存、MESI 与写缓冲](#5. 硬件直觉:缓存、MESI 与写缓冲)
- [6. memory_order 六种枚举](#6. memory_order 六种枚举)
- [7. release / acquire 与「同步」](#7. release / acquire 与「同步」)
- [8. memory_order_seq_cst 与全局顺序](#8. memory_order_seq_cst 与全局顺序)
- [9. 互斥锁、平台差异与 CAS](#9. 互斥锁、平台差异与 CAS)
- [10. 实战选型、决策图与代码片段](#10. 实战选型、决策图与代码片段)
- [11. memory_order_consume 说明](#11. memory_order_consume 说明)
- [12. 工程建议与工具](#12. 工程建议与工具)
- [13. 小结](#13. 小结)
1. 两类核心问题
| 概念 | 含义 |
|---|---|
| 数据竞争(Data Race) | 两个及以上线程并发访问同一内存位置,至少一方为写,且访问之间没有通过原子操作 或互斥 等建立 happens-before 关系时,构成数据竞争;程序为未定义行为(UB)。 |
| 内存执行顺序 | 源码顺序、编译器生成指令顺序、CPU 动态执行顺序,以及其它线程何时能看到某次写入,在多核与优化下可能不一致;需要由语言和同步原语约定「允许推断什么」。 |
顺序与可见性
数据竞争
非原子读改写
交错执行丢更新
编译器/CPU 重排
缓存与写缓冲延迟
std::atomic 原子性
memory_order 约束
2. 同步手段与标准术语
2.1 语言层常用同步原语
| 手段 | 适用场景 | 与内存序关系 |
|---|---|---|
std::mutex / std::lock_guard 等 |
保护临界区,业务逻辑首选 | 锁的解/加锁隐含同步,无需手写 memory_order |
std::atomic |
无锁算法、标志位、计数器 | 需显式或默认 memory_order |
std::condition_variable |
线程间等待/通知 | 必须与 mutex 配对使用 |
std::latch / std::barrier(C++20) |
阶段同步 | 按标准定义建立同步 |
2.2 happens-before 与 synchronizes-with(直觉)
| 术语 | 直觉 |
|---|---|
| happens-before | 若 A happens-before B,则 A 的副作用对 B 可见 且 A 排序在 B 之前(用于推理数据竞争与可见性)。 |
| synchronizes-with | 一种更强的跨线程关系:特定原子上的 release 与 acquire(或部分其它操作)配对时建立,用于推导 happens-before。 |
| 强先序(strongly happens-before) | 标准中用于排除循环等 corner case;日常工程可先掌握前两者。 |
权威定义以 cppreference:std::memory_order 与 C++ 标准为准。
3. 数据竞争与 std::atomic
3.1 计数器为何不准
多线程对普通 int 自增,结果常小于「线程数 × 每线程次数」,因为 ++counter 在机器层面多为**读-改-写(RMW)**三步,可被其它线程打断:
text
时间 →
线程1: [读 counter=5] [加1] [写回 6]
线程2: [读 counter=5] [加1] [写回 6] ← 两次自增只得到 6
3.2 示例:错误与修复
cpp
// 错误:数据竞争
int counter = 0;
void increment() {
for (int i = 0; i < 100000; ++i)
++counter;
}
// 正确:原子类型(默认 memory_order_seq_cst)
#include <atomic>
std::atomic<int> counter{0};
void increment_ok() {
for (int i = 0; i < 100000; ++i)
++counter; // 原子 RMW
}
3.3 std::atomic 常用接口
下列操作均可传入 std::memory_order;不显式指定时默认为 memory_order_seq_cst。
| 操作(示意) | 作用 |
|---|---|
store(val, order) |
原子写入 |
load(order) |
原子读取 |
fetch_add / fetch_sub |
原子加减(与 += / -= 等对应) |
fetch_and / fetch_or / fetch_xor |
原子按位与/或/异或 |
compare_exchange_weak/strong |
CAS,实现无锁结构时常用 |
注意 :std::atomic 保护的是该原子对象本身 的读写;其它普通变量仍要靠 mutex 或 用原子「发布/获取」配对 来保证可见性(见下文)。
4. 仅有原子性仍不够:重排与可见性
4.1 生产者-消费者反例
cpp
int data = 0;
std::atomic<bool> ready{false};
// 生产者(意图:先写 data,再公布 ready)
void producer() {
data = 42;
ready.store(true, std::memory_order_relaxed); // 危险:见下文
}
// 消费者
void consumer() {
while (!ready.load(std::memory_order_relaxed)) { }
int v = data; // 可能读到 0:未与生产者建立同步
}
原因概要:
- 编译器 可能在单线程等价前提下重排对
data与ready的访问(取决于具体写法与优化)。 - CPU 可能乱序执行或经写缓冲提交,使其它核心观察到的生效顺序与源码意图不一致。
- 即使对每个原子变量单独的操作是原子的,不同变量之间 仍需要
release/acquire(或更强)来建立跨线程的顺序与可见性保证。
4.2 正确配对(概念)
生产者对 ready 使用 memory_order_release ,消费者对 ready 使用 memory_order_acquire ,可在标准语义下建立 synchronizes-with ,使消费者在看到 ready == true 之后对 data 的读取能看到生产者在线程中先发生于 release 的那些写入(在正确使用的前提下)。
release / acquire 配对
release:此前对 data 的写入不得晚于 ready 的发布
acquire:看到 ready 后对 data 的读可见生产者侧写入
仅用 relaxed:无跨变量顺序保证
data 与 ready 可被重排或晚可见
5. 硬件直觉:缓存、MESI 与写缓冲
以下为教学用简化图景,便于理解为何需要内存序;真实行为以具体 ISA 与内存模型(如 x86 TSO、ARM 弱序)为准。
text
Core0 (写 data, ready) Core1 (读 ready, data)
| |
L1/L2 L1/L2
\__________ L3 / 主存 __________/
| 机制 | 直觉说明 |
|---|---|
| 私有缓存 | 某核上的写入可能先停留在本核可见的缓存层次,再按协议传播;其它核「何时」看到取决于缓存一致性流量与屏障。 |
| MESI 等协议 | 保证同一缓存行 上的相干性,但不自动给出「变量 A 的写入一定先于变量 B 被其它核感知」这种跨地址的程序级顺序。 |
| 写缓冲(Store Buffer) | 写可先进入缓冲再提交到缓存;与失效队列等机制组合时,可能出现不同存储被其它核以意外顺序观察的错觉,需通过屏障/fence 约束。 |
5.1 MESI 状态(教学简表)
| 状态 | 含义(直觉) |
|---|---|
| M(Modified) | 本核独占且已修改,与主存不一致;需通过写回或失效与其它核协调。 |
| E(Exclusive) | 本核独占且与主存一致。 |
| S(Shared) | 多核可读,缓存行一致。 |
| I(Invalid) | 本地副本无效,需从其它核或主存重新加载。 |
5.2 屏障类型与内存序(硬件视角对照,非一一映射)
教学中常把屏障分为四类(名称因资料而异):
| 屏障(教学名) | 直觉约束 |
|---|---|
| LoadLoad | 屏障前的 Load 先于屏障后的 Load 被感知 |
| LoadStore | 屏障前的 Load 先于屏障后的 Store 被感知 |
| StoreStore | 屏障前的 Store 先于屏障后的 Store 被感知 |
| StoreLoad | 屏障前的 Store 先于屏障后的 Load 被感知(往往最贵) |
acquire / release / seq_cst 在实现上会组合出上述效果以满足 C++ 语义;不必在应用代码里手写汇编屏障,除非做底层或内核。
std::atomic 带 memory_order 的读写,由实现插入适当指令与优化边界,将 C++ 抽象映射到目标平台的原子与屏障原语。
6. memory_order 六种枚举
| 枚举值 | 主要约束(直觉) | 典型场景 | 相对开销 |
|---|---|---|---|
relaxed |
仅保证该原子 读写为原子操作,不与其它内存位置建立顺序。 | 独立计数器、统计量,逻辑不依赖「先写完 A 再读 B」。 | 低 |
acquire |
本原子 load 与后继内存访问的排序:之后的读写不会「排到」此次 load 之前(Acquire 语义)。 | 消费者读「就绪」标志后访问有效载荷。 | 中(视平台) |
release |
本原子 store 与先前内存访问的排序:之前的读写不会「排到」此次 store 之后(Release 语义)。 | 生产者写完数据再 store 标志。 |
中 |
acq_rel |
读-改-写(RMW)上同时带 acquire + release 成分。 | fetch_add、自旋锁的 CAS 等。 |
中高 |
seq_cst |
默认 ;所有 seq_cst 操作参与一个全局一致的总序,且与各线程程序顺序协调。 |
多变量、多线程对「谁先谁后」有强一致叙述需求时;不确定时优先使用。 | 高(往往) |
consume |
依赖链上的宽松顺序;实践中常被实现为 acquire,标准亦趋于弱化。 | 新代码慎用;见第 11 节。 | 视实现 |
7. release / acquire 与「同步」
7.1 配对写法示例
cpp
#include <atomic>
int data = 0;
std::atomic<bool> ready{false};
void producer() {
data = 42;
ready.store(true, std::memory_order_release);
}
void consumer() {
while (!ready.load(std::memory_order_acquire)) { }
int v = data; // 与正确使用下可建立 happens-before,读到 42
}
7.2 图示(happens-before 直觉)
消费者线程 内存( data, ready ) 生产者线程 消费者线程 内存( data, ready ) 生产者线程 release 之前的写入对消费者侧 acquire 后可见 写入 data = 42 release store ready=true acquire load ready 直到为 true 读取 data
要点 :release 与 acquire配对在同一原子对象 上时,标准提供 synchronizes-with ,从而把生产者对 data 的写入与消费者读取 data 在逻辑上串起来(在数据竞争以外的前提满足时)。
8. memory_order_seq_cst 与全局顺序
8.1 何时需要强于 acquire/release 的保证
当程序依赖多个原子变量 之间的相对顺序,且要求所有线程对这组操作的先后有一致叙事时,仅用 relaxed 可能出现逻辑上令人困惑的交错观察 (与具体 litmus 测试类似)。此时应使用 memory_order_seq_cst(或重新设计同步、改用锁)。
8.2 对 seq_cst 的准确理解(避免误解)
seq_cst不是「禁用 CPU 缓存」或「每次读写都直达主存」的简单实现;编译器与 CPU 仍可在规则允许范围内优化。- 其语义核心是:存在所有
seq_cst操作的一个单一全序 ,且与每个线程内的seq_cst顺序一致,从而排除一类「不同线程看到矛盾先后顺序」的行为。
8.3 默认即是 seq_cst
不显式写 memory_order 的 std::atomic 操作默认为 seq_cst,最利于正确性 ;在热点路径上经证明可弱化时再改为 acquire/release/relaxed。
8.4 与「Store Buffering」类 litmus 的直觉(极简)
下列示意 说明为何弱序下会出现反直觉观察(非可运行断言):
| 线程 1 | 线程 2 |
|---|---|
x.store(1, relaxed) |
y.store(1, relaxed) |
r1 = y.load(relaxed) |
r2 = x.load(relaxed) |
在弱序模型下,可能出现 r1 == r2 == 0 这类结果;若这些 load/store 全部改为 seq_cst ,标准排除此类与「单一全序」矛盾的观察。实际编程中:多标志位协调状态机 时优先 锁 或 seq_cst。
9. 互斥锁、平台差异与 CAS
9.1 原子 + 内存序 vs 互斥锁
| 维度 | std::mutex |
std::atomic + 精细内存序 |
|---|---|---|
| 正确性成本 | 低,易推理 | 高,需严格论证 |
| 性能 | 竞争强时可能阻塞 | 无锁可在低竞争热点路径更省,但难调 |
| 适用 | 大多数业务逻辑 | 库、队列、计数器、专家维护的代码 |
建议:默认可先写锁;** profiling** 证明瓶颈后再考虑无锁与弱内存序。
9.2 平台直觉:x86 与 ARM(实现仍须按语言语义)
| ISA 家族 | 直觉(过度简化) |
|---|---|
| x86(TSO 类) | 较强序,许多「单线程可见」顺序在硬件上不易被打乱,但编译器重排仍存在,仍须用原子/mutex。 |
| ARM / RISC-V 等 | 更弱序,更依赖屏障;同一 C++ 源码在不同架构上由编译器插入不同 fence。 |
因此:不能 因为在 x86 上「测着没问题」就省略 memory_order 或误用 relaxed。
9.3 compare_exchange 与自旋锁示意
| API | 说明 |
|---|---|
compare_exchange_strong |
保证**虚假失败(spurious failure)**仅来自底层竞争;循环 CAS 常用。 |
compare_exchange_weak |
允许虚假失败,某些架构上更高效;必须在循环里重试。 |
cpp
// 极简自旋锁(教学用,生产需考虑公平性/PAUSE 等)
class SpinLock {
std::atomic<bool> locked{false};
public:
void lock() {
bool exp = false;
while (!locked.compare_exchange_weak(exp, true,
std::memory_order_acquire,
std::memory_order_relaxed)) {
exp = false;
}
}
void unlock() {
locked.store(false, std::memory_order_release);
}
};
10. 实战选型、决策图与代码片段
是
否/热点无锁
是
否
是
否
是
否
需要跨线程共享数据?
能用一把锁包住?
std::mutex / lock_guard
std::atomic
多个原子变量强顺序叙事?
memory_order_seq_cst 或重构
用原子标志发布普通数据?
release / acquire 配对
仅独立计数统计?
memory_order_relaxed
| 场景 | 建议 |
|---|---|
| 不确定 / 复杂多原子状态机 | 使用默认 seq_cst 或互斥锁 |
| 纯计数、与别的共享数据无发布关系 | fetch_add(..., memory_order_relaxed) |
| 单生产者单消费者:用原子标志发布数据 | 生产者 release,消费者 acquire |
| RMW 无锁结构(自旋锁、队列下标) | acq_rel 或按接口文档选用 |
relaxed 计数示例:
cpp
std::atomic<long long> reqs{0};
void on_request() {
reqs.fetch_add(1, std::memory_order_relaxed);
}
11. memory_order_consume 说明
memory_order_consume 旨在表达「仅与数据依赖 相关的顺序」,理论上可弱于 acquire。目前主流编译器往往不 单独实现弱 consume 语义,而是提升(strengthen)为 acquire ;C++17 起对 consume 的规范与编译器支持仍在演进。新工程 一般直接用 acquire/seq_cst,或在专家审查下使用依赖链模式。
12. 工程建议与工具
| 建议 | 说明 |
|---|---|
| 默认保守 | 无测量、无形式化论证时,不要过早弱化内存序。 |
| 用锁简化 | 业务代码优先 std::mutex;无锁仅用于已证实的瓶颈。 |
| ThreadSanitizer | 编译选项如 -fsanitize=thread(Clang/GCC)可帮助发现数据竞争。 |
| 单测与压力测 | 并发 bug 难复现;结合 TSAN 与长时间压测。 |
| 阅读标准与博客 | 以 cppreference memory order 与标准草案相关章节为权威对照。 |
13. 小结
| 步骤 | 动作 |
|---|---|
| 1 | 凡多线程共享可变数据,先消除数据竞争:原子或互斥。 |
| 2 | 若用原子做「标志位 + 普通数据」的发布,使用 release/acquire 配对。 |
| 3 | 多原子、全局先后叙事强依赖时,使用 seq_cst 或锁。 |
| 4 | 独立统计 等可尝试 relaxed,并限制其作用域。 |
| 5 | 用 TSAN 与代码评审降低并发缺陷风险。 |
C++ 内存模型
数据竞争
原子与互斥
顺序与可见性
release_acquire
seq_cst
硬件直觉
缓存 MESI
写缓冲
工程
TSAN
先锁后无锁
本文可与同目录 《无锁环形队列与高并发日志设计要点》 对照阅读;该文从工程结构角度涉及原子与内存序的实际用法。
根据公开资料整理,并结合 C++ 标准与通用并发实践扩写。