18、内存模型与内存序:C11内存模型、happens-before关系、内存序的六种类型
说到并发编程,很多人第一反应就是加锁。嗯,锁确实能解决问题,但它不是万能的。我早年做嵌入式系统时,就吃过内存序的亏——两个线程之间明明用了原子变量,结果数据还是乱套了。后来我才明白,光有原子操作还不够,你得理解内存模型。
说白了,C11引入的内存模型,就是给并发编程定了一套“交通规则”。没有这套规则,编译器、CPU、缓存这些家伙就会自作主张地重排你的指令,让你写的代码跑起来完全不是那么回事。
为什么需要内存模型?
你想想看,现代CPU有多复杂?多级缓存、乱序执行、指令流水线……为了性能,它们会偷偷调整指令的执行顺序。编译器也一样,为了优化代码,它会重新排列你的语句。
单线程下这没问题——反正结果一样。但多线程呢?
核心问题:一个线程对共享变量的修改,另一个线程什么时候才能看到?看到的顺序对不对?
我在项目中遇到过这样一个bug:线程A写了一个flag,线程B等着这个flag变成true后才去读数据。结果flag已经true了,数据却是旧的。为什么?因为CPU把数据写入和flag写入的顺序给颠倒了。这就是典型的内存序问题。
C11内存模型:三种关系
C11标准把线程间的交互抽象成三种关系:
- Sequenced-before( sequenced-before ):同一个线程内,语句按源码顺序执行。这是编译器保证的。
- Happens-before( happens-before ):跨线程的“先发生”关系。如果A happens-before B,那么A的结果对B可见。
- Synchronizes-with( synchronizes-with ):两个线程之间的同步点。比如一个线程释放锁,另一个线程获取同一把锁,这就构成了synchronizes-with关系。
这里最关键的就是happens-before。它定义了跨线程的可见性顺序。如果操作A happens-before 操作B,那么A写入的值,B一定能看到。反之,如果没有这个关系,B可能看到A的旧值,也可能看到新值——全看CPU心情。
Happens-before 的传递性
happens-before具有传递性。什么意思?
如果A happens-before B,B happens-before C,那么A happens-before C。这个特性非常有用,它让我们可以构建出复杂的同步逻辑。
举个例子:
// 线程1
x = 42; // A
atomic_store(&flag, 1, memory_order_release); // B
// 线程2
while (atomic_load(&flag, memory_order_acquire) != 1); // C
int y = x; // D
这里,B和C构成了synchronizes-with关系。所以B happens-before C。又因为A sequenced-before B,C sequenced-before D。所以A happens-before D。线程2读到的x一定是42。
我的经验:写并发代码时,脑子里要时刻画一条happens-before链。只要链断了,数据就可能出问题。我曾经为了性能,把release-acquire换成了relaxed,结果调试了整整两天——嗯,再也不敢了。
内存序的六种类型
C11定义了六种内存序,从松到严排列:
| 内存序 | 含义 | 开销 | 使用场景 |
|---|---|---|---|
| memory_order_relaxed | 最宽松,只保证原子性 | 最低 | 计数器、统计量 |
| memory_order_consume | 数据依赖序(很少用) | 较低 | 指针传递(但建议用acquire替代) |
| memory_order_acquire | 读操作,禁止后续读写重排到前面 | 中等 | 读取同步标志 |
| memory_order_release | 写操作,禁止前面读写重排到后面 | 中等 | 写入同步标志 |
| memory_order_acq_rel | acquire + release 合并 | 较高 | 读-改-写操作 |
| memory_order_seq_cst | 顺序一致性,最严格 | 最高 | 默认值,通用场景 |
六种内存序详解
1. memory_order_relaxed
这是最轻量级的。它只保证操作本身是原子的,不提供任何同步或排序保证。说白了,就是“我保证这个读写不会撕裂,但别的我不管”。
atomic<int> counter{0};
// 多个线程同时执行
counter.fetch_add(1, memory_order_relaxed);
这种场景下,我们只关心最终计数,不关心谁先谁后。用relaxed就足够了,性能最好。
注意:relaxed不提供happens-before关系。如果你用它来同步数据,一定会出问题。我曾经见过有人用relaxed实现自旋锁——结果锁根本不起作用,因为线程之间没有可见性保证。
2. memory_order_acquire 和 memory_order_release
这对搭档是并发编程中最常用的。它们配合使用,可以构建出happens-before关系。
- Release:保证在此之前的读写操作,不会被重排到release之后。
- Acquire:保证在此之后的读写操作,不会被重排到acquire之前。
看个典型用法:
atomic<bool> ready{false};
int data = 0;
// 生产者线程
data = 42; // A
ready.store(true, memory_order_release); // B
// 消费者线程
while (!ready.load(memory_order_acquire)); // C
int result = data; // D
这里B是release,C是acquire。B和C构成了synchronizes-with关系。所以A happens-before D。消费者一定能看到data=42。
3. memory_order_acq_rel
这个用于读-改-写操作,比如fetch_add、compare_exchange等。它同时具备acquire和release的效果。
atomic<int> val{0};
int expected = 0;
val.compare_exchange_strong(expected, 1, memory_order_acq_rel);
这个操作既读取了旧值(需要acquire语义),又写入了新值(需要release语义)。
4. memory_order_seq_cst
这是最严格的内存序,也是C11原子操作的默认值。它保证所有线程看到的操作顺序完全一致——就像所有操作都在一个全局时钟下执行一样。
atomic<int> x{0}, y{0};
// 线程1
x.store(1, memory_order_seq_cst);
y.store(1, memory_order_seq_cst);
// 线程2
int a = y.load(memory_order_seq_cst);
int b = x.load(memory_order_seq_cst);
使用seq_cst时,所有线程对x和y的修改顺序达成一致。不会出现线程2看到y=1但x=0的情况。
我的建议:刚开始学并发编程时,先用seq_cst。等代码跑通了,再考虑优化成更宽松的内存序。别一上来就追求极致性能——我见过太多人为了省那点开销,把代码搞出诡异的bug。
5. memory_order_consume
这个类型比较特殊,它只保证数据依赖关系。比如你读了一个指针,然后通过这个指针访问数据,consume保证你能看到指针指向的数据的最新值。
但实际上,几乎所有编译器都把consume当成acquire来实现。所以我的建议是:别用consume,直接用acquire。
知识体系总览
下面这张图总结了本章的核心内容:
如何选择合适的内存序?
这个问题没有标准答案,但我可以分享一些经验:
- 默认用seq_cst:除非你明确知道性能瓶颈在这里,否则别瞎优化。
- 计数器用relaxed:只关心最终值,不关心顺序。
- 标志位同步用release-acquire:这是最经典的配对用法。
- 读-改-写用acq_rel:比如自增、CAS操作。
- 别碰consume:它已经被证明是个失败的设计。
避坑指南:我曾经在一个高并发系统中,把所有的seq_cst都改成了relaxed,以为能大幅提升性能。结果呢?系统跑起来数据全乱了。后来花了整整一周才定位到问题——有些看似独立的原子变量,其实存在隐式的依赖关系。从那以后,我改内存序都是一个个改,改完就跑压力测试,绝不大批量替换。
总结
内存模型和内存序,是C语言并发编程的基石。理解它们,你才能真正写出正确的并发代码。
记住三个关键词:原子性、可见性、顺序性。原子性保证操作不撕裂,可见性保证修改能被其他线程看到,顺序性保证看到的内容是合理的。
嗯,这一章的内容就到这里。内存序这东西,光看理论是不够的,你得亲手写代码去感受。下次遇到诡异的并发bug,不妨先检查一下——是不是内存序用错了?