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_createhappens-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 内存序的选择策略
说了这么多,到底该怎么选内存序?我总结了一个简单的决策树:
- 能用默认就用默认:
memory_order_seq_cst是最安全的,性能损失通常可以接受 - 单生产者-单消费者场景:可以考虑
release/acquire,性能更好 - 纯计数器,不关心顺序:
memory_order_relaxed就够用 - 读-改-写操作:用
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_set和atomic_flag_clear在所有平台上都能正确工作,作为自旋锁的基础。 - 新增了
atomic_compare_exchange的弱版本:在某些平台上,弱版本(weak)比强版本(strong)性能更好,允许虚假失败。
说白了,C23在内存模型上更务实了。它没有引入革命性的新概念,而是把C11/C17中那些「理论上很美、实践中很坑」的东西清理掉了。
12.8 知识体系图
下面这张图总结了本章的核心知识点和它们之间的关系:
12.9 总结
内存模型是C语言并发编程的基石。没有它,你写的多线程代码就像在雷区里跳舞——随时可能踩到数据竞争这颗雷。C11/C23通过引入内存序、happens-before关系和原子操作,给了我们一套清晰的规则。
最后分享一个我个人的习惯:写多线程代码时,先假设所有共享变量都是「有毒」的。每次访问共享变量前,问自己三个问题:
- 这个变量会被多个线程访问吗?
- 有没有写操作?
- 有没有建立happens-before关系?
如果三个问题的答案都是「是」,那你的代码大概率是安全的。否则,嗯,你懂的——bug正在某个角落里等着你。
公众号:蓝海资料掘金营,微信deep3321