跳到内容

第 3 卷 / 第 16 章 / 第 2 课

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 程序行为未定义。

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。

text
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:

c
#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;
}
bash
cc -std=c17 -O2 -Wall -Wextra -Werror -pthread publication.c
./a.out
# 42

answer 本身不是原子对象,但它的写 happens-before 主线程的读,所以没有 data race。如果主线程在 acquire 成功前偷读一次 answer,协议就被破坏。该协议只发布一次,worker 发布后不再改 answer;重用 ready 或覆盖 answer 需要新的所有权交接。sched_yield 只是调度提示,不保证公平或终止。

在这个小程序里,pthread_join 本身也能同步 worker 完成;保留标志是为了单独展示发布模式。生产代码若本来就要 join,通常不需要再忙等一个完成标志。

4. 六种 memory order 各管什么 ​

C11 原子操作可以指定:

顺序可用于核心语义
relaxedload/store/RMW/fence原子访问遵循单对象 modification order;relaxed fence 没有效果
consumeload/RMW/fence读取侧建立依赖顺序;工程中通常避免依赖 consume,consume fence 按 acquire fence 处理
acquireload/RMW/fence读到对应发布值时,为后续访问建立获取侧顺序
releasestore/RMW/fence为之前访问建立发布侧顺序,需要相应获取操作配合
acq_relRMW/fence同时具备 acquire 与 release 语义
seq_cstload/store/RMW/fence对 seq_cst 操作再施加规范要求的单一总序

fence 需要通过相应原子读写建立同步,单独摆一对屏障不会发布数据。RMW 的失败 CAS 路径是 load,适用失败顺序的限制。

“更强”不等于“给所有普通变量自动加锁”。原子顺序只在满足相应读取、修改顺序与 synchronizes-with 条件时传播可见性。

relaxed 适合独立统计 ​

若计数值不负责发布其他对象,只要求每次增量不丢失,可以使用 relaxed:

c
#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) 保证对该对象的原子操作符合语言语义,不保证实现一定使用单条硬件指令。某些类型或对齐条件下,编译器运行库可以借助内部锁。

可以用:

c
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 的概念是:

text
if (*object == expected) {
    *object = desired;
    success
} else {
    expected = *object
    failure
}

C API 在失败时会把实际值写回 expected。weak 版本还允许在值相等时虚假失败,因此通常放在循环里:

c
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 把两行交换了”经常混合三个层面:

  1. C 抽象机允许编译器生成与源码不同的指令顺序;
  2. ISA 对跨核观察规定某种 memory ordering;
  3. 微架构内部可以乱序执行,只要架构承诺仍满足。

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 应是另存的、确实包含并发冲突的完整测试程序,并非前面几行片段:

bash
cc -std=c17 -O1 -g -fsanitize=thread   -fno-omit-frame-pointer -pthread race.c
./a.out

它能发现许多实际执行到的 data race,但不是证明器:

  • 没运行到的路径不会报告;
  • 自定义同步原语可能需要额外标注;
  • 时序和开销与生产构建不同;
  • 某些平台或库不受支持;
  • 逻辑死锁与原子性违规不一定是 data race。

静态分析、代码评审、压力测试和语言级证明仍然需要。修复报告时应恢复 happens-before 关系,不要只给对象加 volatile 或压制告警。

11. 推理练习 ​

观察下面两段:

c
/* A: 只有原子计数 */
atomic_fetch_add_explicit(&completed, 1,
                          memory_order_relaxed);

如果 completed 只是统计,relaxed 可以成立。若 consumer 把计数增加当作“任务结果已准备好”的信号,则顺序不够。

c
/* 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。

对照语言规则 ​

Built with VitePress | Software Systems Atlas