C++ 并发:内存序、可见性与指令重排
本文面向有一定 C++ 并发基础的读者(知道线程、互斥量、基本的 用法),但想把'为什么这样'弄清楚。我们会从 的语义出发,讲清 和 的关系——不是空洞的定义,而是大量实战例子、容易踩的坑和调试技巧。
探讨 C++ 并发编程中的内存模型核心概念。内容涵盖 CPU 缓存一致性、指令重排机制以及 C++11 内存模型下的 happens-before 关系。详细解析了 std::atomic 的不同 memory_order 语义(relaxed, acquire, release, seq_cst),并通过双重检查锁定等实战案例说明如何正确使用原子变量避免数据竞争。文章还介绍了性能考量、调试工具(如 TSan)及工程实践清单,旨在帮助开发者编写高效且安全的并发代码。

本文面向有一定 C++ 并发基础的读者(知道线程、互斥量、基本的 用法),但想把'为什么这样'弄清楚。我们会从 的语义出发,讲清 和 的关系——不是空洞的定义,而是大量实战例子、容易踩的坑和调试技巧。
std::atomicstd::atomic先给你一个看起来简单但会'出错'的例子:
int x = 0, y = 0;
void thread1(){
x = 1; // A
int r1 = y; // B
}
void thread2(){
y = 1; // C
int r2 = x; // D
}
直觉会告诉你 r1 == 0 && r2 == 0 不可能同时成立:因为若两个线程都先写后读,总有一个先写早于另一个后读。但在现实的多核处理器上,如果没有同步,两个读取同时得到 0 是可能的——因为写入对其他核可见需要时间,或编译器/CPU 做了重排。
这就是为什么我们不能把并发程序的正确性只交给直觉:你需要明确'一个操作对另一个操作是否可见'的约定,也就是 happens-before。
三个最常见的术语:
硬件保证的通常是 缓存一致性(cache coherence)——同一地址的不同副本(存在于多个 cache 层)最终会保持一致。但这并不自动保证操作间的全局顺序性,也不防止编译器在不破坏单线程语义的前提下重排指令。
现代多核 CPU 通常实现 MESI(或其变体)协议来维护缓存一致性。
重要的限制:
举例:当线程 A 在地址 p 写 1,线程 B 立刻读 p,并不一定马上得到 1;缓存一致性保证最终能看到 1,但在没有内存屏障或原子操作的情况下'最终'可能对短时间窗口无保证。
总结:cache coherence 是必要但不足的并发正确性基础。
现代编译器会为了优化而重排代码,但不会改变单线程程序的可观测行为(所谓'as-if'规则)。同理,CPU 也可能为乱序执行、预测分支而产生看似重排的执行顺序。
两种重排来源:
mfence)来强制顺序。例子(编译器重排):
a = 1;
int t = b;
编译器可能把两行对应的内存操作重排,若 a 和 b 在另一个线程中被交叉访问,就会看到不同的 interleaving。
因此我们需要显式同步原语(如原子变量或屏障)来约束重排。
std::atomic:happens-before 是怎样建立的C++11 为并发设计了内存模型,核心概念有两点:
std::atomic 提供了一组原子操作与不同的内存序(memory order),通过这些操作我们可以建立 happens-before 关系。
最常见的一对语义是 release-acquire:
store 带 memory_order_release,load 带 memory_order_acquire,若 load 读取到 store 的值,那么 store 之前发生的所有内存写,对 load 之后的线程可见(也就是说,release -> acquire 建立了内存上的可见性屏障)。
这就解决了我们开头的小实验:若 x 用 store(release) 写,y 用 load(acquire) 读,会把两个线程的写入顺序串联起来,避免同时得到 0 的情况。
memory_order 详解:relaxed / acquire / release / seq_cstC++ 提供了几种内存序:
memory_order_relaxed:仅保证原子性,不保证任何跨线程的顺序性或可见性;用于不需要同步的计数器等场景。memory_order_release:写操作带 release 语义;与后续的 acquire 形成屏障。memory_order_acquire:读操作带 acquire 语义;保证在该读之后看到 release 之前发生的内存变化。memory_order_acq_rel:用于读写(如交换)既具 acquire 又具 release。memory_order_seq_cst(顺序一致性):最强保证,所有 seq_cst 操作在全局上形成一个单一的总顺序(简化理解但代价高)。示例:
std::atomic<int> x{0}, y{0};
int r1 = 0, r2 = 0;
// Thread 1
x.store(1, std::memory_order_relaxed);
r1 = y.load(std::memory_order_relaxed);
// Thread 2
y.store(1, std::memory_order_relaxed);
r2 = x.load(std::memory_order_relaxed);
若使用 relaxed,上面的代码仍可能返回 r1 == 0 && r2 == 0。若改成 release/acquire,就能禁止这种结果。
注意:seq_cst 是最易理解的,但在某些平台上实现代价更高。因此工程中常把 Release/Acquire 作为首选,只有在需要全局强顺序时才用 seq_cst。
Memory fence(内存屏障)是底层机制,std::atomic 的 release/acquire 在很多实现中会翻译成特定的 CPU 指令序列或借助 compiler barrier。
常见 fence 类型:
举例(x86 下的语义简化):
MOV 写在 cache coherence 上是强有保证的,但需要 mfence 才能实现 full fence;在 C++ 层,我们几乎不用直接写 mfence——使用 std::atomic_thread_fence 或 atomic 的 memory_order 即可。但在嵌入式或内核编程,你会直接面对这些指令。
双重检查锁定是一种常见的懒汉单例实现,但未正确使用内存序会导致严重的可见性问题。
错误实现:
Singleton* instance = nullptr;
Singleton* get(){
if(instance == nullptr){
std::lock_guard<std::mutex> lk(mutex);
if(instance == nullptr) instance = new Singleton();
}
return instance;
}
在没有合适内存序的情况下,另一个线程可能看到 instance 非空但构造尚未完成(构造重排问题)。正确做法是把 instance 定义为 std::atomic<Singleton*> 并在写入时使用 release,在读取时使用 acquire:
std::atomic<Singleton*> inst{nullptr};
Singleton* get(){
Singleton* tmp = inst.load(std::memory_order_acquire);
if(tmp == nullptr){
std::lock_guard<std::mutex> lk(mutex);
tmp = inst.load(std::memory_order_relaxed);
if(tmp == nullptr){
tmp = new Singleton();
inst.store(tmp, std::memory_order_release);
}
}
return tmp;
}
关键点:release-store 确保在 store 之前构造的初始化对后续 acquire-load 的线程可见。
atomic 保证顺序std::atomic<int> a, b; a.store(1); b.store(1); 并不能保证另一线程先观察到 a==1 再观察到 b==1,除非使用 release/acquire 将它们串起来。
memory_order_relaxedrelaxed 在高性能统计计数时有用,但若你用它建立同步,可能出现可见性丢失的 bug。
seq_cst 以为一劳永逸虽然 seq_cst 提供强保证,但它也可能在某些体系结构上限制编译器/CPU 的优化,从而影响性能。更现实的策略是用 release/acquire 精确地建立必要的屏障。
std::atomic<T> 当作'更快的锁'来替代锁原子变量适合小粒度同步(标志、计数器),但对复杂的数据一致性或多个变量的原子性,仍需锁或更复杂的同步协议。
工程实践:优先用互斥量实现正确性(简单可靠),在热点处进行剖析,若证明锁成为瓶颈,再考虑用原子或无锁结构优化。
使用 TSan 时注意:在大量原子/锁操作的程序中它会产生误报或过多噪声,但通常第一步应该运行 TSan 来快速定位 race 条件。
并发程序的难点在于 可见性 与 顺序 不是天然具有的,而是靠语言与硬件提供的抽象来建立。std::atomic 与 C++11 内存模型把这些抽象搬到了语言层面,让你能够以更可控、更可移植的方式写并发代码。理解 happens-before、release-acquire、cache coherence 与指令重排的交互,是写出正确与高效并发程序的关键。
写并发代码的黄金路径是:

微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online