本章要解决什么

如何构建一个锁?怎样构建高效的锁?高效的锁以低成本提供互斥,还可能具备其他一些性质。需要什么硬件支持?什么操作系统支持?

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)加锁,允许更多线程同时处在加锁代码中。

怎样评价一个锁

① 正确性 能否提供互斥? 会不会让两个线程 同时进入临界区? ② 公平性 每个竞争者锁空闲时 都有公平的机会吗? 有没有线程会饿死? ③ 性能 无竞争时的开销 单 CPU 上多线程竞争 多 CPU 上多线程竞争
原书 28.4 节:评价锁的三条标准。本章末尾有一张表把所有实现按这三条对比。

最早的方案:关中断

void lock()   { DisableInterrupts(); }
void unlock() { EnableInterrupts(); }

在单处理器上,进入临界区前关中断,临界区就不会被打断,于是像原子的一样执行。优点只有一个:简单。缺点很多:

  1. 要信任应用程序:必须允许任意线程执行特权操作(开关中断)。贪婪的程序一开始就 lock() 独占 CPU;恶意或出错的程序 lock() 后死循环,OS 再也拿不回控制权,只能重启。
  2. 多处理器上无效:别的 CPU 上的线程照样能进入临界区,关本 CPU 的中断毫无用处。
  3. 长时间关中断会丢中断:比如错过磁盘读完成的中断,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),在单处理器上尤其浪费——它等的那个线程在它自旋时根本没法运行。

假装你是一个恶意的调度程序,总在最不合时宜的时刻打断线程,破坏它们构建同步原语的努力。某个中断序列也许不太可能出现,但只要可能,就足以证明某个方案不行。下面的模拟器就让你当这个调度程序。

模拟器 1

错误的锁:只用 flag 的 load/store

你来当(恶意的)调度程序:每点一次按钮,选中的线程执行一行代码。目标:让两个线程同时进入临界区。点"载入原书图 28.2"看书中的轨迹。

用 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)。

评价自旋锁

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;                // 叫下一个号
}
ticket = 3 取号机:下一个号码 turn = 1 叫号屏:现在轮到 T1 · 0 T2 · 1 T3 · 2 已用完 myturn==turn → 进入 自旋等待
ticket 锁 = 银行取号:FAA 取号,unlock 把叫号屏加 1。先取号的一定先入场。

和之前方案的重要区别:它保证所有线程都能前进。一旦线程拿到号,它一定会在未来某个时刻被叫到(前面的人用完临界区之后)。而 TAS 自旋锁没有这种保证:一个线程可能一直自旋,而别的线程反复获取、释放锁。

模拟器 2

硬件原语构建的锁:逐行交错执行

选择锁的实现,扮演调度程序单步执行(每步 = 一行,硬件原子指令整行原子)。观察共享变量(flag / ticket / turn)、每个线程的局部变量(myturn、LL 链接是否有效)、谁在临界区、每个线程自旋了几次,以及获得锁的先后顺序。试试"恶意调度":它总在 T1 刚拿到锁时打断它,比较 TAS 与 ticket 锁的公平性。

自旋太多:怎么办?

硬件锁简单(几行代码)而且正确,但有时非常低效。想象单处理器上两个线程:线程 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;
}

逐点理解:

唤醒/等待竞争(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() 时的同一交错:

模拟器 3a

wakeup/waiting race:一次落空的唤醒

T1 持有锁,T2 来获取。T2 在"释放 guard"与"park()"之间被中断,T1 恰好在此时 unlock。切换有无 setpark(),看 T2 的结局。

下面的模拟器把三种等待策略放在同一个单 CPU 上比较:N 个线程循环执行"获取锁 → 临界区 → 释放 → 做其他工作",时间片轮转调度,初始时 T1 持锁并即将在临界区中被抢占(原书讨论的最坏情形)。

模拟器 3b

自旋 vs 让出(yield)vs 队列 + park/unpark

单 CPU,时间单位离散。红色 = 自旋/失败尝试浪费的 CPU,黄色 = 上下文切换开销。改变线程数 N、时间片、临界区长度,比较三种策略浪费的 CPU 时间和各线程获得锁的次数(公平性)。
临界区(线程颜色)临界区外的工作自旋 / 失败的尝试上下文切换睡眠(park,不占 CPU)

设 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);
}
bit 31 bit 30 … bit 0:等待者(感兴趣的线程)个数 1 = 已加锁 (整数为负) unlock 加 0x80000000:结果为 0 ⇔ 没有等待者 → 不用进内核 一个 int 同时编码"是否持有"和"有多少等待者"
futex 锁的整数:最高位表示锁是否被持有,其余位计数等待者。

这段真实代码有两点值得注意:① 用一个整数同时记录锁是否被持有(最高位)和等待者数量(其余位),所以锁被持有时这个整数是负数;② 为常见情况(无竞争)优化:只有一个线程加锁、解锁时,只做一次原子位测试并设置(加锁)和一次原子加法(解锁),完全不进内核。

两阶段锁(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同 TAScompare-and-swap
LL/SC 自旋锁✔✘同 TAS同 TASload-linked / store-conditional
ticket 锁✔✔ 先来先服务差,且下一个持号者没在运行时会整体停滞好,但每次交接都要把缓存行搬到指定的核fetch-and-add
TAS + yield✔✘ 可能饿死较好:只浪费上下文切换线程很多时切换开销大yield()
队列 + park/unpark✔(需 setpark 防唤醒竞争)✔ 队列 FIFO好:等待者睡眠好(guard 只自旋几条指令)park / unpark / setpark
futex / 两阶段锁(pthread_mutex)✔通常不保证严格公平好好,无竞争时不进内核原子指令 + futex

先预测,再验证

(改编自 Homework 第 2-4 题)模拟器 1 中,从初始状态出发,最少需要几次线程切换才能让两个线程同时进入临界区?中断应该落在哪一行之后?
看答案

最少 2 次切换:T1 执行 while (flag == 1) 读到 0 后被打断(第 1 次切换);T2 执行 while(也读到 0)和 flag = 1;再切回 T1(第 2 次切换),它执行 flag = 1——此刻两个线程都已通过 lock(),都会进入临界区。这正是原书图 28.2 的轨迹(点"载入原书图 28.2")。关键点:中断落在 TEST 之后、SET 之前。

模拟器 2 选 TAS,点"恶意调度",数一数 T1 拿到锁 3 次期间 T2 自旋了几次、拿到过几次锁?再换成 ticket 锁,用同一个恶意调度程序,获得锁的顺序变成什么样?
看答案

TAS:T1 连续拿到 3 次锁,T2 每次被调度时锁都被 T1 持有,自旋 3 次、一次也没拿到,直到 T1 全部做完——这就是不公平(换成无限循环就是饿死)。ticket:T2 在 T1 第一次持锁期间就取了号(1),T1 释放后再取号只能拿到 2,于是获得顺序变成 T1、T2、T1、T2……严格轮流。公平的代价是 T1 也得自旋等待。

模拟器 2 选 LL/SC,点"两个线程同时 LL"。预测哪个线程的 StoreConditional 会失败,为什么?
看答案

T1、T2 都 LL 读到 flag=0。T1 先 SC 成功(flag 在它 LL 之后没被写过),把 flag 写成 1。T2 的 SC 失败:自它 LL 之后,flag 已被 T1 写过,于是返回 0,回到外层循环重试,这次 LL 读到 1 开始自旋。

模拟器 3b:N=4、Q=10、C=4、W=6、切换开销 1、时长 150。先预测三种策略中哪个浪费(红色)最多?把 N 改为 8,自旋策略的浪费大约按什么规律增长?
看答案

用模拟器实测(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 吞吐量最高"是同一个道理:公平与性能常常需要取舍。

(原书思考题)图 28.9 里,如果把 m->guard = 0; 移到 park(); 之后,会发生什么?
看答案

线程带着 guard 睡着了。持锁者调用 unlock() 时要先获取 guard,只能在 guard 上永远自旋,于是永远没人 unpark 那个睡着的线程;其他来 lock() 的线程也都卡在 guard 上。整个锁死锁。

(改编自 Homework 第 9-10 题)模拟器 2 选 Peterson,尝试各种调度(包括恶意调度和随机调度),两个线程能同时进入临界区吗?会不会两个人都永远等下去?
看答案

都不会。若两人都设了 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 的真实输出整理):

线程数tasttascasticketyieldmutex
10.0020.0020.0030.0030.0030.007
20.0300.0320.0330.0610.0060.039
40.1330.0940.1620.3150.0150.076
80.5690.5450.5901.0820.0420.211
162.3992.0153.23420 s 超时(只完成 459 万 / 1600 万)0.1070.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)。

怎么解读:

自测题

1. 为什么"关中断"不能作为通用的锁实现?
原书列举:要信任任意程序(可能独占 CPU 或死循环)、多处理器上无效、长时间关中断会丢中断。它只在 OS 内部有限使用。
2. 图 28.1 的 flag 锁为什么不正确?
两个线程都可能在对方 SET 之前完成 TEST,都读到 0,然后都 SET 为 1 进入临界区。自旋浪费是性能问题,不是正确性问题。
3. TestAndSet(&flag, 1) 返回 0 意味着什么?
TAS 返回旧值并原子地写入新值。旧值为 0 说明锁原本空闲,而现在它已经是 1,调用者就是新的所有者。
4. 下列哪种锁能保证每个等待的线程最终都能获得锁(不会饿死)?
ticket 锁按取号顺序服务:一旦取到号,前面的人用完后一定轮到你。TAS、CAS、yield 都不提供这种保证。
5. 单 CPU 上 N 个线程竞争一个 TAS 自旋锁,持锁线程在临界区中被抢占。最坏情况下浪费多少?
其余 N−1 个线程依次被调度,每个都自旋到时间片结束才放弃 CPU,而它们等的持锁者根本没机会运行。
6. LL/SC 锁中,StoreConditional 什么时候会失败?
SC 只有在 LL 之后该地址没被更新过时才成功。两个线程都 LL 到 0 时,先 SC 的成功,后 SC 的失败并重试。
7. 图 28.9 中,unlock 在有等待者时为什么不把 flag 置 0?
原书:"we just pass the lock directly from the thread releasing the lock to the next thread acquiring it; flag is not set to 0 in-between."
8. setpark() 解决的是什么问题?
调用 setpark() 后,如果在真正 park 之前被 unpark,随后的 park 会立即返回,不会错过这次唤醒。
9. 关于 Linux futex 锁(图 28.10),哪项正确?
一个整数:最高位表示是否持有、其余位计数等待者。快路径只需一次原子位测试并设置;这也是两阶段锁的一种形式(只自旋一次)。

常见误区

本章小结