14、并发编程中的内存模型:C11内存模型概述、happens-before关系、内存序(memory_order)详解、原子操作的内存序使用
说到多线程编程,很多人第一反应就是加锁。嗯,锁确实能解决问题,但它不是全部。我早年做嵌入式系统时,就吃过内存可见性的亏——两个线程共享一个标志位,明明在一个线程里改了,另一个线程就是读不到新值。当时我以为是CPU缓存的问题,后来才明白,这背后是内存模型在作祟。
今天我们就来聊聊C11标准引入的内存模型。这东西听起来很理论,但说白了,它就是一套规则,告诉编译器和CPU:你对内存的操作,到底该怎么排序、怎么同步。
14.1 C11内存模型概述
C11标准之前,C语言对多线程基本是「爱答不理」的状态。你写pthread也好,用Windows API也罢,编译器根本不关心你的线程同步需求。它只认单线程的优化规则——该重排指令就重排,该合并写操作就合并。
这就带来了问题。我举个例子:
// 线程A
flag = 1;
data = 42;
// 线程B
while (!flag);
printf("%d", data);
你猜data的值一定是42吗?不一定。编译器可能把data = 42优化到flag = 1之前,CPU也可能乱序执行。结果就是,线程B看到flag为1,但data还是旧值。
C11内存模型就是为了解决这类问题而生的。它定义了三种基本模型:
- 顺序一致性(Sequential Consistency):最严格,所有线程看到的操作顺序都一样。代价是性能开销大。
- 获取-释放(Acquire-Release):折中方案,保证关键点的同步,但不保证全局顺序。
- 松散(Relaxed):基本不保证顺序,只保证原子性。适合计数器这种场景。
我个人习惯是:能用顺序一致性就用,性能扛不住再降级。别一开始就想着优化,先把正确性保住。
14.2 happens-before关系
happens-before,直译就是「发生在之前」。它是内存模型的核心概念,用来判断两个操作之间是否存在顺序保证。
说白了,如果操作A happens-before 操作B,那么A的结果对B是可见的。注意,这不是时间上的先后,而是逻辑上的保证。
哪些情况会建立happens-before关系?
- 同一个线程内:按代码顺序,前面的操作happens-before后面的操作。
- 锁的释放与获取:解锁操作happens-before后续的加锁操作。
- 原子操作:带有适当内存序的原子操作可以跨线程建立happens-before。
- 线程创建与汇合:线程创建happens-before新线程的第一条指令;线程的最后一条指令happens-beforejoin返回。
关键点:happens-before是传递的。如果A happens-before B,B happens-before C,那么A happens-before C。这个传递性在实际编程中非常有用。
我曾经在调试一个网络服务器时,发现两个线程之间共享的请求队列总是出现数据不一致。查了半天,发现是生产者线程的写操作和消费者线程的读操作之间没有建立happens-before关系。加上一个原子标志位后,问题就解决了。
14.3 内存序(memory_order)详解
C11定义了六种内存序,从松到严排列如下:
| 枚举值 | 含义 | 典型用途 |
|---|---|---|
| memory_order_relaxed | 松散序,只保证原子性 | 计数器、统计量 |
| memory_order_consume | 消费序,依赖关系保证 | 指针传递(很少用) |
| memory_order_acquire | 获取序,后续读不能重排到前面 | 读锁、标志位检查 |
| memory_order_release | 释放序,前面的写不能重排到后面 | 写锁、标志位设置 |
| memory_order_acq_rel | 获取+释放 | 读-改-写操作 |
| memory_order_seq_cst | 顺序一致性 | 默认值,通用场景 |
你想想看,为什么需要这么多?因为不同的场景对同步的要求不一样。比如一个统计访问次数的计数器,你只需要保证加减操作是原子的,不需要关心顺序。这时候用relaxed就够了。
我的建议:新手先从memory_order_seq_cst开始用。等你对内存模型有了感觉,再尝试降级优化。我见过太多人一上来就用relaxed,结果出了bug还找不到原因。
14.4 原子操作的内存序使用
光说不练假把式。我们来看看实际代码中怎么用这些内存序。
先看一个最简单的例子——自旋锁:
#include <stdatomic.h>
typedef struct {
atomic_flag flag;
} spinlock_t;
void spinlock_lock(spinlock_t *lock) {
while (atomic_flag_test_and_set_explicit(
&lock->flag, memory_order_acquire));
}
void spinlock_unlock(spinlock_t *lock) {
atomic_flag_clear_explicit(
&lock->flag, memory_order_release);
}
这里用acquire和release配对。为什么?因为加锁时,我们需要看到其他线程在释放锁之前做的所有写操作。释放锁时,我们需要保证自己的写操作对其他线程可见。
再看一个更实际的场景——生产者消费者模型:
atomic_int items_produced = 0;
int buffer[BUFFER_SIZE];
void producer() {
int item = produce_item();
int idx = atomic_fetch_add_explicit(
&items_produced, 1, memory_order_relaxed);
buffer[idx] = item;
atomic_store_explicit(
&items_produced, idx + 1, memory_order_release);
}
void consumer() {
int idx;
do {
idx = atomic_load_explicit(
&items_produced, memory_order_acquire);
} while (idx == 0);
int item = buffer[idx - 1];
consume_item(item);
}
这里有个细节:为什么producer里先用了relaxed,最后又用release?因为fetch_add本身不需要同步,但最后存储时,必须保证buffer的写入对其他线程可见。这就是release的作用。
注意:memory_order_consume在实际使用中非常坑。大多数编译器(包括GCC和Clang)都把它提升为memory_order_acquire。我建议你直接忽略它,用acquire代替。
最后,我们通过一张图来梳理整个内存模型的知识体系:
嗯,这张图基本把C11内存模型的骨架画出来了。从上到下,先理解happens-before这个理论基础,再掌握六种内存序的语义,最后落实到原子操作的具体使用中。
说实话,内存模型这东西,光看书是学不会的。我建议你动手写几个小例子,比如用relaxed实现一个计数器,用acquire/release实现一个标志位同步。跑起来,看看结果,再想想为什么。踩过坑,才能真正理解。
一句话总结:C11内存模型给了你一把双刃剑——用好了,可以写出高性能的无锁代码;用不好,bug能让你调试到怀疑人生。我的建议是:先求稳,再求快。
公众号:蓝海资料掘金营,微信deep3321