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关系 内存序(6种) 原子操作 建立场景 • 同一线程:代码顺序 • 锁操作:释放→获取 • 原子操作:带内存序 • 线程创建/汇合 六种内存序 • relaxed:只保证原子性 • acquire/release:配对使用 • acq_rel:读-改-写 • seq_cst:默认,最严格 常用操作 • atomic_store/store_explicit • atomic_load/load_explicit • atomic_fetch_add/sub • atomic_flag_test_and_set 实际应用:自旋锁、无锁队列、计数器 核心原则:正确性优先,性能优化在后。先理解happens-before,再选择合适的内存序。

嗯,这张图基本把C11内存模型的骨架画出来了。从上到下,先理解happens-before这个理论基础,再掌握六种内存序的语义,最后落实到原子操作的具体使用中。

说实话,内存模型这东西,光看书是学不会的。我建议你动手写几个小例子,比如用relaxed实现一个计数器,用acquire/release实现一个标志位同步。跑起来,看看结果,再想想为什么。踩过坑,才能真正理解。

一句话总结:C11内存模型给了你一把双刃剑——用好了,可以写出高性能的无锁代码;用不好,bug能让你调试到怀疑人生。我的建议是:先求稳,再求快。


公众号:蓝海资料掘金营,微信deep3321