12、内存模型与并发编程:C11/C23的内存模型、数据竞争与happens-before关系

并发编程,说白了就是让程序同时干好几件事。但多线程一旦跑起来,问题就来了——两个线程同时读写同一个变量,结果会怎样?C语言在C11标准之前,对这个问题基本是「装死」状态。嗯,标准里压根没提线程这回事。直到C11引入了内存模型,才算是给并发编程立了规矩。

我个人习惯把内存模型理解成「编译器与硬件的交通规则」。没有规则,你写的代码可能被编译器优化得面目全非,也可能在CPU乱序执行时跑出匪夷所思的结果。今天我们就来聊聊这套规则的核心:happens-before关系数据竞争,以及C11/C23里那些你不得不知的细节。

12.1 为什么需要内存模型?

先看一个经典例子:

// 线程A
flag = 1;
data = 42;

// 线程B
if (flag == 1) {
    printf("%d\n", data);
}

你可能会想:线程A先写flag再写data,线程B看到flag为1时,data肯定已经是42了。但现实很残酷——编译器可能把data=42重排到flag=1之前,CPU也可能乱序执行。结果就是线程B看到flag为1,但data还是旧值。

我在项目中遇到过类似问题。当时调试一个多线程日志系统,日志顺序总是错乱。查了两天才发现是编译器优化把赋值顺序搞反了。从那以后,我对内存序的敬畏心就拉满了。

核心问题:没有内存模型,你无法保证多线程环境下「某个变量的写入对其他线程可见」。

12.2 C11/C23的内存模型概览

C11引入了六种内存序(memory order),C23基本沿用,只做了少量澄清。它们定义在<stdatomic.h>中:

内存序 含义 典型用途
memory_order_relaxed 最宽松,只保证原子性,不保证顺序 计数器、统计量
memory_order_consume 数据依赖顺序(C17起不推荐使用) 几乎不用
memory_order_acquire 防止其后的读写操作重排到前面 读锁、标志位检查
memory_order_release 防止其前的读写操作重排到后面 写锁、标志位设置
memory_order_acq_rel acquire + release 的组合 读-改-写操作
memory_order_seq_cst 最强顺序,全局一致 默认值,通用场景

你想想看,这六种内存序其实就是在「性能」和「顺序保证」之间做取舍。relaxed最快但最不靠谱,seq_cst最安全但可能拖慢性能。我个人的建议是:默认用seq_cst,只有当你明确知道自己在做什么,并且性能瓶颈确实在这里时,才考虑降级

12.3 happens-before关系:并发编程的基石

happens-before(先行发生)是内存模型的核心概念。它定义了两个操作之间的可见性顺序:如果操作A happens-before 操作B,那么A的结果对B是可见的。

怎么建立happens-before关系?主要有这几种方式:

  • 同一个线程内:按代码顺序,前面的操作happens-before后面的操作
  • 互斥锁:解锁操作happens-before后续的加锁操作
  • 原子操作:release操作happens-before后续的acquire操作
  • 线程创建与汇合:thrd_create happens-before新线程开始执行;线程结束happens-beforethrd_join返回

避坑指南:我曾经以为同一个线程内的所有操作天然就是happens-before的。但注意,编译器优化和CPU乱序执行可能会打破「代码顺序」的假象。只有通过原子操作或内存屏障,才能强制建立happens-before关系。

12.4 数据竞争:你踩过多少坑?

数据竞争的定义其实很简单:两个或多个线程同时访问同一内存位置,其中至少一个是写操作,且没有happens-before关系来保证顺序

数据竞争的后果是未定义行为。嗯,你没看错,C标准把数据竞争归类为UB。这意味着编译器可以「合法地」生成任何代码——包括让你的程序崩溃、输出错误结果,甚至删除你整个函数。

我曾经在一个嵌入式项目中遇到过数据竞争导致的诡异bug。两个线程共享一个状态变量,没有用原子操作,结果程序在调试模式下跑得好好的,一开优化就随机崩溃。查了三天,最后发现是编译器把变量优化到了寄存器里,线程B永远看不到线程A的修改。

警告:不要用volatile来解决数据竞争!volatile只告诉编译器不要优化掉这个变量,但它不提供任何原子性或内存序保证。C11之后,正确的做法是使用<stdatomic.h>中的原子类型。

12.5 实战:用原子操作避免数据竞争

来看一个完整的例子。假设我们要实现一个简单的计数器,多个线程并发递增:

#include <stdatomic.h>
#include <threads.h>
#include <stdio.h>

atomic_int counter = 0;

int thread_func(void *arg) {
    for (int i = 0; i < 1000000; i++) {
        atomic_fetch_add(&counter, 1);
    }
    return 0;
}

int main() {
    thrd_t t1, t2;
    thrd_create(&t1, thread_func, NULL);
    thrd_create(&t2, thread_func, NULL);
    thrd_join(t1, NULL);
    thrd_join(t2, NULL);
    printf("Counter: %d\n", atomic_load(&counter));
    return 0;
}

这里atomic_fetch_add默认使用memory_order_seq_cst,保证每次递增都是原子的,并且所有线程看到的counter值是一致的。如果你用普通的counter++,那就是数据竞争——两个线程可能同时读取counter,同时加1,然后同时写回,结果只增加了1而不是2。

12.6 内存序的选择策略

说了这么多,到底该怎么选内存序?我总结了一个简单的决策树:

  1. 能用默认就用默认:memory_order_seq_cst是最安全的,性能损失通常可以接受
  2. 单生产者-单消费者场景:可以考虑release/acquire,性能更好
  3. 纯计数器,不关心顺序:memory_order_relaxed就够用
  4. 读-改-写操作:memory_order_acq_rel

我的经验:在99%的场景下,seq_cst的性能损失不到5%。除非你在写高性能并发库,否则别在内存序上过度优化。我曾经见过有人为了省那点性能,用了relaxed结果引入了一个极难复现的bug,调试成本远超那点性能收益。

12.7 C23对内存模型的改进

C23在内存模型方面没有大改,但做了一些重要的澄清和补充:

  • 明确废弃memory_order_consume这个内存序在C17就被标记为不推荐,C23正式废弃。原因是编译器实现起来太复杂,而且几乎没有编译器真正实现了它。
  • 强化了atomic_flag的语义:保证atomic_flag_test_and_setatomic_flag_clear在所有平台上都能正确工作,作为自旋锁的基础。
  • 新增了atomic_compare_exchange的弱版本:在某些平台上,弱版本(weak)比强版本(strong)性能更好,允许虚假失败。

说白了,C23在内存模型上更务实了。它没有引入革命性的新概念,而是把C11/C17中那些「理论上很美、实践中很坑」的东西清理掉了。

12.8 知识体系图

下面这张图总结了本章的核心知识点和它们之间的关系:

C11/C23内存模型与并发编程知识体系 内存模型 内存序 (6种) happens-before 数据竞争 (UB) relaxed acquire/release acq_rel seq_cst (默认) 线程内顺序 锁的解锁 → 加锁 release → acquire 多线程同时访问 至少一个写操作 无happens-before 核心原则:用原子操作 + 正确内存序 避免数据竞争,建立happens-before关系

12.9 总结

内存模型是C语言并发编程的基石。没有它,你写的多线程代码就像在雷区里跳舞——随时可能踩到数据竞争这颗雷。C11/C23通过引入内存序happens-before关系原子操作,给了我们一套清晰的规则。

最后分享一个我个人的习惯:写多线程代码时,先假设所有共享变量都是「有毒」的。每次访问共享变量前,问自己三个问题:

  • 这个变量会被多个线程访问吗?
  • 有没有写操作?
  • 有没有建立happens-before关系?

如果三个问题的答案都是「是」,那你的代码大概率是安全的。否则,嗯,你懂的——bug正在某个角落里等着你。


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