“In multithreaded programming, if you think you know what's happening, you're probably wrong.”
为什么我们需要内存模型?
在单核CPU时代,我们编写程序时可以对代码执行顺序有着直观的期待——指令基本上按照书写顺序执行。然而,随着多核处理器的普及,这个美好的假设被彻底打破了。
现代CPU为了提升性能,进行了大量优化:
- 编译器指令重排(编译器可以重新排列指令顺序以优化性能)
- CPU乱序执行(CPU可以打乱指令执行顺序以充分利用执行单元)
- 多层缓存结构(每个CPU核心都有自己的缓存,通过缓存一致性协议维护数据一致性)。
编译器指令重排
编译器在保证单线程执行结果(as-if规则)不变的前提下,为了优化性能(如更好地利用寄存器、减少指令依赖),会调整指令顺序。
考虑这个简单的例子:两个线程共享两个变量x和y,初始值都为0:
// 代码顺序
int x = 0, y = 0;
void foo() {
x = 10; // 写X
y = 20; // 写Y
}
在编译器眼里,x 和 y 毫无瓜葛。
为了优化流水线,它完全可能先赋值 y,再赋值 x。
如果有另一个线程在监视 x 的变化来读取 y,它可能在看到 x 变了之后,读到的 y 还是旧值。
CPU 乱序执行
就算你按着编译器的头,不做任何优化,让它不要乱排指令,CPU 这一关也过不去。
现代CPU采用复杂的流水线、超标量、乱序执行技术来榨取性能。更重要的是,缓存一致性协议(如MESI)只保证最终一致性,不保证顺序一致性。
想象两个CPU核心(C1, C2)和一块共享内存:
- 存储缓冲区 (Store Buffer):当C1要写数据时,它并不直接写回可能被其他核心共享的缓存行,而是先写入自己的
Store Buffer,然后继续执行后续指令。这使得“写操作”在 C1 看来是立即完成的,但对 C2 而言,这个写入还不可见。 - 失效队列 (Invalidate Queue):当 C1 需要写入一个缓存行时,它会向其他持有该缓存行的核心发送“失效”消息。 C2 收到后,并不立即处理(清理自己的缓存副本),而是将其放入失效队列并立即回复“收到”,以让 C1 的写入能尽快完成。当 C2 稍后需要读取该数据时,它必须处理失效队列中的消息,这时才会发现自己的缓存副本已失效,从而去C1那获取最新值。
MESI协议保证了最终所有缓存会一致,但
Store Buffer 和 Invalidate Queue 引入了可见性延迟,使得“一个核心的写入”与“另一个核心看到该写入”之间存在一个不确定的间隔。
这破坏了我们对“顺序”的直觉。
为了解决这个问题,我们需要内存屏障(Memory Barrier/Fence):
- 写屏障 (Store Fence, e.g.,
sfenceon x86):强制清空当前核心的Store Buffer,确保屏障之前的所有写操作都对其他核心可见。 - 读屏障 (Load Fence, e.g.,
lfenceon x86):强制处理当前核心的Invalidate Queue,确保屏障之后的读操作能获取到其他核心的最新写入。
C++ 标准并不要求具体的 fence 类型,实际上不同架构(x86 / ARM / POWER)实现差异极大,有时是 fence,有时是带语义的原子指令,有时甚至什么都不插(如 x86 acquire load)。
在弱内存架构下,通常需要通过 fence 或等价机制来实现这些语义
C++内存模型,为我们提供了一套统一、可移植的抽象,来描述并发程序中的操作顺序和内存可见性,并让编译器为我们生成正确的屏障指令。
内存模型的三层抽象
现代 C++ 内存模型的所有规则,本质上都围绕着三种不同层级的“顺序关系”展开。
理解这三种关系,有助于把看似复杂的内存序,统一到一个可推理的模型中。
| 层级 | 关系名称 | 作用范围 | 核心作用 |
|---|---|---|---|
| 第一层 | sequenced-before | 单线程 | 描述线程内的因果关系与重排边界 |
| 第二层 | synchronizes-with | 跨线程(原子) | 在两个线程之间建立同步连接 |
| 第三层 | happens-before | 跨线程(整体) | 给出最终的可见性保证 |
第一层:sequenced-before —— 单线程内的因果关系
sequenced-before 是内存模型中最基础的一层,只存在于同一个线程内部。不直接限制编译器重排。
它是一个关于逻辑因果关系的规则。编译器可以自由重排指令,只要最终的单线程结果(as-if 规则) 与 sequenced-before 定义的逻辑一致即可。
比如:
int main(){
int a = 1; // 1
int b = 2; // 2
a = 10; // 3
b = 20; // 4
printf("%d %d", a, b); // 5
}
在同一线程内,上述代码要求 int a = 1; 在 a = 10; 之前执行,因为前者逻辑上决定了后者的初始值。这就是 sequenced-before 关系。
但是编译器可以将 int b = 2; 提前到 int a = 1; 之前执行,因为它们之间没有逻辑依赖关系。
int a = 1; sequenced-before a = 10;,但 int b = 2; 和 a = 10; 之间没有 sequenced-before 关系。
同理,a = 10; 和 b = 20; 之间也没有sequenced-before 关系,可以互相重排;
即 :只要最终结果和逻辑因果关系一致,编译器可以自由重排指令顺序。
这一层关系决定了编译器在单线程中允许哪些指令重排,为后续所有跨线程关系提供“时间线基础”。
可以理解为“每个线程各自拥有一条内部时间线”。
第二层:synchronizes-with —— 原子操作之间的同步握手
如果说 sequenced-before 是线程内的时间线,那么 synchronizes-with 就是线程之间的连接点。
synchronizes-with 的职责并不是直接保证“所有内存的顺序”,而是在两个线程之间建立一条可靠的同步边,这条同步边,是构建更强可见性保证的关键中间步骤。
一个典型的 synchronizes-with 关系,发生在一个线程的 release 操作和另一个线程的 acquire 操作之间。
考虑以下场景:
int data = 0;
std::atomic<bool> flag = false;
// 线程 A
data = 1;
flag.store(true, std::memory_order_release);
// 线程 B
while (!flag.load(std::memory_order_acquire));
assert(data == 1);
在线程 A release 和 线程 B acquire 之前,两个线程各自的时间线是独立的,互不干扰(即使它们操作同一个原子变量)。
但是,一旦线程 B 成功执行了 acquire,它就“接住”了线程 A 的时间线,形成了 synchronizes-with 关系,线程B acquire 之后的所有操作,都能看到线程 A release 之前的所有操作。
第三层:happens-before —— 可见性的最终承诺
简单来说:如果 A happens-before B,那么 B 一定能够观察到 A 的结果。
happens-before 不是一种原始关系,而是通过同一线程内的 sequenced-before和跨线程的 synchronizes-with组合而成的。
小结
sequenced-before:定义线程内的因果顺序synchronizes-with:在原子操作上搭建线程间的桥梁happens-before:给出跨线程的最终可见性保证
C++内存顺序详解
C++11在语言标准中引入了原子操作库(<atomic>)和正式的内存模型。其核心是六种内存序(memory_order),定义了原子操作周围非原子内存访问的可见性顺序,让程序员可以在不同严格程度之间进行选择。
typedef enum memory_order {
memory_order_relaxed, // 最松散
memory_order_consume, // 消费(谨慎使用)
memory_order_acquire, // 获取
memory_order_release, // 释放
memory_order_acq_rel, // 获取-释放
memory_order_seq_cst // 顺序一致(默认)
} memory_order;
松散模型:memory_order_relaxed
这是约束最弱的内存序。
- 仅保证对同一个原子变量的修改在所有线程眼中有一个一致的全局顺序(Modification Order),
- 不建立任何线程间的同步关系(Happens-Before)
- 不限制编译器或CPU对其周围内存操作的重排。
比如:
std::atomic<int> x{0}, y{0};
// 线程 A
x.store(1, std::memory_order_relaxed); // (1)
y.store(1, std::memory_order_relaxed); // (2)
// 线程 B
int r1 = y.load(std::memory_order_relaxed); // (3)
int r2 = x.load(std::memory_order_relaxed); // (4)
线程 A 中的 x.store 和 y.store 没有 sequenced-before 关系
同样的,线程 B 中的两行代码也没有 sequenced-before 关系,
因此编译器可以重排线程 A 和线程 B 中的代码
因此,在relaxed语义下,即使线程B在(3)处读到了y == 1,它也不能推断线程A中(1)的写操作x = 1一定已经完成。
因为(1)和(2)可能被重排,或者(1)的结果还卡在 Store Buffe r里没对B可见。所以r1 == 1 && r2 == 0是一个可能的结果。
memory_order_relaxed 既不建立 synchronizes-with,也不形成任何跨线程的 happens-before,仅保证原子变量的修改顺序
适用场景:只在乎变量本身的原子性,不在乎它和其他变量的关系。比如:单纯的计数器
relaxed是给‘100% 确定不需要同步’的人准备的,而这种人通常不存在。
发布-获取模型:release & acquire
这是构建无锁数据结构(Lock-Free)的中流砥柱。它们成对出现,构成了 Synchronizes-With 关系。
memory_order_release
memory_order_release (写)的含义是:我写了这个原子变量后,我在原本代码中排在它前面的所有读写操作(包括普通变量),都必须做完,且对 acquire 这一方可见。
具体来说就是:
- 指令重排约束:禁止编译器将
release之前的内存写入操作重排到release之后。 - 可见性保证:所有在该
release操作之前完成的内存写入对其他线程可见,在硬件上通常需要刷空Store Buffer。
memory_order_acquire
memory_order_acquire (读)的含义是:我读了这个原子变量后,我在原本代码中排在它后面的所有读写操作,都不能提到它前面去执行。
具体来说就是:
- 指令重排约束:该
acquire操作之后的所有内存操作,都不能被重排到该acquire操作之前。 - 可见性保证:
release之前的所有写入在acquire之后都可见,在硬件上通常需要先处理完Invalidate Queue
在 C++ 语义层面,
release/acquire只保证 Synchronizes-With 关系;具体是否通过 fence、带语义的原子指令,还是无需额外指令,完全取决于目标架构和编译器实现。
回顾上面在介绍Synchronizes-With关系是的例子:
int data = 0;
std::atomic<bool> flag = false;
// 线程 A (发布者)
data = 1; // 1. 普通写
flag.store(true, std::memory_order_release); // 2. Release 写
// 线程 B (消费者)
while (!flag.load(std::memory_order_acquire)); // 3. Acquire 读
assert(data == 1); // 4. 安全!
在线程A中,尽管 data = 1 和 flag.store 没有逻辑关系,但是因为使用了 memory_order_release,相当于告诉编译器,不能将 data = 1 重排到 flag.store 后面,同时告诉 CPU 要保证 data = 1 的操作对其他核可见(如刷空 store buffer)
类似的,在线程B中,由于 memory_order_acquire 的存在,assert(data == 1) 被禁止重排到 flag.load 前面,同时告诉 CPU 要保证拿到 data 的最新结果(如处理完Invalidate Queue。
如果没有 release/acquire,而是采用 relaxed,那么线程A 和 线程B 可以自由的进行重排,即使没有重排,也可能因为没有清空 store buffer 使得线程B看到的是旧值。
结果就是,线程 B 可能看到 flag 为 true 时,data 还是0(因为 CPU 乱序)。 而有了release/acquire 这层关系,只要步骤 3 成功,步骤 1 就一定对步骤 4 可见。
在这里,release固定了本线程中的 sequenced-before(保证data在store之前),并允许这些操作被跨线程观察到,但是不保证有人一定能看到。
acquire则在本线程上接住了relase所在线程的时间线,与release形成synchronizes-with关系,保证acquire之后的操作都能看到对方release之前的结果(load之后一定能看到修改后的data)
从而在形成了整体的Happens-Before关系
memory_order_acq_rel
memory_order_acq_rel 用于读-改-写(Read-Modify-Write, RMW) 操作(如exchange, compare_exchange_strong, fetch_add)。它同时具有acquire和release的语义:
- 对于操作之前的访问,它具有
release语义 - 对于操作之后的访问,它具有
acquire语义 - 它自身是一个原子操作,保证了“读取值”和“写入新值”这两个步骤之间,不会被任何其他线程的写入所打断。
是实现自旋锁(spinlock)、引用计数等同步原语的基石。
std::atomic<int> lock{0};
void lock_acquire() {
while (lock.exchange(1, std::memory_order_acq_rel) == 1) { // 尝试获取锁
// spin...
}
// 进入临界区,能看见之前持有锁的线程的所有 release 写入
}
void lock_release() {
lock.store(0, std::memory_order_release); // 释放锁,让临界区的写入对后来者可见
}
尴尬的存在:memory_order_consume
consume 设计上比 acquire 更弱,旨在只同步数据依赖(data dependency) 于该原子负载的操作。
例如:你原子加载了一个指针 ptr,你解引用 *ptr 是安全的,但其他无关变量不保证可见。
理想很丰满,显示很骨感,这玩意儿太难实现了。编译器很难追踪复杂的依赖链。因此,目前主流编译器(GCC, Clang, MSVC)通常直接把 consume 提升为 acquire 处理。
标准委员会自己也承认这是一个失败的设计,C++20 之后,标准几乎已经“名存实亡”地放弃了 consume 的可用性。(参见:isocpp.org/files/paper…
因此,在当前的实践中,强烈建议直接使用 acquire/release,并避免使用 consume。
顺序一致性:memory_order_seq_cst
顺序一致性(Sequentially Consistent, seq_cst)是 C++ 原子操作的默认内存序,是最严格、也是最容易理解的模型。
它除了包含 acq_rel 的所有语义外,还额外保证: 所有使用 seq_cst 的操作(无论是读、写还是RMW)在所有线程眼中都有一个单一的、全局一致的执行顺序。
用人话说就是:所有线程看到的原子操作发生的顺序,都是一模一样的。
为什么需要这个?
对于多个生产者-多个消费者的情况,顺序排序可能是必要的,所有消费者必须观察以相同顺序发生的所有生产者的操作。
std::atomic<bool> x = {false};
std::atomic<bool> y = {false};
std::atomic<int> z = {0};
void write_x()
{
x.store(true, std::memory_order_seq_cst);
}
void write_y()
{
y.store(true, std::memory_order_seq_cst);
}
void read_x_then_y()
{
while (!x.load(std::memory_order_seq_cst))
;
if (y.load(std::memory_order_seq_cst))
++z;
}
void read_y_then_x()
{
while (!y.load(std::memory_order_seq_cst))
;
if (x.load(std::memory_order_seq_cst))
++z;
}
int main()
{
std::thread a(write_x);
std::thread b(write_y);
std::thread c(read_x_then_y);
std::thread d(read_y_then_x);
a.join(); b.join(); c.join(); d.join();
assert(z.load() != 0); // will never happen
}
上面的例子中,使用 seq_cst 保证了线程 C 和 D 看到的顺序是一致的,即read_y_then_x 和 read_y_then_x 中至少有一个的 ++z 一定会被执行。
如果是 release/acquire 模型,比如将上面代码中 load 操作的内存序都改为acquire,store操作的内存序都改为 release,那么线程 C 和 D 可能会看到完全相反的顺序
release/acquire 只能保证同一个原子对象建立 happens-before 关系,但是这里有两个原子对象,release/acquire 既不保证不同原子对象之间的顺序,也不保证不同同步链之间的相对可见性。
线程C 看到的顺序是 x = 1,然后看到y = 0;
而线程D可以先看到y = 1,再看到 x = 0;
结果就是线程C和线程D中的++z可能都不会被执行
而seq_cst保证了,如果线程C先看到x = 1,再看到y = 0(即y的写入在x后面),那么线程D看到的顺序和C看到的顺序一样,等到线程D看到y = 1(y的写入已完成)时,x 一定已经是1了。反之亦然。
代价就是:实现层面需要付出额外成本来维护这种全序幻觉。
比如,在 x86 上可能会插入重磅的 lock 前缀指令,或在 ARM 上插入 dmb ish 全屏障。
顺序一致性的理论基础:SC-DRF
C++内存模型建立在 SC-DRF(Sequential Consistency for Data Race Free) 这一重要理论基础之上。
该理论保证:
只要程序是"无数据竞争(Data Race Free)"的,且所有同步操作都使用
memory_order_seq_cst,那么整个程序的行为就会表现得如同顺序一致。
这解释了为什么"默认使用seq_cst"是如此合理的建议:
- 它让多线程程序的推理变得相对简单——你可以像思考单线程程序一样思考执行顺序
- 只要避免了数据竞争,你就能获得强一致性的保证
- 这相当于用性能代价(在某些架构上)换来了开发效率和正确性保证
当你需要优化性能而考虑使用更弱的内存序时,实际上是在脱离 SC-DRF 提供的"安全网",进入需要手动证明正确性的领域。
分类
可以将六种内存序分为三大类,理解其强度与用途:
| 类别 | 包含的 memory_order | 语义强度 | 典型用途 |
|---|---|---|---|
| 顺序一致 (SC) | seq_cst | 最强,有全局总序, | 默认选择,需要强保证的复杂同步 |
| 发布-获取 (Release-Acquire) | release, acquire, acq_rel | 强,能建立线程间同步, | 锁、条件变量、生产者-消费者、单次初始化 |
| 松散 (Relaxed) | relaxed | 最弱,仅原子性 | 计数器、标志位(无需同步时) |
最佳实践
- 默认使用
memory_order_seq_cst:不要过早优化!seq_cst提供的强一致性是符合人类直觉的,正确性远高于性能。如果你的程序连逻辑都是错的,跑得再快也是错的。 - 只有在 Profiler 告诉你这是瓶颈时,才考虑降级:只有在性能分析(Profiling)明确表明原子操作是瓶颈,且与内存序相关时,才考虑使用更弱的内存序。
- Code Review 必须加倍严格。 任何使用了
relaxed的代码,都应该被视作“由于使用了魔法而可能随时爆炸”的危险区域,必须写清楚注释,说明为什么这里不需要同步。 - 避免使用
memory_order_consume:除非你在为特定平台(如Linux内核)编写极致底层代码,并且完全了解其编译器的具体实现,否则请远离它。 - 使用现成模式库:对于大多数应用,使用标准库提供的互斥锁(
std::mutex)、条件变量(std::condition_variable)、以及高级并发结构(std::async,std::future),比直接使用裸原子操作和内存序要安全得多。这些库的接口已经为你封装了正确的内存序。
总结
C++ 内存模型是一个抽象机器(Abstract Machine)的规则集合,它定义了:
- 哪些重排是允许的
- 哪些原子操作之间可以建立 Happens-Before
- 程序在多线程下“可被观察到的行为边界”
微信公众号:午夜游鱼
个人博客原文:深入浅出现代C++内存模型