第二十一章:原子操作与内存一致性
级别 3 -- 经验
使用原子操作和内存排序进行无锁编程——C 并发的最深层次,编译器和 CPU 都是你要对抗的对手。
原子操作是所有高级并发原语的基础。互斥量使用原子操作。条件变量使用原子操作。即使是多线程程序中最简单的"这个标志是否设置?"检查也需要原子操作才能保证正确性。本章涵盖了本书中技术要求最高的内容。
21.1 先行发生关系
当两个线程访问相同的内存时,这些访问"发生"的顺序并不总是清楚的。编译器可能重排指令。CPU 可能重排内存操作。缓存层次结构可能延迟可见性。
先行发生(happened-before)关系定义了一个操作是否保证对另一个操作可见:
// C 深入:先行发生
// 线程 1: 线程 2:
// x = 1; if (flag == 1)
// flag = 1; assert(x == 1); // 可能失败没有适当的同步,线程 2 可能看到 flag == 1 但 x == 0。编译器或 CPU 可能重排存储,或者缓存可能在线程 2 读取 flag 时尚未传播对 x 的写入。
C 深入: 先行发生关系由 C 标准定义(以及之前的 Java 内存模型)。它是一种偏序关系:并非所有操作都彼此有序。只有通过同步(互斥量加锁/解锁、具有适当内存排序的原子操作)连接的操作才能建立先行发生关系。
关键洞察:程序顺序不是内存顺序。你编写的代码不是 CPU 执行的顺序,也不是其他线程观察效果的顺序。
Auto 的方法。 Auto 的 Actor 模型完全避免了这个问题。每个 Actor 拥有自己的数据。消息按顺序传递。没有共享可变状态意味着没有内存排序问题。
21.2 提供同步的 C 库调用
C11 通过 <stdatomic.h> 提供原子操作:
// C 深入:原子操作
#include <stdatomic.h>
#include <stdio.h>
#include <threads.h>
static _Atomic int counter = 0;
int worker(void *arg) {
for (int i = 0; i < 100000; i++) {
atomic_fetch_add(&counter, 1);
}
return 0;
}
int main(void) {
thrd_t t1, t2;
thrd_create(&t1, worker, NULL);
thrd_create(&t2, worker, NULL);
thrd_join(t1, NULL);
thrd_join(t2, NULL);
printf("Counter: %d\n", atomic_load(&counter)); // 200000
return 0;
}_Atomic 类型限定符使对该对象的操作成为原子的。atomic_fetch_add 函数原子性地递增计数器。不需要互斥量。
C 深入: 原子操作使用 CPU 级别原语实现:
- x86:
LOCK前缀指令(例如LOCK XADD)- ARM:
LDXR/STXR独占加载/存储对- 某些架构使用全局比较并交换循环
_Atomic类型可能具有与非原子对应物不同的大小或对齐方式。_Atomic(int)在主流平台上总是无锁的,但_Atomic(struct large)可能使用内部锁。
常见原子操作:
| 操作 | 函数 | 描述 |
|---|---|---|
| 加载 | atomic_load(&x) | 原子读取 |
| 存储 | atomic_store(&x, val) | 原子写入 |
| 获取并加 | atomic_fetch_add(&x, 1) | 加并返回旧值 |
| 获取并减 | atomic_fetch_sub(&x, 1) | 减并返回旧值 |
| 比较并交换 | atomic_compare_exchange_strong | CAS 操作 |
| 交换 | atomic_exchange(&x, val) | 原子交换 |
| 标志测试并设置 | atomic_flag_test_and_set | 简单自旋锁原语 |