10. C++11/14/17/20内存模型演进:原子操作、内存序(memory_order)、happens-before关系
各位同学,今天我们来聊聊C++内存模型。说实话,这个话题在C++圈子里一直是个“硬骨头”。我刚开始接触多线程编程时,也被各种内存序搞得晕头转向。但别怕,咱们一步步来,把这块硬骨头啃下来。
10.1 为什么需要内存模型?
先问大家一个问题:你写了一个全局变量,两个线程同时读写它,会发生什么?
很多人会脱口而出:“数据竞争!”没错,但更深层的问题是——编译器可能重排你的指令,CPU也可能乱序执行,甚至缓存不一致。这些因素加在一起,你看到的代码执行顺序,和实际发生的顺序,可能完全是两码事。
我在项目中遇到过这样一个bug:一个线程写了一个flag变量,另一个线程等着这个flag变成true才继续执行。代码逻辑看起来天衣无缝,但程序就是时不时卡死。后来排查发现,是CPU把flag的写入操作重排到了其他操作之后。嗯,这就是没有内存模型约束的后果。
核心概念:C++11之前,C++标准对多线程几乎没有任何规定。你只能依赖编译器扩展或平台特定的API(如pthreads、Windows API)。C++11引入了正式的内存模型,这才让跨平台的多线程编程有了统一的语义基础。
10.2 原子操作:最小的不可分割操作
原子操作,说白了就是“要么不做,要么做完”。一个原子操作在执行过程中,不会被其他线程打断。
C++11提供了std::atomic<T>模板,支持各种原子操作。来看个最简单的例子:
#include <atomic>
#include <thread>
#include <iostream>
std::atomic<int> counter(0);
void worker() {
for (int i = 0; i < 10000; ++i) {
counter.fetch_add(1, std::memory_order_relaxed);
}
}
int main() {
std::thread t1(worker);
std::thread t2(worker);
t1.join();
t2.join();
std::cout << counter.load() << std::endl; // 输出20000
return 0;
}
你看,fetch_add就是一个原子操作。两个线程同时加1,结果永远是20000。如果用普通的int,结果大概率会小于20000。
我的建议:能用std::atomic就别用volatile。volatile只告诉编译器不要优化,但它不保证原子性,也不保证内存序。我见过太多人把volatile当原子操作用,结果踩坑踩得头破血流。
10.3 内存序(memory_order):控制可见性与顺序
原子操作只是基础,真正让内存模型变得复杂的是内存序。C++11定义了6种内存序,从松到严分别是:
| 内存序 | 含义 | 使用场景 |
|---|---|---|
| memory_order_relaxed | 最宽松,只保证原子性 | 计数器、统计信息 |
| memory_order_consume | 数据依赖顺序(C++17已弃用) | 几乎不用,建议用acquire替代 |
| memory_order_acquire | 后续读写不能重排到该操作之前 | 读取锁、读取标志位 |
| memory_order_release | 之前的读写不能重排到该操作之后 | 写入锁、写入标志位 |
| memory_order_acq_rel | acquire + release | 读-改-写操作 |
| memory_order_seq_cst | 最严格,全局顺序一致 | 默认值,简单但性能开销大 |
我个人习惯是:默认用seq_cst,性能瓶颈时再考虑降级。为什么?因为seq_cst最容易理解,也最不容易出错。等你把程序调通了,再用性能分析工具看看哪里是热点,再针对性地换成更宽松的内存序。
10.4 happens-before关系:多线程的“因果律”
happens-before关系,说白了就是“如果A happens-before B,那么A的结果对B可见”。这是多线程编程中最基本也最重要的概念。
在C++内存模型中,happens-before关系由以下几种方式建立:
- 同一个线程内:按代码顺序,前面的操作happens-before后面的操作
- 线程创建:父线程的
std::thread构造函数happens-before新线程的开始 - 线程join:线程的执行happens-before
join()返回 - 互斥锁:解锁操作happens-before下一个加锁操作
- 原子操作:release操作happens-before对应的acquire操作
来看一个经典的例子:
std::atomic<bool> ready(false);
int data = 0;
void producer() {
data = 42; // A
ready.store(true, std::memory_order_release); // B
}
void consumer() {
while (!ready.load(std::memory_order_acquire)); // C
std::cout << data << std::endl; // D
}
这里,A happens-before B(同一线程内),B happens-before C(release-acquire配对),C happens-before D(同一线程内)。所以A happens-before D,data的值42一定能被消费者线程看到。
我曾经踩过的坑:有一次我用了memory_order_relaxed来读写一个标志位,结果消费者线程看到了标志位变成true,但读取关联数据时却拿到了旧值。原因就是relaxed不提供任何happens-before保证。从那以后,我对内存序的选择就格外谨慎了。
10.5 C++11/14/17/20内存模型演进
内存模型不是一成不变的,每个C++标准版本都在完善它:
- C++11:正式引入内存模型,定义了6种内存序和happens-before关系
- C++14:主要是bug修复,没有重大变化
- C++17:弃用了
memory_order_consume,因为编译器实现太复杂,几乎没人用对 - C++20:引入了
std::atomic_ref、std::atomic<std::shared_ptr>,以及信号量、闩锁、屏障等同步原语
我个人觉得,C++20的改进最实用。特别是std::atomic_ref,它让你可以对非原子变量执行原子操作,这在处理大型数据结构时特别有用——你不需要把整个结构体都声明为原子类型。
10.6 知识体系总览
下面这张图总结了本章的核心内容,我建议你保存下来,写代码时对照着看:
10.7 避坑指南与最佳实践
最后,分享几个我这些年总结出来的经验:
- 能用锁就别折腾原子操作。很多人觉得原子操作比锁快,就拼命用原子操作。但原子操作的正确性很难保证,而且
seq_cst的开销并不比锁小多少。我建议:先写对的,再写快的。 - 不要滥用
memory_order_relaxed。它只适合计数器这种“丢了几个值也无所谓”的场景。但凡涉及数据同步,至少要用acquire/release。 - 多线程编程的黄金法则:如果你不确定某个操作是否安全,那它大概率不安全。写多线程代码时,保持敬畏之心。
- 善用工具:ThreadSanitizer(TSan)能帮你检测数据竞争。我每次提交多线程代码前,都会用TSan跑一遍。
总结一下:C++内存模型的核心就是三件事——原子操作让你能安全地读写共享变量,内存序控制着这些操作的可见性和顺序,happens-before关系则把这一切串起来,形成一套完整的因果逻辑。理解了这三者的关系,你就能写出正确、高效的多线程代码。
公众号:蓝海资料掘金营,微信deep3321