本章要解决什么
如何构建一个锁?怎样构建高效的锁?高效的锁以低成本提供互斥,还可能具备其他一些性质。需要什么硬件支持?什么操作系统支持?
THE CRUX: How to build a lock. How can we build an efficient lock? Efficient locks provide mutual exclusion at low cost, and also might attain a few other properties we discuss below. What hardware support is needed? What OS support?
第 26 章看到:我们想让一段指令原子地执行,但中断(或多个 CPU 上同时运行的线程)让这做不到。本章正面解决这个问题:程序员在临界区前后加上锁(lock),让临界区看起来就像一条原子指令。第 27 章只讲了 pthread_mutex_lock 怎么用;这一章讲它怎么造:从关中断、只用 load/store 的失败尝试,到 test-and-set、compare-and-swap、LL/SC、fetch-and-add 这些硬件原语,再到借助操作系统的 yield、park/unpark 和 Linux futex,避免白白自旋。
本章还有第二个关键问题(28.12 节):如何避免自旋?怎样开发一种不会在 CPU 上无谓浪费时间自旋的锁?(CRUX: How to avoid spinning. How can we develop a lock that doesn't needlessly waste time spinning on the CPU?)
核心概念速览
锁(lock / mutex)
一个变量,状态是可用(available/unlocked/free)或被持有(acquired/locked/held)。持有者称为锁的所有者(owner)。
三条评价标准
正确性(能否互斥)、公平性(会不会饿死)、性能(无竞争、单 CPU 竞争、多 CPU 竞争时的开销)。
自旋锁(spin lock)
抢不到锁就在循环里反复检查(spin-wait),一直占着 CPU。简单,但可能浪费整个时间片。
硬件原子指令
test-and-set(xchg)、compare-and-swap(cmpxchg)、LL/SC、fetch-and-add:把"检查 + 修改"合成一个不可分割的操作。
ticket 锁
用 fetch-and-add 取号、按号入场。保证每个线程最终都能拿到锁(公平),不会饿死。
yield / park
抢不到锁时,与其自旋,不如让出 CPU(yield),或者排进队列睡眠(park),等持有者释放时唤醒(unpark)。
futex 与两阶段锁
Linux 的做法:先在用户态用原子指令试一下/自旋一会儿,拿不到再进内核睡眠。无竞争时几乎零开销。
优先级反转
高优先级线程自旋等待低优先级线程持有的锁,而后者得不到 CPU——系统卡死。避免自旋锁的另一个理由。
锁的基本思想
典型的临界区是更新共享变量:
lock_t mutex; // some globally-allocated lock 'mutex'
...
lock(&mutex);
balance = balance + 1; // 临界区
unlock(&mutex);
语义很简单:调用 lock() 尝试获取锁;如果没有别的线程持有,就获得锁、进入临界区。如果别的线程已持有,lock() 不会返回,直到持有者调用 unlock()。释放时如果有线程在 lock() 里等待,其中一个会(最终)注意到或被告知锁的状态变化,获得锁并进入临界区。
锁把一点调度控制权还给了程序员:线程由程序员创建、由 OS 按它的方式调度;而用锁包住一段代码,程序员就能保证这段代码里至多只有一个线程活动。
POSIX 把锁叫 mutex(mutual exclusion,互斥)。注意 pthread_mutex_lock(&lock) 传入一个锁变量:不同的数据可以用不同的锁保护。一把大锁保护所有临界区叫粗粒度(coarse-grained)加锁;不同数据结构用不同的锁叫细粒度(fine-grained)加锁,允许更多线程同时处在加锁代码中。
怎样评价一个锁
最早的方案:关中断
void lock() { DisableInterrupts(); }
void unlock() { EnableInterrupts(); }
在单处理器上,进入临界区前关中断,临界区就不会被打断,于是像原子的一样执行。优点只有一个:简单。缺点很多:
- 要信任应用程序:必须允许任意线程执行特权操作(开关中断)。贪婪的程序一开始就
lock()独占 CPU;恶意或出错的程序lock()后死循环,OS 再也拿不回控制权,只能重启。 - 多处理器上无效:别的 CPU 上的线程照样能进入临界区,关本 CPU 的中断毫无用处。
- 长时间关中断会丢中断:比如错过磁盘读完成的中断,OS 就不知道该唤醒等待的进程。
所以关中断只在有限场合使用:操作系统自己访问内部数据结构时,有时用屏蔽中断保证原子性——OS 总是信任自己,不存在信任问题。
失败的尝试:只用 load/store
试着只用一个普通变量 flag 表示锁是否被持有(图 28.1):
typedef struct __lock_t { int flag; } lock_t;
void init(lock_t *mutex) {
// 0 -> lock is available, 1 -> held
mutex->flag = 0;
}
void lock(lock_t *mutex) {
while (mutex->flag == 1) // TEST the flag
; // spin-wait (do nothing)
mutex->flag = 1; // now SET it!
}
void unlock(lock_t *mutex) {
mutex->flag = 0;
}
有两个问题。正确性:检查(TEST)和设置(SET)是两步。如果 T1 读到 flag=0、正要设置时被中断,T2 也读到 0 并设置为 1 进入临界区;T1 恢复后也把 flag 设为 1——两个线程都进了临界区(图 28.2)。性能:等待者不停地检查 flag,称为自旋等待(spin-waiting),在单处理器上尤其浪费——它等的那个线程在它自旋时根本没法运行。
假装你是一个恶意的调度程序,总在最不合时宜的时刻打断线程,破坏它们构建同步原语的努力。某个中断序列也许不太可能出现,但只要可能,就足以证明某个方案不行。下面的模拟器就让你当这个调度程序。
错误的锁:只用 flag 的 load/store
用 test-and-set 构建能用的自旋锁
既然关中断在多处理器上无效、load/store 又不行,系统设计者开始发明加锁的硬件支持。最早的多处理器(如 1960 年代初的 Burroughs B5000)就有,今天所有系统都有,甚至单 CPU 系统也有。最简单的是 test-and-set(也叫原子交换 atomic exchange),用 C 描述它做的事:
int TestAndSet(int *old_ptr, int new) {
int old = *old_ptr; // fetch old value at old_ptr
*old_ptr = new; // store 'new' into old_ptr
return old; // return the old value
} // 关键:这三步由硬件原子地完成
它返回旧值,同时把内存设为新值。在 SPARC 上叫 ldstub,在 x86 上就是带锁的 xchg。用它构建自旋锁(图 28.3):
void lock(lock_t *lock) {
while (TestAndSet(&lock->flag, 1) == 1)
; // spin-wait (do nothing)
}
void unlock(lock_t *lock) {
lock->flag = 0;
}
为什么正确?两种情况:① 锁空闲(flag=0):TestAndSet 返回 0,线程跳出循环获得锁,同时flag 被原子地设为 1。② 锁被持有(flag=1):TestAndSet 返回 1(又把 1 写了一遍,无害),线程继续自旋,直到持有者把 flag 清 0,下一次 TestAndSet 返回 0 并原子地设成 1。检查旧值和设置新值合成了一个原子操作,所以只有一个线程能拿到锁。
这种锁叫自旋锁(spin lock)。在单处理器上它需要抢占式调度(定时器中断会打断线程);否则自旋的线程永远不会让出 CPU,持锁者永远没机会释放。
1960 年代 Dijkstra 向朋友们提出并发问题,数学家 Dekker 给出了一个只用 load 和 store 的解法(假设它们彼此原子,早期硬件确实如此)。Peterson 后来改进了它(两个线程):
int flag[2];
int turn;
void init() {
flag[0] = flag[1] = 0; // 用 flag 表示"我想持有锁"
turn = 0; // 轮到谁?(线程 0 或 1)
}
void lock() {
flag[self] = 1; // self 是调用者的线程 ID
turn = 1 - self; // 谦让:让对方先
while ((flag[1-self] == 1) && (turn == 1 - self))
; // 对方也想要,且轮到对方 → 自旋
}
void unlock() {
flag[self] = 0; // 撤销意愿
}
flag 表达"我想进",turn 决定"两人都想进时谁让"。两个线程都设了 flag 时,最后写 turn 的那个把机会让给了对方,所以对方能进、自己等。模拟器 2 可以选 Peterson 验证它。不过原书指出:这条研究路线后来意义不大——假设一点硬件支持要简单得多;而且这类算法在现代硬件上不能工作(宽松的内存一致性模型会重排 load/store)。
评价自旋锁
- 正确性:✔ 一次只允许一个线程进入临界区。
- 公平性:✘ 不保证。自旋的线程在竞争下可能永远拿不到锁——饿死(starvation)。
- 性能(单 CPU):很痛苦。持锁线程在临界区里被抢占后,调度程序可能依次运行其他 N−1 个线程,每个都尝试获取锁、自旋整整一个时间片才放弃 CPU。
- 性能(多 CPU):相当不错(线程数大致等于 CPU 数时)。A 在 CPU1 持锁,B 在 CPU2 自旋;临界区通常很短,锁很快就可用,B 没浪费多少周期。
compare-and-swap
另一个硬件原语:SPARC 上叫 compare-and-swap,x86 上叫 compare-and-exchange(cmpxchg)(图 28.4):
int CompareAndSwap(int *ptr, int expected, int new) {
int original = *ptr;
if (original == expected)
*ptr = new;
return original;
}
检查 *ptr 是否等于 expected,是就更新为 new,否则什么也不做;无论如何都返回原值,调用者据此知道是否成功。把 TAS 锁的 lock() 换成下面这样即可:
void lock(lock_t *lock) {
while (CompareAndSwap(&lock->flag, 0, 1) == 1)
; // spin
}
CAS 比 TAS 更强大,后面讲无锁同步(lock-free synchronization)时会用到这种能力;但如果只是用它构建简单自旋锁,行为和 TAS 自旋锁完全相同。
load-linked / store-conditional
MIPS、Alpha、PowerPC、ARM 提供一对配合使用的指令(图 28.5):
int LoadLinked(int *ptr) {
return *ptr;
}
int StoreConditional(int *ptr, int value) {
if (no update to *ptr since LL to this addr) {
*ptr = value;
return 1; // success!
} else {
return 0; // failed to update
}
}
load-linked 就像普通的 load;关键在 store-conditional:只有自上次 LL 以来没有别的 store 写过这个地址,它才成功(写入并返回 1),否则失败(不写,返回 0)。用它构建锁(图 28.6):
void lock(lock_t *lock) {
while (1) {
while (LoadLinked(&lock->flag) == 1)
; // spin until it's zero
if (StoreConditional(&lock->flag, 1) == 1)
return; // if set-to-1 was success: done
// otherwise: try again
}
}
void unlock(lock_t *lock) {
lock->flag = 0;
}
失败是怎么发生的:T1 执行 LL 读到 0,正要 SC 时被中断;T2 也执行 LL 读到 0。现在两个线程都要 SC——只有一个能成功把 flag 改成 1;第二个 SC 会失败(因为另一个线程在它 LL 和 SC 之间写了 flag),只好重试。学生 David Capel 给出的更短写法(利用短路求值):
void lock(lock_t *lock) {
while (LoadLinked(&lock->flag) || !StoreConditional(&lock->flag, 1))
; // spin
}
原书提示:代码越少越好(Lauer 定律)。"If the same people had twice as much time, they could produce as good of a system in half the code." 简洁的代码更容易理解、bug 更少。
fetch-and-add 与 ticket 锁
fetch-and-add 原子地把一个值加 1 并返回旧值:
int FetchAndAdd(int *ptr) {
int old = *ptr;
*ptr = old + 1;
return old;
}
Mellor-Crummey 和 Scott 用它构建了 ticket 锁(图 28.7):
typedef struct __lock_t {
int ticket;
int turn;
} lock_t;
void lock_init(lock_t *lock) {
lock->ticket = 0;
lock->turn = 0;
}
void lock(lock_t *lock) {
int myturn = FetchAndAdd(&lock->ticket); // 取号
while (lock->turn != myturn)
; // spin // 等叫号
}
void unlock(lock_t *lock) {
lock->turn = lock->turn + 1; // 叫下一个号
}
和之前方案的重要区别:它保证所有线程都能前进。一旦线程拿到号,它一定会在未来某个时刻被叫到(前面的人用完临界区之后)。而 TAS 自旋锁没有这种保证:一个线程可能一直自旋,而别的线程反复获取、释放锁。
硬件原语构建的锁:逐行交错执行
自旋太多:怎么办?
硬件锁简单(几行代码)而且正确,但有时非常低效。想象单处理器上两个线程:线程 0 持锁时被中断,线程 1 尝试获取锁,发现被持有,于是开始自旋——自旋——继续自旋……直到定时器中断,线程 0 再次运行并释放锁。任何时候线程在这种情况下自旋,都浪费了整个时间片,去检查一个不会改变的值。N 个线程竞争时,可能浪费 N−1 个时间片。
这就是本章第二个关键问题:如何避免自旋?单靠硬件解决不了,还需要操作系统的支持。
简单方法:让出 CPU(yield)
void init() {
flag = 0;
}
void lock() {
while (TestAndSet(&flag, 1) == 1)
yield(); // give up the CPU
}
void unlock() {
flag = 0;
}
假设 OS 提供 yield() 系统调用:把调用者从运行态变成就绪态,让另一个线程运行——相当于线程主动把自己"取消调度"。两个线程一个 CPU 时效果很好:发现锁被持有就让出 CPU,持锁者得以运行并完成临界区。
但如果有 100 个线程反复竞争:持锁者被抢占后,其余 99 个都会执行"运行一下、发现锁被持有、yield"。比自旋(浪费 99 个时间片)好,但上下文切换的开销仍然可观。更糟的是,它没有解决饿死:一个线程可能陷入无休止的 yield 循环,而别的线程反复进出临界区。
使用队列:休眠代替自旋
前面方法的真正问题是把太多事留给运气:调度程序决定下一个运行谁,选错了,运行的线程要么自旋、要么立刻让出。我们需要明确控制锁释放后由谁获得。为此需要一点 OS 支持和一个等待队列。原书采用 Solaris 的两个调用:park() 让调用线程睡眠,unpark(threadID) 唤醒指定线程(图 28.9):
typedef struct __lock_t {
int flag;
int guard;
queue_t *q;
} lock_t;
void lock_init(lock_t *m) {
m->flag = 0;
m->guard = 0;
queue_init(m->q);
}
void lock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1)
; // acquire guard lock by spinning
if (m->flag == 0) {
m->flag = 1; // lock is acquired
m->guard = 0;
} else {
queue_add(m->q, gettid());
m->guard = 0;
park(); // 睡眠,直到被 unpark
}
}
void unlock(lock_t *m) {
while (TestAndSet(&m->guard, 1) == 1)
; // acquire guard lock by spinning
if (queue_empty(m->q))
m->flag = 0; // let go of lock; no one wants it
else
unpark(queue_remove(m->q)); // hold lock (for next thread!)
m->guard = 0;
}
逐点理解:
- guard 是一个"保护 flag 和队列"的自旋锁。所以这个方案并没有完全消除自旋:线程可能在获取/释放锁的过程中被中断,导致别人自旋等 guard。但自旋时间很有限——只是 lock/unlock 里的几条指令,而不是用户定义的临界区。
- 拿不到锁时,线程把自己加入队列、释放 guard、然后
park()。思考题:如果 guard 的释放放在park()之后会怎样?提示:很糟——线程带着 guard 睡着了,其他线程(包括要来 unlock 唤醒它的持锁者)都会在 guard 上永远自旋。 - 唤醒别的线程时 flag 不置 0。这不是错误而是必须:被唤醒的线程从
park()返回时并不持有 guard,没法再去把 flag 设成 1。所以锁被直接从释放者传递给下一个获取者,flag 中间不变成 0。 - 用队列控制下一个获得锁的是谁,因此避免了饿死。
唤醒/等待竞争(wakeup/waiting race)与 setpark()
注意 park() 调用之前那一小段:线程已经决定要睡觉(因为锁被持有),但还没真正睡着。如果此时切换到持锁线程并且它释放了锁,它会 unpark 这个"还醒着"的线程——这次唤醒就落空了;随后该线程执行 park(),可能永远睡下去。Solaris 用第三个系统调用 setpark() 解决:线程先声明"我马上要 park 了";若在真正 park 之前有人 unpark 了它,随后的 park 会立即返回而不是睡眠:
queue_add(m->q, gettid());
setpark(); // new code
m->guard = 0;
park();
另一种办法是把 guard 传进内核,由内核原子地释放锁并把当前线程移出运行队列。下面这个小模拟器对比有无 setpark() 时的同一交错:
wakeup/waiting race:一次落空的唤醒
下面的模拟器把三种等待策略放在同一个单 CPU 上比较:N 个线程循环执行"获取锁 → 临界区 → 释放 → 做其他工作",时间片轮转调度,初始时 T1 持锁并即将在临界区中被抢占(原书讨论的最坏情形)。
自旋 vs 让出(yield)vs 队列 + park/unpark
设 T2 优先级高、T1 优先级低,调度程序只要 T2 可运行就运行 T2。T2 因 I/O 阻塞时 T1 运行,拿到一个自旋锁进入临界区。此时 T2 的 I/O 完成,立刻抢占 T1;T2 尝试获取锁,拿不到,就一直自旋——而 T1 永远得不到 CPU 去释放锁,系统挂起。(火星探路者号 Mars Pathfinder 就遇到过优先级反转;macOS 的 OSSpinLock 在不同优先级线程间也不安全。)
不用自旋锁也躲不开:三个线程 T3 > T2 > T1,T1 持锁,T3 抢占 T1 后等这把锁,此时 T2 开始运行——它优先级高于 T1,于是 T1 一直没机会运行,最高优先级的 T3 被中等优先级的 T2 间接卡住。解决办法:避免自旋锁;优先级继承(priority inheritance)——高优先级线程等待低优先级线程时,临时提升后者的优先级;或者最简单地,让所有线程优先级相同。
不同的 OS,不同的支持:Linux futex
Linux 提供 futex,与 Solaris 的接口类似但把更多功能放进内核:每个 futex 关联一个物理内存地址和一个内核中的等待队列。futex_wait(address, expected):如果 *address == expected 就让调用者睡眠,否则立即返回(这个"检查-睡眠"在内核中原子完成,天然避免了唤醒/等待竞争)。futex_wake(address) 唤醒队列中的一个线程。nptl 库(glibc)中 lowlevellock.h 的代码片段(图 28.10):
void mutex_lock (int *mutex) {
int v;
// Bit 31 was clear, we got the mutex (fastpath)
if (atomic_bit_test_set (mutex, 31) == 0)
return;
atomic_increment (mutex);
while (1) {
if (atomic_bit_test_set (mutex, 31) == 0) {
atomic_decrement (mutex);
return;
}
// Have to wait. First make sure futex value
// we are monitoring is negative (locked).
v = *mutex;
if (v >= 0)
continue;
futex_wait (mutex, v);
}
}
void mutex_unlock (int *mutex) {
// Adding 0x80000000 to counter results in 0 if and
// only if there are not other interested threads
if (atomic_add_zero (mutex, 0x80000000))
return;
// There are other threads waiting for this mutex,
// wake one of them up.
futex_wake (mutex);
}
这段真实代码有两点值得注意:① 用一个整数同时记录锁是否被持有(最高位)和等待者数量(其余位),所以锁被持有时这个整数是负数;② 为常见情况(无竞争)优化:只有一个线程加锁、解锁时,只做一次原子位测试并设置(加锁)和一次原子加法(解锁),完全不进内核。
两阶段锁(two-phase lock)
Linux 的做法有一种古老方法的味道,至少可追溯到 1960 年代初的 Dahm 锁,现在叫两阶段锁:它意识到自旋可能是有用的,尤其是锁即将被释放时。第一阶段先自旋一会儿,希望能拿到锁;如果没拿到,进入第二阶段:调用者睡眠,直到锁空闲时被唤醒。上面的 Linux 锁只自旋一次;推广一下,可以在循环里自旋固定时间再用 futex 睡眠。
两阶段锁是又一个混合方案:把两个好主意结合起来可能得到更好的主意。但效果强烈依赖于硬件环境、线程数和工作负载。做出一个对所有情况都好的通用锁,是个真正的挑战。
把所有实现放在一起比较
| 实现 | 正确性(互斥) | 公平性 | 性能:单 CPU 竞争 | 性能:多 CPU 竞争 | 需要的支持 |
|---|---|---|---|---|---|
| 关中断 | 仅单处理器 | — | 快,但要信任应用、可能丢中断 | 无效 | 特权指令 |
| 只用 load/store 的 flag | ✘ 可同时进入 | ✘ | 自旋浪费 | 自旋浪费 | 无 |
| Peterson / Dekker | ✔ 仅限顺序一致的内存 | ✔(两线程轮流) | 自旋浪费 | 现代 CPU 宽松内存模型下失效 | 只要 load/store |
| TAS 自旋锁 | ✔ | ✘ 可能饿死 | 差:最多浪费 N−1 个时间片 | 好(线程≈CPU、临界区短) | test-and-set / xchg |
| CAS 自旋锁 | ✔ | ✘ | 同 TAS | 同 TAS | compare-and-swap |
| LL/SC 自旋锁 | ✔ | ✘ | 同 TAS | 同 TAS | load-linked / store-conditional |
| ticket 锁 | ✔ | ✔ 先来先服务 | 差,且下一个持号者没在运行时会整体停滞 | 好,但每次交接都要把缓存行搬到指定的核 | fetch-and-add |
| TAS + yield | ✔ | ✘ 可能饿死 | 较好:只浪费上下文切换 | 线程很多时切换开销大 | yield() |
| 队列 + park/unpark | ✔(需 setpark 防唤醒竞争) | ✔ 队列 FIFO | 好:等待者睡眠 | 好(guard 只自旋几条指令) | park / unpark / setpark |
| futex / 两阶段锁(pthread_mutex) | ✔ | 通常不保证严格公平 | 好 | 好,无竞争时不进内核 | 原子指令 + futex |
先预测,再验证
看答案
最少 2 次切换:T1 执行 while (flag == 1) 读到 0 后被打断(第 1 次切换);T2 执行 while(也读到 0)和 flag = 1;再切回 T1(第 2 次切换),它执行 flag = 1——此刻两个线程都已通过 lock(),都会进入临界区。这正是原书图 28.2 的轨迹(点"载入原书图 28.2")。关键点:中断落在 TEST 之后、SET 之前。
看答案
TAS:T1 连续拿到 3 次锁,T2 每次被调度时锁都被 T1 持有,自旋 3 次、一次也没拿到,直到 T1 全部做完——这就是不公平(换成无限循环就是饿死)。ticket:T2 在 T1 第一次持锁期间就取了号(1),T1 释放后再取号只能拿到 2,于是获得顺序变成 T1、T2、T1、T2……严格轮流。公平的代价是 T1 也得自旋等待。
看答案
T1、T2 都 LL 读到 flag=0。T1 先 SC 成功(flag 在它 LL 之后没被写过),把 flag 写成 1。T2 的 SC 失败:自它 LL 之后,flag 已被 T1 写过,于是返回 0,回到外层循环重试,这次 LL 读到 1 开始自旋。
看答案
用模拟器实测(150 个时间单位):N=4 时,自旋策略浪费 30 个自旋单位 + 14 个切换单位(29%);yield 只浪费 3 + 16(13%);park 浪费 12 + 23(23%),但四个线程获得锁的次数恰好都是 3 次。N=8 时自旋浪费飙升到 70 + 14(56%):持锁者被抢占时,其余 7 个线程每人自旋一整个时间片(7 × 10 = 70),正是原书说的"N−1 个时间片"。
yield 把"浪费整个时间片"变成"浪费一次尝试和一次切换",而且释放者常常自己又立刻抢到锁,所以浪费最少——但谁拿到锁全凭运气(N=8 时获得次数 2,2,2,2,1,1,1,1)。park 让等待者根本不被调度,并按队列顺序把锁直接交接,获得锁的次数最均匀;代价是每次交接都要切换到被唤醒的线程,释放者下次来拿锁时往往也得排队睡眠。这和实验里"不公平的 yield/mutex 吞吐量最高"是同一个道理:公平与性能常常需要取舍。
m->guard = 0; 移到 park(); 之后,会发生什么?看答案
线程带着 guard 睡着了。持锁者调用 unlock() 时要先获取 guard,只能在 guard 上永远自旋,于是永远没人 unpark 那个睡着的线程;其他来 lock() 的线程也都卡在 guard 上。整个锁死锁。
看答案
都不会。若两人都设了 flag,turn 只能等于其中一个值:最后写 turn 的线程让出,另一个能进入——所以既互斥又不会死锁。但请记住:这依赖于 load/store 按程序顺序生效;在现代 CPU 上,编译器和硬件的重排会破坏它,需要内存屏障(memory fence)。
动手实验:用 GCC 原子内建造锁
labs/28-locks/locks.c 用 __sync_lock_test_and_set(x86 上是 xchg)、__sync_val_compare_and_swap(lock cmpxchg)、__atomic_fetch_add(lock xadd)实现 TAS、TTAS、CAS、ticket、TAS+yield 锁,与 pthread_mutex 和图 28.1 的错误 flag 锁对比:N 个线程各做 loops 次 lock; counter++; unlock,检查结果并计时(所有线程在 barrier 上集合后同时开跑)。
cd labs/28-locks && make
make correct # 正确性
make bench # 1/2/4/8/16 线程
make oversub # 线程数 = 2 × CPU 数
make single # 所有线程钉在 CPU 0 上
正确性(4 线程 × 100 万次):
none threads=4 counter=1070265 expected=4000000 WRONG time=0.006s
flag threads=4 counter=1057400 expected=4000000 WRONG time=0.011s
tas threads=4 counter=4000000 expected=4000000 OK time=0.140s
cas threads=4 counter=4000000 expected=4000000 OK time=0.148s
ticket threads=4 counter=4000000 expected=4000000 OK time=0.308s
mutex threads=4 counter=4000000 expected=4000000 OK time=0.075s
性能(每线程 100 万次,单位:秒;make bench 的真实输出整理):
| 线程数 | tas | ttas | cas | ticket | yield | mutex |
|---|---|---|---|---|---|---|
| 1 | 0.002 | 0.002 | 0.003 | 0.003 | 0.003 | 0.007 |
| 2 | 0.030 | 0.032 | 0.033 | 0.061 | 0.006 | 0.039 |
| 4 | 0.133 | 0.094 | 0.162 | 0.315 | 0.015 | 0.076 |
| 8 | 0.569 | 0.545 | 0.590 | 1.082 | 0.042 | 0.211 |
| 16 | 2.399 | 2.015 | 3.234 | 20 s 超时(只完成 459 万 / 1600 万) | 0.107 | 0.381 |
$ make oversub # 32 线程 × 20 万次
tas threads=32 counter=6400000 expected=6400000 OK time=1.517s
ttas threads=32 counter=6400000 expected=6400000 OK time=1.341s
ticket threads=32 TIMEOUT after 10.0s: only 18964 of 6400000 increments done (1896 per second)
yield threads=32 counter=6400000 expected=6400000 OK time=0.030s
mutex threads=32 counter=6400000 expected=6400000 OK time=0.143s
$ make single # taskset -c 0,4 线程 × 500 万次
tas threads=4 counter=20000000 expected=20000000 OK time=0.136s
ticket threads=4 TIMEOUT after 5.0s: only 534588 of 20000000 increments done (106918 per second)
yield threads=4 counter=20000000 expected=20000000 OK time=0.093s
mutex threads=4 counter=20000000 expected=20000000 OK time=0.150s
示例输出,你的机器上数字会不同(本机 AMD Ryzen 7 8745H,8 核 16 线程,WSL2,gcc 14.2 -O2)。
怎么解读:
- 正确性:图 28.1 的 flag 锁和不加锁一样错(丢了约 3/4);所有基于原子指令的锁都正确。
- 自旋锁随线程数急剧变慢:锁变量所在的缓存行在各核之间来回搬;每次 TAS/CAS 都是一次独占写。TTAS 先只读自旋(命中本地缓存),略好一些。
- ticket 锁最公平,也最脆弱:它严格按号交接,每次都要把锁交给指定的下一个线程。一旦那个线程没在运行(16 线程占满 16 个逻辑 CPU 再加上系统其他负载、32 线程超额订阅、单 CPU),所有人都得等它被调度回来——每次交接都付出一次调度延迟,吞吐量暴跌到每秒几千次。这正是原书"单 CPU 上自旋锁很痛苦"的极端版本:公平性与性能之间的冲突。
- yield 和 mutex 反而最快:它们不公平——抢不到的线程让出 CPU / 睡眠,持锁线程释放后往往自己立刻又拿到锁(缓存还是热的),一口气完成很多次。吞吐高,公平性差。
- 无竞争时(1 线程)mutex 比 TAS 稍慢:glibc 快路径多做了一些检查,但仍只是一次原子操作、不进内核——正是 futex "为常见情况优化"的效果。
自测题
常见误区
- "用一个普通变量当锁就行":不行。检查与设置必须合成一个原子操作,这需要硬件原语(或 Peterson 这类在现代硬件上并不可靠的算法)。
- "自旋锁总是坏的":多 CPU、线程数不超过 CPU 数、临界区很短时,自旋锁很好,甚至比睡眠更快(省掉了上下文切换)。两阶段锁正是基于这一点。
- "公平的锁一定更快":相反。实验中最公平的 ticket 锁在线程数 ≥ CPU 数时性能崩溃;不公平的 mutex、yield 吞吐量最高。公平和性能常常矛盾。
- "yield 解决了饿死":没有。yield 只减少了浪费,线程仍可能无限地 yield 而别人反复拿锁。需要队列才能控制下一个获得锁的是谁。
- "有了 park/unpark 就完全不自旋了":guard 仍是自旋锁,只是自旋时间限于 lock/unlock 内的几条指令。
- "CAS 锁比 TAS 锁更好":只用来构建简单自旋锁时两者行为相同;CAS 的强大之处在无锁数据结构。
本章小结
- 锁让临界区像单条原子指令一样执行。评价锁看三点:正确性、公平性、性能(无竞争 / 单 CPU / 多 CPU)。
- 关中断只适合单处理器上的 OS 内部;只用 load/store 的 flag 不正确;Peterson/Dekker 只用 load/store 能工作,但在现代宽松内存模型下失效。
- 硬件原语——test-and-set、compare-and-swap、LL/SC、fetch-and-add——把"检查 + 修改"合成一个原子操作,可以构建正确的自旋锁;ticket 锁进一步保证了公平。
- 自旋在单 CPU 或线程多于 CPU 时浪费严重。OS 支持能改善:yield 让出 CPU;队列 + park/unpark(加 setpark 防唤醒竞争)让等待者睡眠并按序交接;Linux futex 加上两阶段锁是今天真实的做法:硬件原子指令 + OS 睡眠/唤醒原语。
- 下一章(第 29 章)把锁用到计数器、链表、队列、哈希表中,看看锁放在哪里、放几把如何影响正确性与性能;第 30 章的条件变量处理"等待某个条件成立"。