16.2 C 并发内存模型
把完成标志改成 atomic,只完成了一半:还要说明读者何时能读旁边的数据。这里以 C11/C17 线程内存模型为基线。
两个线程都能访问同一地址,并不表示它们会按源码顺序互相看见。编译器可以在不改变单线程可观察结果的前提下重排和消除操作,CPU 也有 store buffer、cache coherence 和乱序执行。C11 内存模型给这几层规定了一份共同契约。
本章只讨论线程间可见性与原子操作。对象如何对齐、restrict 承诺什么、volatile 适合哪里,见对象布局、别名与 volatile。
1. 先从 data race 的正式条件出发
若不同线程中的两个求值:
- 访问同一个 memory location;
- 至少一个在修改;
- 至少一个访问不是原子操作;
- 两者之间没有 happens-before 关系;
它们构成 data race,C 程序行为未定义。
int counter = 0;
/* Thread A */ counter++;
/* Thread B */ counter++;counter++ 是读、计算、写的组合。即使目标机器的对齐 int 读写不会撕裂,这两个表达式仍无同步地冲突。问题不只是“最后少加一次”,编译器可以基于“正确程序没有 data race”的前提进行更激进优化。
三个常见修复方向是:
- 用 mutex 保护所有冲突访问;
- 把对象声明为原子类型,并选择足够的 memory order;
- 改变所有权,并在交接时建立同步;墙上时钟看似不重叠不等于 happens-before。
2. happens-before 是可见性的推理骨架
happens-before 不是墙上时钟先后。它由几类关系传递形成:
sequenced-before 描述同一线程内,语言规定的求值顺序。
synchronizes-with 来自特定同步事件,例如:
- 对同一 mutex 的 unlock 与后续成功 lock;
- acquire load 读取到 release store 发布的值;
- 线程创建与退出/join 所定义的同步;
- 语言或库明确规定的其他同步原语。
若 A sequenced-before release,release synchronizes-with acquire,acquire 又 sequenced-before B,那么 A happens-before B。
producer: write payload
release store ready=true
│
│ synchronizes-with
▼
consumer: acquire load sees true
read payload关键限定是“acquire 读到了相应 release 或其 release sequence 发布的值”。在同一对象上随便放一个 release 与 acquire,不会自动为所有执行建立关系。
3. 一个正确的发布协议
保存为 publication.c。下面的 worker 先写普通对象 answer,再用 release store 发布完成标志。主线程的 acquire load 观察到 ready == true 后,才读取 answer:
#include <pthread.h>
#include <sched.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
static int answer = 0;
static atomic_bool ready = false;
static void *compute(void *unused) {
(void)unused;
answer = 6 * 7;
atomic_store_explicit(&ready, true, memory_order_release);
return NULL;
}
int main(void) {
pthread_t worker;
if (pthread_create(&worker, NULL, compute, NULL) != 0) {
return EXIT_FAILURE;
}
while (!atomic_load_explicit(&ready, memory_order_acquire)) {
sched_yield();
}
printf("%d\n", answer);
if (pthread_join(worker, NULL) != 0) {
return EXIT_FAILURE;
}
return answer == 42 ? EXIT_SUCCESS : EXIT_FAILURE;
}cc -std=c17 -O2 -Wall -Wextra -Werror -pthread publication.c
./a.out
# 42answer 本身不是原子对象,但它的写 happens-before 主线程的读,所以没有 data race。如果主线程在 acquire 成功前偷读一次 answer,协议就被破坏。该协议只发布一次,worker 发布后不再改 answer;重用 ready 或覆盖 answer 需要新的所有权交接。sched_yield 只是调度提示,不保证公平或终止。
在这个小程序里,pthread_join 本身也能同步 worker 完成;保留标志是为了单独展示发布模式。生产代码若本来就要 join,通常不需要再忙等一个完成标志。
4. 六种 memory order 各管什么
C11 原子操作可以指定:
| 顺序 | 可用于 | 核心语义 |
|---|---|---|
relaxed | load/store/RMW/fence | 原子访问遵循单对象 modification order;relaxed fence 没有效果 |
consume | load/RMW/fence | 读取侧建立依赖顺序;工程中通常避免依赖 consume,consume fence 按 acquire fence 处理 |
acquire | load/RMW/fence | 读到对应发布值时,为后续访问建立获取侧顺序 |
release | store/RMW/fence | 为之前访问建立发布侧顺序,需要相应获取操作配合 |
acq_rel | RMW/fence | 同时具备 acquire 与 release 语义 |
seq_cst | load/store/RMW/fence | 对 seq_cst 操作再施加规范要求的单一总序 |
fence 需要通过相应原子读写建立同步,单独摆一对屏障不会发布数据。RMW 的失败 CAS 路径是 load,适用失败顺序的限制。
“更强”不等于“给所有普通变量自动加锁”。原子顺序只在满足相应读取、修改顺序与 synchronizes-with 条件时传播可见性。
relaxed 适合独立统计
若计数值不负责发布其他对象,只要求每次增量不丢失,可以使用 relaxed:
#include <stdatomic.h>
static atomic_ulong requests = 0;
void record_request(void) {
atomic_fetch_add_explicit(
&requests, 1, memory_order_relaxed
);
}每次 fetch-add 仍原子地参与 requests 的 modification order。它没有保证读取计数的线程会同时看到某个请求对象的其他字段。unsigned long 计数达到上限后按模回绕;要永久累计仍需定义溢出策略。
acquire/release 适合发布
上一节的 ready 是单向发布:producer 之前的写通过 release/acquire 对 consumer 可见。
seq_cst 适合先求清楚
不显式指定顺序的 C 原子操作默认 seq_cst。它通常最容易推理,适合作为正确版本的起点。只有性能测量证明同步成本重要,而且能够写出完整 happens-before 证明时,再弱化顺序。
不要在文档里写固定的“seq_cst 比 acquire 慢多少纳秒”。指令选择与成本依赖架构、操作类型、cache line 所在状态和竞争程度;编译器也可能把相邻操作合并进更大上下文。
5. 原子性不等于 lock-free
_Atomic(T) 保证对该对象的原子操作符合语言语义,不保证实现一定使用单条硬件指令。某些类型或对齐条件下,编译器运行库可以借助内部锁。
可以用:
atomic_is_lock_free(&object)查询具体对象的实现属性。即使返回 true,算法整体也不一定 lock-free;它可能在别的条件上无限等待。lock-free、wait-free 与 obstruction-free 描述的是系统进展保证,不是单个变量的类型标签。
原子操作也不能修复复合不变量。若“余额与交易状态必须一起变化”,把两个字段分别改成 atomic 仍可能让读者看到不一致组合。mutex 或经过证明的多字协议往往更清楚。
6. Read-modify-write 与 compare-exchange
atomic_fetch_add、atomic_exchange 与成功的 compare-exchange 是原子 read-modify-write(RMW);失败的 compare-exchange 只执行原子读取并更新 expected。CAS 的概念是:
if (*object == expected) {
*object = desired;
success
} else {
expected = *object
failure
}C API 在失败时会把实际值写回 expected。weak 版本还允许在值相等时虚假失败,因此通常放在循环里:
int expected = atomic_load_explicit(
&state, memory_order_relaxed
);
int desired = transform(expected);
while (!atomic_compare_exchange_weak_explicit(
&state,
&expected,
desired,
memory_order_acq_rel,
memory_order_relaxed)) {
desired = transform(expected);
}这是函数体片段,state 是 atomic_int,transform 接受并返回 int。第一次 CAS 前也要从 expected 计算 desired;失败后基于刷新后的 expected 重算。transform 必须允许重复求值,不能在每次尝试中重复产生业务副作用。失败顺序不能是 release 或 acq_rel,也不能强于成功顺序。
CAS 循环仍可能遭遇 ABA、饥饿、cache line 争用和内存回收难题。能原子更新一个机器字,不等于已经得到正确无锁容器。
7. mutex 往往比精巧原子协议更合适
mutex 的正确性含义比“阻止两人同时进入”更完整:成功 lock 与前一位持有者的 unlock 建立同步。临界区里的普通访问因此可以安全传递。
优先选 mutex 的情形包括:
- 要维护多个字段的不变量;
- 临界区可能演进,未来增加状态;
- 低竞争路径已经足够快;
- 需要条件变量、超时或优先级策略;
- 团队难以长期维护 lock-free 证明;
- 对象回收比原子更新本身更复杂。
原子操作的价值是表达特定共享协议,而不是为了避开“锁看起来不高级”。先写出状态机和进展条件,再决定原语。
8. 编译器重排与 CPU 重排要分开
一句“CPU 把两行交换了”经常混合三个层面:
- C 抽象机允许编译器生成与源码不同的指令顺序;
- ISA 对跨核观察规定某种 memory ordering;
- 微架构内部可以乱序执行,只要架构承诺仍满足。
C 原子 memory order 同时约束编译器转换,并映射到目标 ISA 所需的指令或屏障。相同 C 代码在不同架构上可能生成不同机器指令,但应具有相同语言级语义。
在 x86 上偶然可用的无原子代码仍是 C data race。换到 ARM 只是更容易暴露问题;错误从一开始就存在,不能把 C 正确性建立在某个 CPU 的强顺序习惯上。
9. volatile 为什么不能修复线程通信
把 ready 改成 volatile int 仍没有原子同步:
- 普通读写可能构成 data race;
- volatile 不建立 synchronizes-with;
- 它不发布旁边的
answer; - 它不提供复合原子操作;
- 它不定义跨线程进展保证。
C volatile 的含义不是“每次都从 DRAM 读”,也不是一个可移植的 compiler fence 口号。具体使用边界见配套的对象布局页。
Java 的 volatile 是 Java Memory Model 中的同步动作,语义明显强于 C volatile;也不能笼统说它与所有 C seq_cst 操作完全等价。Python 的 GIL 同样不是用户数据协议;扩展模块、I/O 释放锁,以及 CPython 3.13 起可选的 free-threaded 构建,都要求使用明确同步原语。
10. 用工具抓 race
ThreadSanitizer 可以在支持的平台上插桩。先用上面的 publication.c 检查工具能否运行;race.c 应是另存的、确实包含并发冲突的完整测试程序,并非前面几行片段:
cc -std=c17 -O1 -g -fsanitize=thread -fno-omit-frame-pointer -pthread race.c
./a.out它能发现许多实际执行到的 data race,但不是证明器:
- 没运行到的路径不会报告;
- 自定义同步原语可能需要额外标注;
- 时序和开销与生产构建不同;
- 某些平台或库不受支持;
- 逻辑死锁与原子性违规不一定是 data race。
静态分析、代码评审、压力测试和语言级证明仍然需要。修复报告时应恢复 happens-before 关系,不要只给对象加 volatile 或压制告警。
11. 推理练习
观察下面两段:
/* A: 只有原子计数 */
atomic_fetch_add_explicit(&completed, 1,
memory_order_relaxed);如果 completed 只是统计,relaxed 可以成立。若 consumer 把计数增加当作“任务结果已准备好”的信号,则顺序不够。
/* B: 发布一个不可变对象 */
initialize(object);
atomic_store_explicit(&published, object,
memory_order_release);
/* another thread: object is a thread-local variable */
object = atomic_load_explicit(&published,
memory_order_acquire);
if (object != NULL) {
use(object);
}若 acquire 读到 release 发布的指针,初始化写可以对 consumer 可见。但还要回答:
- 发布后对象是否保持不可变?
- 多个发布者如何竞争?
- 对象何时回收,读者是否仍持有指针?
published的原子指针类型是否声明正确?
无锁发布常常容易,安全回收更难。
12. 小结
C 并发内存模型把“看起来先写后读”变成可验证关系:
- data race 需要冲突访问、至少一个非原子操作且缺少 happens-before;
- release/acquire 只有在读到对应发布值时才同步;
- relaxed 保证原子性与单对象 modification order,不发布旁边数据;
- seq_cst 给 seq_cst 操作增加统一总序,不把混用的 relaxed 或普通访问自动纳入同一总序;
- 原子类型不保证实现 lock-free,也不自动维护多字段不变量;
- mutex、线程生命周期和条件变量同样建立同步关系;
volatile不是线程通信工具。
下一章进入汇编基础与调用约定,看编译器如何把对象访问、原子操作与函数调用落实成目标 ISA 的指令和 ABI。