先从一个很多人第一次写多线程时就会用到的小例子说起:
- 线程 A:先写数据,再把一个标志位
flag置为 1; - 线程 B:一直读
flag,等它变成 1 之后再去读数据;
按直觉来看,只要 B 线程看到 flag == 1,数据就应该已经准备好了。但在真实的多核机器上,这样的程序有可能偶尔读到旧数据甚至未初始化的数据。
这一篇,我们就从“如何把这种诡异行为挡在门外”的角度,聊聊:
- 硬件层面的 barrier/fence 指令 到底做了什么;
- C/C++11 里的
std::atomic和memory_order是怎样帮你安插这些 barrier; - C/C++ 标准里的内存模型,为什么是可移植并发编程的基础设施。
你可以把上一篇视作“这世界为什么这么乱”,而这一篇更偏“标准库和语言在帮你收拾哪几类烂摊子”。
从一个“理应没有问题”的例子说起
先用一个更贴近日常的版本,来回顾一下那个经典的“消息传递”模式。
int data = 0;
int flag = 0;
// 线程 A
void producer() {
data = 1; // 写入数据
flag = 1; // 通知对方:数据准备好了
}
// 线程 B
void consumer() {
while (flag != 1) {
// busy wait
}
int r = data; // 期望这里一定读到 1
}
在一个“朴素”的世界观里,我们会这么想:
- A 线程 先 把
data写成 1,再 把flag写成 1; - B 线程只有在看到
flag == 1时才跳出 while 循环; - 所以 B 跳出循环之后,
data就一定已经是 1。
如果我们把所有线程的读写,按时间顺序排成一条线,只允许“按顺序交错”的行为,上面这段程序似乎只有两种结局:
- B 还在循环里转圈,
flag依然是 0; - B 跳出了循环,并且
r一定是 1。
看到 r == 0 的情况,在这个模型里根本不被允许。
现实世界:松弛内存模型下的“额外结果”
可现实世界的硬件和编译器并不会这么“老实”。
为了压榨性能,它们会:
- 把写入暂时缓存在写缓冲区,稍后再写回缓存或内存;
- 对某些指令做乱序执行,只要最后单线程语义不变就行;
- 把多个相邻读写合并、重排,减少访存次数。
结果就是:
- 在一些架构(比如 ARM、Power)上,这段代码有可能现实中出现
r == 0的执行路径; - 某些编译器在激进优化时,也可能重排对
data和flag的写入,使得“对外可见”的顺序变成先flag后data。
换句话说,简单交错模型里不可能发生的行为,在真实机器上却发生了。
只要某个行为:
- 在“所有线程读写按顺序交错”的理想模型里不可能出现;
- 但在真实的硬件 + 编译器组合上会出现;
我们就说这个内存模型相对那个理想模型是“松弛(relaxed)”的。
问题在于:
- 像
r == 0这样的“额外结果”,对多数程序来说都是不受欢迎的; - 很多并发算法的正确性,恰恰是建立在“这些怪异情况不会发生”的假设之上。
接下来,我们就看一看:硬件和语言分别提供了哪些工具,来帮你把这些诡异行为挡在门外。
硬件的武器:barrier / fence 指令
先从硬件说起。
在像 ARM、Power 这样的体系结构上,处理器本身就提供了各种 barrier/fence 指令。你可以把它们理解成:
- 在指令流里插的“路障”;
- 告诉 CPU:“在这个点前后的某些内存访问,对其他核心来说不能再随意重排了”。
非常粗略地说,一条 barrier 会保证:
- 在 barrier 之前 的指定类型的内存访问,必须在对外可见顺序上,排在 barrier 之前;
- 在 barrier 之后 的指定类型的访问,必须排在 barrier 之后;
- 中间不能被硬件随意打乱成对外看起来更“松弛”的顺序。
这听起来很美——只要我在 data 和 flag 之间插上一条合适的 barrier,不就能禁止 r == 0 这种结果了吗?
但代价也同样直观:
- 每多一条 barrier,流水线就多一次“刹车”;
- 某些乱序和合并优化被迫关掉;
- 性能要为此付出代价。
所以,硬件会给你武器,但不会帮你决定哪里该用。这就是语言和标准库要接住的部分:如何在不把性能打死的前提下,提供一个比“直接写 barrier”更友好的接口。
C/C++11 的武器:原子类型和 memory_order
C 和 C++ 在 2011 版标准里,引入了一整套原子操作库(C 是 <stdatomic.h>,C++ 是 <atomic>),里面最核心的概念有两个:
std::atomic<T>这样的原子类型;memory_order_acquire/memory_order_release等内存序枚举值。
它们一起完成两件事:
- 约束编译器优化:告诉编译器在这些点上,哪些重排、合并、寄存器缓存不能做;
- 驱动硬件插入 barrier:在需要的地方生成相应的 fence 指令,让硬件也“收敛”到语言承诺的语义上。
我们把前面的消息传递例子,改成使用 C++11 原子的版本:
#include <atomic>
std::atomic<int> data{0};
std::atomic<int> flag{0};
// 线程 A
void producer() {
data.store(1, std::memory_order_relaxed); // 先写数据(可以相对宽松)
flag.store(1, std::memory_order_release); // 再写标志位,release
}
// 线程 B
void consumer() {
while (flag.load(std::memory_order_acquire) != 1) {
// busy wait,acquire
}
int r = data.load(std::memory_order_relaxed);
}
这里有几个关键点:
flag被声明为一个原子变量;- 对
flag的写使用了memory_order_release; - 对
flag的读使用了memory_order_acquire; data的读写用的是比较宽松的memory_order_relaxed。
在 C++11 的内存模型下:
- A 线程对
flag的 release 写; - B 线程看到这个值的 acquire 读;
这两者之间会建立一个 happens-before 关系。粗略理解就是:
只要 B 线程通过
flag.load(acquire)观察到了 A 线程flag.store(release)写入的那个值,那么在 A 线程中、这次写入之前发生的其他写入(比如对data的写),对 B 线程也必须可见。
换句话说:
release + acquire配对,帮你在两个线程之间建立了一种“先后次序”的保证;- 这个保证会从语言层透传到底层硬件,必要时自动生成 barrier 指令;
- 从而在结果集合里排除了 那些“flag 已经是 1,但 data 还是旧值”的怪异执行。
内存模型:把这些规则写进标准
从更“俯视”的角度看,一门并发编程语言至少要提供一组最小能力,让不同线程有机会就某个共享数据的状态达成一致。有研究(比如 Attiya 等人)指出,如果没有这种“达成共识”的机制,程序员在程序执行过程中甚至说不清“大家最终应该看到的值是什么”。
在比较松弛的内存模型下,这种“达成共识”的操作往往是昂贵的:它背后意味着某种形式的全局同步,会迫使硬件和编译器收一收刚才那些激进优化。在硬件层面,这类操作跟它们所限制的优化形式紧密相关;但在语言层面,相应的构造可以被设计得更直观一些。对这些“共识能力”的规范,其实就构成了内存模型设计空间中的一个维度。
一端是强内存模型。还记得我们前面虚构的那个“所有线程的读写都交错到一条时间线上”的简单模型吗?理论上,这就是所谓的顺序一致性(Sequential Consistency)。它的好处是语义简单、易于理解:程序员不必去脑补各种松弛并发下诡异的交互,因为很多“最奇怪的行为”在这个模型里被直接禁止了。
代价同样明显:为了维持这种强顺序,编译器必须生成更保守的代码,在必要的地方插入显式同步,关掉一部分重排和缓存优化。对于现代多核处理器来说,这些额外的同步往往意味着不小的性能开销。
另一端是非常松弛的内存模型。它尽量贴近底层松弛处理器的真实行为,让实现更高效,但把更多复杂性丢给了程序员。如果对内存顺序的保证过于薄弱,很多程序就很难给出一个“合理的规格说明”——你很难用自然语言说清楚:“在这种模型下,这段代码到底允许出现哪些结果、不允许出现哪些结果?”
于是就有了一个折中方案:松弛模型 + 显式同步特性。语言提供一套基础上比较松弛的默认语义,同时再给你一些“局部收紧顺序”的工具,比如互斥锁、栅栏 / 屏障,或者我们刚才例子里用到的各种 memory_order 注解。程序员需要在关键路径上,手动插入足够的显式同步,既要保证正确性,又尽量不要多到把性能拖垮。
还有一种思路,是通过要求程序员遵守某些编程规约,在仍然可以高效实现的前提下,提供比“完全松弛”更强的模型。比如:只要程序员承诺“不会写出某类存在 data race 的代码”,那么“这些危险模式不存在”就成了编译器在优化时可以依赖的不变条件。一旦程序违背了这种规约,语言就只能对它的行为给出比较弱的保证,甚至直接落入“未定义行为”的区域。
说回到这篇文章关心的 C/C++ 世界,上面这些行为——release/acquire 怎么配对、在什么条件下形成 happens-before、哪些结果被允许、哪些必须被排除——并不是某个编译器“心情好”额外送你的功能,而是被写进了标准本身。
2011 年的 C 和 C++ 标准文档,正式引入了一套内存模型,用来规定:
- 原子类型和普通变量在多线程下的语义;
- 不同
memory_order之间的关系; - 什么算 data race,什么是未定义行为;
- 哪些执行被视为符合内存模型,哪些算违规。
C++2014 基本保持了这套模型,只做了少量修补。
在这之前,两种语言在并发内存访问上的语义,更多是依赖 POSIX 线程库等外部规范,加上一些“约定俗成”的理解。Hans Boehm 在 2005 年就指出:
如果并发语义只是由一个和语言规范分离的库来描述,那么在定义语义时很容易出现循环依赖。
简单说就是:
- 语言标准不知道库会做什么;
- 库又可以假设编译器会做一些“合理”的优化;
- 结果就很难给出一个既准确又不自相矛盾的整体语义。
C/C++11 之后,这套内存模型把“并发语义”真正拉回到了语言规范的范围内,也给了像 std::atomic、memory_order 这样的库特性一个可以精确定义的地基。
写多线程代码时,可以记住这几件事
最后,用几条更偏实战的视角收个尾:
-
不要直接和“纯硬件世界”的松弛行为硬刚。 除非你在写极底层的运行时或内核代码,否则优先通过语言和标准库提供的抽象(锁、条件变量、并发容器、
std::atomic等)来约束行为,让编译器帮你安插 barrier。 -
一旦用了原子和内存序,就把自己当成在写“并发协议”。 先画清楚:哪些写入必须先于哪些读取发生,你希望排除掉哪些“怪异结果”。再去选择合适的
memory_order组合,而不是随手写成seq_cst或到处relaxed。 -
把和内存模型打交道的代码,圈在尽量小的范围里。 大部分业务逻辑尽量停留在“锁 + 高层并发原语”这一层;只有少数性能瓶颈、或者需要 lock-free 的数据结构,才用到精细的原子和内存序,并配合足够扎实的测试和观测手段。
理解了硬件 barrier、原子操作以及背后的内存模型,再回头看那些“偶发且难以复现”的多线程 Bug,你会发现:
- 它们并不是某种“玄学”;
- 而是完全可以用清晰的规则和模型来解释。
后面我们会单独开一篇,从 C++ 的 std::atomic 出发,具体拆解各种 memory_order 语义,以及它们在真实代码中的使用场景和坑点。