11.1 锁是怎样工作的
高级接口会遮住两套关键机制:锁要把并发协议落实到原子操作与等待策略,动态分配器则要管理一片不断切分、合并的内存。只有看见它们的边界,才能判断一次同步或分配究竟付出了什么代价。
两个线程未经同步执行普通 counter++,在 C 中会构成数据竞争,问题不止“结果少一次”。第 9 章已经给出了应用层答案:共享可变状态需要同步。本章再向下走一层,看看锁如何借助原子操作、缓存一致性和内核等待队列,把“同时进入”变成“依次通过”。
这里讨论的是实现原理,不是鼓励在业务代码里自造锁。生产程序应优先使用 pthread_mutex_t、Java Lock 或语言运行时提供的同步原语。成熟实现还要处理取消、进程共享、健壮性、优先级继承等边界。
1. 普通读写为什么不够
下面这段代码看似先检查、再占用,实际有两个问题:
int locked = 0;
void lock(void) {
while (locked) {
}
locked = 1;
}一方面,“读取 0”与“写入 1”是两步,两个线程可能同时通过检查。另一方面,C 语言里线程无同步地读写同一对象会构成数据竞争,行为未定义;volatile 只能约束部分编译器优化,不能把两步合成原子操作,也不能建立线程间的内存顺序。
锁至少要完成两件事:
- 对锁状态做不可分割的修改,决定唯一赢家;
- 让赢家在临界区内的读写,与下一个获得锁的线程建立正确的先后关系。
下面的实现用原子读改写选出赢家,再用 acquire/release 建立同步。这是本例采用的协议,不是说每种互斥算法都必须含有 CAS。
2. 一个可运行的 C11 自旋锁
下面的 GCC/Clang + POSIX 示例采用“先读,看到空闲再 CAS”的方式,保存为 spinlock.c。它适合展示原理,但没有公平性、阻塞等待和优先级处理,不能直接替代 pthread_mutex_t。
#include <pthread.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdio.h>
#include <stdlib.h>
typedef struct {
_Atomic bool locked;
} spinlock_t;
#define SPINLOCK_INIT { false }
static inline void cpu_relax(void) {
#if defined(__x86_64__) || defined(__i386__)
__builtin_ia32_pause();
#elif defined(__aarch64__) || (defined(__arm__) && __ARM_ARCH >= 7)
__asm__ volatile("yield");
#else
atomic_signal_fence(memory_order_seq_cst);
#endif
}
static void spin_lock(spinlock_t *lock) {
for (;;) {
while (atomic_load_explicit(&lock->locked,
memory_order_relaxed)) {
cpu_relax();
}
bool expected = false;
if (atomic_compare_exchange_weak_explicit(
&lock->locked, &expected, true,
memory_order_acquire, memory_order_relaxed)) {
return;
}
}
}
static void spin_unlock(spinlock_t *lock) {
atomic_store_explicit(&lock->locked, false,
memory_order_release);
}
enum { THREADS = 4, INCREMENTS = 50000 };
static spinlock_t lock = SPINLOCK_INIT;
static long counter = 0;
static void *worker(void *unused) {
(void)unused;
for (int i = 0; i < INCREMENTS; ++i) {
spin_lock(&lock);
++counter;
spin_unlock(&lock);
}
return NULL;
}
int main(void) {
if (!atomic_is_lock_free(&lock.locked)) {
fputs("this demo requires lock-free atomic bool\n", stderr);
return EXIT_FAILURE;
}
pthread_t threads[THREADS];
for (int i = 0; i < THREADS; ++i) {
if (pthread_create(&threads[i], NULL, worker, NULL) != 0) {
return EXIT_FAILURE;
}
}
for (int i = 0; i < THREADS; ++i) {
if (pthread_join(threads[i], NULL) != 0) {
return EXIT_FAILURE;
}
}
printf("%ld\n", counter);
return counter == (long)THREADS * INCREMENTS
? EXIT_SUCCESS : EXIT_FAILURE;
}编译运行:
cc -std=c17 -O2 -Wall -Wextra -Werror -pthread spinlock.c
./a.out
# 200000成功的 CAS 使用 memory_order_acquire:获得锁后,线程能看到前一位持有者在释放锁之前完成的写入。解锁使用 memory_order_release,与下一次成功的 acquire 配对。失败的探测不需要取得临界区数据,因此可以使用 relaxed。
这段关系比“加一道内存屏障”更准确:
线程 A:修改共享数据
release 解锁
│ synchronizes-with
▼
线程 B:acquire 加锁成功
读取共享数据3. TAS、TTAS 与缓存一致性
最直接的自旋锁会不断执行 test-and-set:每次尝试都是原子读改写。锁被占用时,许多核心仍反复争夺同一条缓存行,产生大量一致性流量。
上面的实现属于 test-and-test-and-set(TTAS)思路:
- 先用普通原子读观察状态;
- 只有看到空闲时,才尝试 CAS 抢锁。
等待期间的 load 可以命中本地缓存,不必每次请求独占缓存行;解锁写入也会使其他核心持有的共享副本失效。因此 TTAS 是减轻热点,不是消除热点。竞争线程很多时,它们还可能在解锁瞬间一起醒来,形成“惊群式”争抢。
_Atomic bool 是否 lock-free 取决于实现,本例先检查;若底层原子操作还要借助锁,就不能把它当成纯自旋锁成本模型。
cpu_relax 也不是同步原语。平台上的 pause/yield 指令只是告诉处理器这是等待循环,可能改善功耗或流水线行为;正确性仍来自原子操作和内存顺序。这里的 ARM yield 是处理器提示,不是向 OS 调度器让出 CPU;fallback 的 atomic_signal_fence 也没有提供硬件等待指令。
4. 自旋什么时候合算
自旋的赌注是:锁很快就会释放,而且持有者正在另一个处理器上继续运行。以下情况会让赌注变差:
- 临界区包含 I/O、缺页或其他可能阻塞的操作;
- 可运行线程数超过处理器数,持锁线程可能已被换出;
- 锁竞争持续升高;
- 虚拟机或容器得到的 CPU 时间不稳定;
- 实时任务被低优先级持锁者挡住。
“临界区只有几行”并不能证明适合自旋;一次缓存未命中或缺页就会改变数量级。是否采用自旋,应通过目标机器上的竞争率、等待时间和 CPU 消耗来判断。
操作系统内核可以在禁止抢占等受控条件下使用自旋锁。普通应用缺少这些保证,互斥量通常更稳妥。很多互斥量也会自适应地短暂自旋,但那是实现策略,不是 API 承诺。
5. mutex 的快路径与慢路径
一个常见的互斥量结构可以概括为两条路径:
原子操作发现未锁定 ──成功──> 进入临界区
│
失败
▼
记录竞争状态 → 加入等待 → 由内核挂起
│
解锁者唤醒一个或若干等待者 <──┘没有竞争时,锁与解锁可能只需用户态原子操作。竞争慢路径可能进入内核等待队列,也可能先短暂自旋。具体状态位、队列结构、自旋次数和唤醒策略由 libc、JVM 与操作系统共同决定,不应由应用依赖。
Linux futex 解决了什么
传统 Linux futex 接口把一个按 4 字节对齐的 32 位用户空间整数与内核等待机制连接起来;不能直接把上面的 _Atomic bool 地址交给它。两个关键操作是:
FUTEX_WAIT:仅当整数仍等于调用者给出的期望值时才睡眠;FUTEX_WAKE:唤醒等待在该地址上的线程。
“睡前再比较”封住了一个重要竞态:等待者决定睡眠后、真正入队前,锁可能已被释放。若整数已经变化,FUTEX_WAIT 不会继续睡下去。
概念上的等待循环是:
while(未能原子获得锁):
把状态更新为“存在竞争者”
futex_wait(状态地址,期望的竞争值)
# 被唤醒、被信号中断或遇到虚假唤醒后,都重新检查条件唤醒不是把锁直接交给某个线程。被唤醒者仍须重新竞争,因此等待必须围绕条件循环。比较与入队必须是 futex 提供的原子协议,普通 load 后再随意 sleep 不等价。自己拼 futex mutex 很容易遗漏丢失唤醒、状态转换和生命周期问题;理解协议即可,工程代码使用成熟实现。
Java 的 AbstractQueuedSynchronizer 也把原子状态与等待队列结合起来,LockSupport.park/unpark 负责阻塞与唤醒。不过它是 JVM 层同步框架,不是 futex 的简单别名;底层是否使用 futex 取决于平台和 JVM 实现。
6. 公平性不是免费的
TTAS 自旋锁允许刚到的线程抢在等待已久的线程前面,可能出现饥饿。若要更稳定的顺序,可以选择其他结构:
| 结构 | 等待方式 | 主要代价 |
|---|---|---|
| ticket lock | 每个线程取号,等待自己的号码 | 所有等待者轮询同一服务号 |
| MCS lock | 每个线程在自己的队列节点上等待 | 需要管理节点,路径更复杂 |
| 排队 mutex | 竞争者进入等待队列 | 可能发生内核切换与唤醒 |
| 不公平 mutex | 解锁后允许重新争抢 | 吞吐可能较高,尾延迟不稳定 |
公平策略能限制插队,却可能降低吞吐。例如锁刚释放时,让当前正在 CPU 上运行的线程获得锁,有时比唤醒已经睡眠的队首更省成本。到底优先平均吞吐、尾延迟还是严格顺序,是同步原语的设计选择。
还有一个更严重的问题是优先级反转:高优先级线程等待低优先级持锁者,而中优先级线程不断占用 CPU。实时系统可能需要支持优先级继承的 mutex;普通自旋锁做不到这一点。
7. 读写锁与无锁读取的边界
读写锁允许多个读者同时进入,但它并不天然更快。读者计数本身是共享写热点;写者还可能长期等不到机会。读取极短、核心很多或写入并不少时,普通 mutex 反而可能更好。
基于版本号的 seqlock 适合“读者可以重试、写入很少”的特定场景。正确读法必须把数据复制到私有快照,并在复制前后验证序列号。验证完成后返回指向原共享对象的可变指针并不安全,因为写者随后仍可修改它。若复制时与非原子 writer 冲突,普通 C 读写已经构成 data race;事后发现版本改变并重试,不能撤销 UB。对象生命周期、语言允许的读取协议与内存回收必须先解决。
因此,“乐观”不是省略同步,而是把同步变成验证、重试和安全回收。
8. 选择与排查
面对共享热点,可以按这个顺序思考:
- 能否消除共享,例如线程私有状态、分片计数或消息传递?
- 临界区能否缩短,并把 I/O 移到锁外?
- 是否真的存在并行读优势,值得使用读写锁?
- 竞争时应该消耗 CPU 自旋,还是让线程睡眠?
- 公平性、取消、超时和优先级是否属于需求?
- 性能问题来自锁本身,还是锁保护的数据造成缓存行争用?
测量时至少观察吞吐、等待时间分布、CPU 使用率和上下文切换。只报告“每次加锁多少纳秒”通常会掩盖真实工作负载中的竞争形态。
9. 小结
本例的 TTAS 协议包含多层约定:
- 原子读改写选出唯一持有者;
- acquire/release 让临界区内的数据按预期可见;
- TTAS 降低持续原子写带来的缓存争夺,但不能消除竞争;
- futex 让无竞争路径留在用户态,让竞争者能够进入内核睡眠;
- 公平性、低延迟、低 CPU 消耗和高吞吐很难同时最大化。
锁对象自身也需要内存。下一节转向动态内存分配器:它如何切分空闲块、控制碎片,并避免所有线程挤在一把堆锁上。更完整的 C 原子语义将在第16章:C 与内存模型继续展开。