同步问题的本质不是"加不加锁",而是哪些操作不可分割、等待时该自旋还是睡眠、以及死锁为什么防不住。

学习目标

读完本章,你应当能够:

  1. 用"结果依赖不可控的时序"定义竞态条件(race condition),并验证临界区三条件中哪一条被违反。
  2. 推导 CAS 的语义与 ABA 的成因,说明 LL/SC 为何天然抗 ABA,以及 lock 前缀在 x86 上锁的是什么。
  3. 用"临界区长度 vs 一次挂起唤醒开销"给出自旋锁与互斥锁的判据,说出约 1 μs 分界的来源与三条限制。
  4. 说明 ticket lock 与 MCS 队列锁各自消除了哪一部分缓存行争抢,解释单核上自旋锁为什么无意义,以及 RCU 为何能免锁。
  5. 写出条件变量的正确模式,说清必须用 while 的理由,并指出"丢失唤醒"真正的成因。
  6. 完整推导死锁四条件及其"必要而非充分"的含义,逐条给出破坏手段,用银行家算法算安全序列,用等待图判环。
  7. 辨析活锁与饥饿、无锁与无等待、优先级反转与优先级继承。

前置知识检查

你需要知道在哪一节补齐
缓存层次与延迟量级、MESI 四态与伪共享成因见第 1 章 1.4、1.5 节
上下文切换的开销构成与量化见第 5 章 5.5 节
单核/多核与调度器的基本行为见第 5 章 5.1 节
JMM、volatile、锁升级(本章只需知道"存在这一层抽象")见第 30 章 30.2 节

6.1 竞态条件与临界区三条件

竞态条件(race condition):程序的输出依赖于两个或多个控制流(线程、中断处理程序、信号处理器)在同一段共享状态上的不可控的相对时序。两个关键词缺一不可:共享状态——若两个线程只操作各自的局部变量,时序怎么变结果都不变,这不是竞态;不可控——时序由调度器、中断到达时刻与缓存一致性协议共同决定。反过来说,只要有共享可变状态 + 并发访问且无同步,就存在竞态,不管这次测试有没有复现;把竞态说成"偶发 bug"是常见错误,其实它是"必然存在的可能性,只是触发概率低"。

1
2
3
4
5
6
7
/* count++ 为什么不是原子的:它在机器层被拆成"读-改-写"三步,中间任何一处都可被抢占 */
void *worker(void *arg) {
    for (long i = 0; i < 1000000; i++) {
        count++;   /* 等价于 tmp = count; tmp = tmp + 1; count = tmp; 三步 */
    }
    return NULL;
}

两个线程各执行 100 万次,count 的最终值可能落在 100 万到 200 万之间任意一个值上:不是"总是小于 200 万",而是"每次运行都可能不同"——这才是"依赖不可控时序"。对临界区(critical section)问题,一个正确解法必须同时满足下表三条。

条件严格表述违反了会怎样属于哪类属性
互斥(mutual exclusion)任何时刻至多一个线程处于临界区内数据被破坏,正确性问题安全性(safety)
进展(progress)临界区空闲且有线程想进入时,必须在有限时间内选定一个进入者,选择不能无限推迟所有线程都卡在门外,系统停摆活性(liveness)
有限等待(bounded waiting)一个线程发出进入请求后,在它之前允许其他线程进入的次数有上界某线程无限期饥饿活性(liveness)
进展与有限等待极易混为一谈,但量化对象不同:进展关心"系统整体还在动"(每次都有人能进),有限等待关心"某一个具体线程能进"。一个允许"总是让刚到的线程插队"的算法满足进展(系统一直有人进出),却违反有限等待(先来的老线程被无限插队)。后者正是非公平锁的语义。
1
2
3
4
5
6
7
8
     三条件的失效场景对照(两个线程 T1、T2 争一把锁)
  违反互斥        : T1 |----CS----|        两者同时在 CS,数据被破坏
                    T2      |----CS----|
  违反进展        : T1 申请 ─┐
                    T2 申请 ─┴─▶ 选择器不表态,CS 空着谁都不许进
                                  (活锁:大家都在动,没有进展)
  违反有限等待    : T1 申请 ──▶ 一直被后来的 T3、T4、T5… 插队
                                  (系统有进展,T1 被饿死)

非公平互斥锁(如 Java 默认的 ReentrantLock、Linux 的 mutex)主动放弃有限等待换吞吐;ticket lock(6.3.4)则用取号把有限等待变成 FIFO 保证。

6.2 硬件原语:从 CAS 到内存屏障

6.2.1 CAS 的语义与 ABA

比较并交换(compare-and-swap, CAS) 的语义可以完整写成下面几行,三个细节都是面试追问的落点。

1
2
3
4
5
  CAS(addr, expected, new) :
      old = *addr                 // 原子地读
      if (old == expected)        // 原子地比较
          *addr = new             // 原子地写
      return old                  // 无论成功失败都返回"操作前的值"

① 返回旧值而非布尔值(C11 的 atomic_compare_exchange_weak 就是这种形式):失败时直接拿到当前值,可立刻改 expected 再试一次,省掉一次读取。② 弱版本允许虚假失败(spurious failure):即使 *addr == expected 也可能返回失败,接口因此强制写成循环;cmpxchg 风格几乎没有虚假失败,LL/SC 架构上则很常见。③ 成功即写入,失败什么也不做——CAS 不"部分成功"。 ABA 问题:CAS 只比较值,不比较版本;若某地址的值经历 A → B → A,CAS 看到 A 就认为"期间没人动过",而这是假的。

1
2
3
4
5
6
7
8
9
  ABA 在无锁栈上的真实破坏力
  初始栈:  top ─▶ [A] ─▶ [B] ─▶ [C]
  T1: old = top = A ; 读到 next = B ;   ⏸ 被抢占
  T2: pop A ; pop B ; push A           (B 已被回收)
      现在  top ─▶ [A] ─▶ [C]
  T2: 把 B 的那块内存重新分配出去(例如又压入一个 B')
  T1 恢复: CAS(top, A, B)  成功!因为 top 当前确实等于 A
      ⟹ 新栈变成 top ─▶ [B](可能已失效的对象)─▶ ???  ← 结构被撕裂
  关键:CAS 成功,但 T1 所基于的"世界状态"已经不存在了。

6.2.2 LL/SC 与 CAS 的差异:谁更抗 ABA

加载链接 / 条件存储(load-linked / store-conditional, LL/SC) 成对使用:LL(addr) 读 *addr,同时在硬件里对该地址的"监视粒度"打一个独占标记;SC(addr, new) 只在标记仍然有效时写入并返回成功,否则不写入并返回失败。任何对该粒度的写入、上下文切换、缓存行丢失都会使标记失效。

维度CASLL/SC
语义粒度单条指令一次完成,天然的读-改-写两条指令之间构成一个"事务窗口"
ABA 抗性不抗:只看值是否相等,值绕回来检测不到天然抗:检测"期间对该地址有没有任何写入",值绕回来照样发现
虚假失败cmpxchg 基本没有常见:任何对该监视粒度的写入、中断、缓存行迁移都可能导致 SC 失败
代码约束无LL 与 SC 之间不能有复杂操作(不能有嵌套内存操作、函数调用、系统调用)
典型实现x86 cmpxchgARM LDREX/STREX、RISC-V LR/SC、PowerPC lwarx/stwcx.
“LL/SC 更抗 ABA"只说对了一半,另一半是它的代价。抗 ABA 的来源是"监视标志"覆盖了整个窗口,因此任何对该位置的写入都会让 SC 失败——包括与你的逻辑无关的写入(伪冲突),于是 LL/SC 的循环在高竞争下重试次数可能远超 CAS。工程取舍是:CAS 需要额外手段补 ABA 防护,LL/SC 需要接受虚假失败带来的重试放大。

6.2.3 xchg 与 x86 的 lock 前缀

交换(exchange, xchg) 原子地交换寄存器与内存操作数。x86 有一条特殊规则:xchg 只要带内存操作数就隐式含锁语义,不需要写 lock 前缀,这使它成为实现 TAS 自旋锁最直接的指令。lock 前缀的作用随硬件世代变化,必须说清。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
  lock 前缀的两种实现(按硬件世代)
  ── 早期(486 及之前):锁总线 ──
     CPU 拉低 LOCK# 信号,独占内存总线整个操作周期
     ▸ 所有核的所有内存访问都被阻塞,代价与"总线宽度 × 操作周期"成正比
  ── Pentium Pro 及之后:锁缓存行 ──
     操作数已在本核缓存且缓存行处于 Exclusive/Modified ⟹ 不碰总线,缓存内部完成
     读-改-写,靠 MESI 保证原子性;缓存行不在本核(Shared/Invalid)⟹ 发 RFO
     (read-for-ownership)把该行独占过来再本地完成,只有此时才近似锁总线
     ▸ 结论:lock 的代价 ≈ 一次缓存行所有权转移,而不是一次总线独占
  ── 附带语义 ──
     lock 前缀同时是一条完整的全屏障(Intel SDM 明确其具有栅栏语义),
     所以 lock cmpxchg / lock xadd 之后不需要再补 mfence

原子操作的三个实现层级。 一个原子操作要三层共同保证,任何一层缺失都会得到错误行为。

层级保证什么缺失后的症状
编译器 / 语言层(std::atomic、AtomicInteger)不把操作拆成多条指令、不把值缓存进寄存器、不跨调用重排循环条件被提升到循环外,读到永不变化的旧值
CPU 指令层(cmpxchg、LL/SC、xchg)该操作对目标地址不可分割读-改-写被另一个核插入,丢失更新
内存屏障层(mfence、dmb、fence)操作的可见顺序与其他地址的读写顺序值最终会一致,但关键窗口内读到旧的;无锁算法的状态机被撕裂
面试官追问的下一层通常是:“既然 lock cmpxchg 自带全屏障,为什么还需要 std::atomic_thread_fence?“答案是:普通变量的读写不受原子指令保护。无锁算法里"更新一个指针"和"填充该指针指向的数据"是两件独立的事,缺屏障就会出现"指针已可见、数据还没写完"的发布错误。第 30 章 30.3 节给出这一层在 JMM 中的映射。

6.3 自旋锁、队列锁与判据

6.3.1 test-and-set 实现自旋锁

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
/* TAS 自旋锁:用 xchg 抢占 0 → 1 的所有权 */
typedef struct { volatile int locked; } tas_lock_t;
static inline void tas_lock(tas_lock_t *l) {
    /* 为什么写成 while(xchg(...)) 而不是先读再 xchg:
       读操作不改变状态,从读到 0 到执行 xchg 之间仍可能被别人抢走,
       所以"是否拿到锁"必须由原子操作本身来回答,不能由普通读来预判 */
    while (__atomic_exchange_n(&l->locked, 1, __ATOMIC_ACQUIRE) != 0) {
        /* 空转;真实实现会在这里插 PAUSE(见 6.3.3) */
    }
}
static inline void tas_unlock(tas_lock_t *l) {
    /* 释放只需"临界区内的写先于 locked=0 可见",所以普通写 + release 就够,
       不必再用一次原子 RMW,省掉一次独占缓存行的开销 */
    __atomic_store_n(&l->locked, 0, __ATOMIC_RELEASE);
}

成本账:无竞争时一次 xchg 加一次普通写,约 20 ns 量级;有竞争时每个失败者都在反复执行带 lock 前缀的 RMW,而 RMW 要求独占缓存行,于是所有自旋者把该缓存行在核间来回"乒乓”,总线流量与重试次数成正比。

6.3.2 自旋锁还是互斥锁:以约 1 μs 为分界

判据:比较"临界区执行时间 T_cs“与"一次挂起加唤醒的完整开销 T_switch"。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
  决策不等式
      自旋胜出        : T_cs < T_switch
      互斥(睡眠)胜出 : T_cs > T_switch
  数字口径(本书全书统一使用)
      T_switch ≈ 1 ~ 5 μs
          = 陷入内核(约 0.1~1 μs)+ 调度决策与队列操作
          + 两次上下文切换的寄存器/栈/地址空间相关开销(见第 5 章 5.5 节)
      ⟹ 工程上取分界 ≈ 1 μs:
         T_cs 在 100 ns ~ 1 μs 量级 ⟹ 自旋
         T_cs 在 10 μs 以上         ⟹ 睡眠
         中间地带 ⟹ "先短自旋再挂起"的自适应策略(Linux futex、Java 轻量级锁都走这条路)
把判据说成"临界区短就自旋"只能算及格,必须补三个限定:①自旋线程要真的在另一个核上跑(多核);②临界区时间要稳定——若 P50 是 100 ns 而 P999 是 5 ms,1% 的长尾就贡献了约 0.001 × 5 ms = 5 μs 的期望自旋成本,直接吃掉全部收益;③必须有自旋次数上界或让出机制,否则自旋锁等于 CPU 消耗器。

6.3.3 单核上自旋锁为什么无意义

在单核(或自旋线程与持有者共享同一个 CPU)上,自旋线程占着 CPU,持有者拿不到 CPU 就无法释放锁,自旋只是在等着被调度器切走。这正是内核在单核配置下把 spin_lock 退化成"仅关抢占/关中断"的原因——此时它的真实作用不是互斥,而是关闭抢占以保护 per-CPU 数据。但"单核"在工程上还有第三种形态:线程数超过核数,此时即使机器是多核,持有者被换出后自旋者仍可能陪跑整个时间片。

1
2
3
4
5
6
7
  单核上 TAS 自旋锁的完整时间线(T1 持有锁,T2 自旋)
  时间 ─────────────────────────────────────────────────────────▶
  CPU   [T1 进入CS ....] [T1 被抢占] [T2 运行并自旋 ......] [T1 恢复] [释放锁]
                          ▲                                  ▲
                          │                                  │
                    调度器选中 T2                    T2 已烧掉近一个时间片
        T2 在这整段时间里执行的指令:全是 xchg,产出为零

6.3.4 ticket lock:先用"取号"解决公平性

ticket lock 用两个计数器和一次 fetch_add 把申请顺序固定下来:lock() 时 my = fetch_add(&next_ticket, 1) 原子取号,然后自旋等待 now_serving == my;unlock() 时 fetch_add(&now_serving, 1) 叫下一个号。结果是严格 FIFO,有限等待有上界(最多等"前面已取号的人数"次)。但它解决了公平性,没有解决争抢:所有等待者自旋读的是同一个 now_serving,该变量所在的缓存行是共享行;释放时做一次写,按 MESI 协议会让所有核的副本失效(Invalid),于是 N 个核同时发读请求重新拉取——这就是第 1 章 1.5 节所说的缓存行争抢(cache-line ping-pong),只是从"写争抢"变成"读风暴”。争抢没有消失,只是换了位置。

6.3.5 MCS / CLH 队列锁:把自旋点分散到各自本地

MCS 锁(Mellor-Crummey & Scott) 的核心思想:每个等待者在自己的、本地缓存着的节点上自旋,前驱释放时只写后继的节点。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
  MCS 锁结构:一个原子 tail 指针 + 每个等待者一个栈上节点
     tail ─▶ [node3] ─▶ [node2] ─▶ [node1] ─▶ (holder 的节点)
              locked=1   locked=1   locked=0
  lock(qnode):
      qnode->next = NULL ; qnode->locked = 1     // 先把自己标成"未获锁"
      pred = xchg(&tail, qnode)                  // 原子地把 tail 指向自己,拿到前驱
      if (pred != NULL) {
          pred->next = qnode                     // 告诉前驱"我排在你后面"
          while (qnode->locked) pause;           // ★ 自旋在【自己的】节点上
      }
  unlock():
      if (qnode->next == NULL) {                 // 后继可能还没挂链,需处理这个竞态窗口
          if (CAS(&tail, qnode, NULL)) return;
          while (qnode->next == NULL) pause;
      }
      qnode->next->locked = 0                    // 只写【后继】的节点,唤醒它

为什么它消除了争抢:每个自旋者的 qnode->locked 在它自己的缓存行里,唯一会写它的是其前驱,于是该行长期处于本核的 Shared/Exclusive、命中 L1;释放时的写只让一个核的缓存行失效,其余核完全不受打扰;队列变长时总线流量近似 O(1) 而非 ticket lock 的 O(N) 突发。CLH 锁 是同一思路的另一形态:节点记录前驱状态,自旋读前驱节点(通常已被本核缓存),释放时写自己的节点供后继读,第 31 章 31.4 节的 AQS 队列就是它的变体。

队列锁把"争抢"从一个共享变量转移到了一串私有变量,因此同时解决了互斥、缓存行争抢与公平性三个问题。这就是”所有等待者自旋在同一地址“与”各自自旋在自己的地址“之间的数量级差异,也是"如何优化高并发锁"的标准第一答案。

6.4 信号量、条件变量与屏障

6.4.1 信号量与互斥锁的本质区别

信号量(semaphore) 是一个带阻塞能力的整数计数器,只有两个原子操作:P 操作(proberen,也叫 wait/down)执行 S = S − 1,若 S < 0 则调用者阻塞入等待队列;V 操作(verhogen,也叫 signal/up)执行 S = S + 1,若有等待者则唤醒其中一个。

维度互斥锁(mutex)二元信号量(binary semaphore)计数信号量
是否有所有者有:谁加锁谁解锁无无
能否跨线程不能(释放时校验所有者)能:A 线程 P,B 线程 V能
能否跨进程可以(共享内存中的 pthread mutex)可以(POSIX 命名信号量、System V)可以
可重入由实现决定(ReentrantLock 可重入)不可重入,重入即自锁不适用
语义重心保护临界区(互斥)两者皆可控制并发度 / 顺序

信号量能实现互斥,但互斥锁不能替代信号量的全部用途,因为信号量表达的是"资源计数 + 顺序”,不是"所有权”。顺序控制是它最有价值也最容易被忽略的用法。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
/* 用信号量把"先 A 后 B"的顺序强制出来,而不是靠 sleep 猜时间 */
sem_t s_ab;
void *thread_a(void *_) {
    do_a();
    sem_post(&s_ab);            /* V:告诉 B "轮到你了";初值为 0 意味着 B 会先阻塞 */
    return NULL;
}
void *thread_b(void *_) {
    sem_wait(&s_ab);            /* P:A 没发信号就阻塞在这里 */
    do_b();                     /* 这里的先后关系由计数器保证,与调度顺序无关 */
    return NULL;
}

二元信号量做互斥与互斥锁的行为差异有三点,必须能说出:①无所有权 → 任何线程都能"解锁",也就无法检测误用;②不可重入 → 同一线程重复 P 会把自己锁死;③无法实现优先级继承等高级特性。工程上"该用 mutex 却用了信号量"的典型症状就是偶发自死锁与无法定位的误释放。

6.4.2 条件变量:为什么必须配对,为什么必须用 while

条件变量(condition variable) 只做一件事:让线程在条件不成立时原子地释放锁并睡眠,被唤醒后原子地重新获得锁。它必须与互斥锁配对,原因有两条,且都是"否则必然出错"级别的:条件本身是被并发修改的共享状态(判断"队列是否为空"必须读队列,不加锁就与生产者的入队构成 6.1 节的竞态);“检查条件"与"开始等待"必须原子,否则会出现:消费者检查发现队列空 → 释放锁 → 生产者入队并发信号 → 消费者才去睡眠 → 信号被丢失、消费者永久沉睡。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
/* 错误模式:用 if 只检查一次,醒来后径直认为条件成立 */
pthread_mutex_lock(&m);
if (!queue_empty(&q)) {            /* ✗ 只检查一次 */
    item = queue_pop(&q);
    pthread_mutex_unlock(&m);
    return item;
}
pthread_cond_wait(&cv, &m);        /* ✗ 醒来后没有回到上面重新判断 */
item = queue_pop(&q);              /* 队列可能仍是空的 → 取到垃圾/越界 */
pthread_mutex_unlock(&m);
/* 正确模式:while 每次唤醒都重新判断条件 */
pthread_mutex_lock(&m);
while (queue_empty(&q)) {          /* 不把"被唤醒"等同于"条件成立" */
    pthread_cond_wait(&cv, &m);    /* 原子地释放 m 并入等待队列;返回时已重新持有 m */
}
item = queue_pop(&q);
pthread_mutex_unlock(&m);
return item;

必须用 while 的两条独立理由:虚假唤醒(spurious wakeup)——POSIX 明确规定 pthread_cond_wait 允许在没有线程调用 signal/broadcast 的情况下返回(这是为给实现留自由度),所以把"返回"理解为"条件成立"的代码都是错的;唤醒后条件可能再次为假——broadcast 唤醒 N 个消费者但队列只有 1 个元素,第一个取走后其余 N−1 个必须重新判断并继续等待,这不属于虚假唤醒,而是"条件被多方竞争修改”。

一个极常见的错误归因:“用 if 会造成丢失唤醒。“这是错的。丢失唤醒的根因是"等待没有与条件检查构成原子”(典型样本是无锁条件下 if (!cond) wait(),或先检查条件、再到另一个临界区里等待)——即使把 if 换成 while,这种结构照样丢信号。while 解决的是虚假唤醒与条件二次变假这两件事。面试里把这两个概念混答,会被连续追问到答不出来。

6.4.3 同步原语速查

原语状态等待时行为语义重心典型误用
自旋锁(spinlock)一个标志位(或号)忙等,占 CPU极短临界区、不可睡眠的上下文临界区里做 I/O 或睡眠
互斥锁(mutex)标志位 + 等待队列挂起,让出 CPU保护临界区,有所有权锁内做耗时操作(T_cs 巨大)
二元信号量计数器 0/1挂起互斥或"让路”当作可重入锁 → 自死锁
条件变量无状态,必须配锁挂起并重新获取锁条件等待,不能单独表达互斥用 if 检查;signal 时未持锁
屏障(barrier)到达计数挂起直到全员到齐分阶段并行计算的对齐线程数与屏障参与者数不一致 → 挂死
读写锁读者计数 + 写者标志挂起或自旋读多写少,允许读并发写者饥饿;读者临界区内再申请写锁
条件变量不保存任何状态,所以 signal 时若不持有配对的锁,就可能落在"等待者尚未入队"的窗口里而丢失信号——这是"必须在持锁状态下修改条件、并在持锁状态下 signal"这条规则的实际来源。追问方向是"signal 与 unlock 谁先谁后":先 unlock 再 signal 通常能减少等待者"醒来却拿不到锁又睡回去"的颠簸,但前提是条件已在本临界区内改好。

6.4.4 屏障与 RCU 简介

屏障(barrier) 的语义是"到齐才能继续":每个线程到达后计数加一,最后一个到达者唤醒所有等待者;它的正确性前提是参与者数量固定,多一个或少一个都会永久挂死。RCU(read-copy-update) 是"读多写极少"场景的免锁方案,它能免锁的原因不是"读操作没有同步",而是把同步成本移出了读路径。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  RCU 的三条规则(以链表中删除一个节点为例)
  ① 发布/替换:写者先构造新版本,再用【单个原子指针赋值】把它发布出去,
     读者始终看到"某个完整版本",绝不会看到半成品(配合 release 屏障完成发布)
  ② 读侧不加锁:读者进入临界区只需禁用抢占,不做任何原子 RMW,
     因此读路径的可扩展性与核数近似无关
  ③ 延迟释放:写者不能立刻 free 旧节点,必须等所有"在发布之前就已开始
     的读者"全部离开临界区(synchronize_rcu 等待宽限期 grace period 结束)
  ┌── 宽限期 ──────────────────────────────────────────┐
  │ 旧读者:  [====临界区====]                          │
  │ 旧读者:        [====临界区====]                    │
  │ 新读者:                        [==] [==] [==]      │  ← 新读者只会看到新版本
  └────────────────────────────────────────────────────┘
                        ▲ 所有旧读者离开后,旧节点才可安全释放
  ⟹ 读侧零原子操作、零写共享行 ⟹ 无缓存行争抢、无锁等待、无死锁

代价必须一起说清:写者要付出"等待宽限期"的延迟与内存回收的复杂性;读者临界区里不能睡眠(否则宽限期无限延长);RCU 适合"读远多于写"且能容忍读到略有滞后的数据(路由表、配置、内核对象引用),不适合需要强一致读写的计数器。

6.5 内存屏障的硬件层

x86-TSO 的弱序在哪里。 x86 的内存模型是 TSO(total store order),它只对一种重排留了口子:StoreLoad。原因在硬件——写操作进入写缓冲(store buffer) 后即被认为"已提交",CPU 不等缓存行真正到手就继续执行;读操作可以越过(bypass) 自己尚未落地的写,先去读别的地址。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
  x86-TSO 下的 StoreLoad 重排(为什么 mfence 是 x86 上唯一必需的通用屏障)
  线程1                        线程2
  ─────────────────────        ─────────────────────
  X = 1;                       Y = 1;
  r1 = Y;   ← 可能读到 0       r2 = X;   ← 可能读到 0
  直觉上两个 r 不可能同时为 0(谁先写谁就该被读到),但 TSO 允许:
    - 线程1 的 X=1 还躺在【线程1 的写缓冲】里没进缓存,r1 读 Y 直接越过它
    - 线程2 同理 ⟹ r1 == 0 && r2 == 0 是合法结果
  ⟹ TSO 保持的三种顺序:LoadLoad、LoadStore、StoreStore
  ⟹ TSO 唯一不保持的:StoreLoad
指令 / 手段约束的顺序x86 上是否必需何时用
lfence后续指令不得越过它提前执行(序列化指令流)很少见于内存排序目的需要"前序指令全部完成后才继续"的场合(历史上的 MMIO、某些推测执行缓解路径)
sfence之前的写全部对其他核可见后,后续写才可见(StoreStore)TSO 已保证,通常不需要主要是弱序存储(movnt 非临时写)之后需要排序时
mfence全屏障:所有更早的读与写都先于其后的读与写实现 StoreLoad 时必需需要"我的写已可见"那类握手;也可由 lock 前缀指令替代
编译器屏障(asm volatile("" ::: "memory"))只阻止编译器跨它重排访存与 CPU 无关,任何平台都需要编译器可能把普通变量的读写搬过原子操作时
最常见的混淆是把 lfence 当"读屏障"、sfence 当"写屏障",再以为二者合起来等于 mfence。在 TSO 上这个等号不成立:lfence 的名字虽来自 Load Fence,它做的却是指令流序列化,不是"读操作排序";缺 StoreLoad 语义时 sfence + lfence 也替代不了 mfence。另一个混淆是"编译器屏障能替代 CPU 屏障"——它只约束编译器,硬件的乱序执行与写缓冲完全不受影响。

6.6 死锁:四条件、谱系与算例

6.6.1 四条件的完整推导,以及"必要不等价于充分"

死锁(deadlock):一组线程中的每一个都在等待一个只能由组内其他线程释放的资源,且都不主动放弃已持有的资源,于是全体永久阻塞。四条件用"反推"得到:想让死锁发生,必须依次凑齐下面四条,缺一条就死不了。

条件含义为什么必须是它
互斥(mutual exclusion)资源在任一时刻只能被一个线程占用若资源可共享(如只读数据),大家都能拿,无从互相等待
持有并等待(hold and wait)线程持有至少一个资源,同时又去申请新的资源若从不"边拿边要"(要么一次拿全、要么全都不要),不存在等待链
不可抢占(no preemption)资源只能由持有者主动释放,不能被强行夺走若能抢占,系统直接把资源收回来给等待者,链条断裂
循环等待(circular wait)存在线程到资源的等待环:T1 等 R2、R2 被 T2 持有、T2 又在等 R1若等待关系构成无环偏序,必有一个"最大者"不需要等别人,它终将完成
四条同时满足是死锁的必要条件,不是充分条件。 单线程不可能死锁就是最有说服力的反例:它递归申请一把不可重入锁时,形式上"互斥 + 持有并等待 + 不可抢占 + 自己等自己"全都成立,但我们不把它叫死锁——死锁的严格定义要求"一组控制流互相等待",环至少需要两个参与者。 更贴近工程的表述是:四条件满足只说明"存在一条导致死锁的执行交错",不说明"每次运行都死锁"。两个线程各持一把锁再去申请对方那把,只有调度恰好让两者都完成第一个 lock 之后才轮到第二个 lock 才会死锁;若调度让某一线程连续完成两次加锁,就完全无事。这就是死锁"偶发、压测才复现"的根源——它是必要条件加一个恰好坏的时序。

6.6.2 死锁处理的四类策略与逐条破坏方法

策略做法时机代价
预防(prevention)静态地破坏四条件中的至少一条设计期牺牲资源利用率或并发度
避免(avoidance)每次分配前判断"分配后系统是否仍处于安全状态"每次资源请求需预知最大需求,每次分配都要算,开销高
检测与恢复(detection & recovery)允许死锁发生,周期性找环,找到后中止或回滚运行期需要回滚能力与检测开销
鸵鸟策略(ostrich)假装不会发生,靠重启与超时兜底——线上真的死锁时只能重启
被破坏的条件破坏手段现实中的形态
———
互斥把资源改成可共享只读副本、MVCC 快照读(见第 22 章)、ReadWriteLock 允许读并发
持有并等待要求一次性申请全部资源,申请不到就全都不要统一加锁顺序下的"预取";代价是利用率低、可能饿死
不可抢占允许系统强制回收数据库检测到死锁后回滚牺牲者(见第 24 章);tryLock(timeout) 失败则释放已持有的锁并退避重试
循环等待给资源定全序,一律按序号递增申请固定加锁顺序最实用(如"先锁账户 ID 小的、再锁大的");代价是要全局维护序号约定
工程上绝大多数死锁预防就是"固定加锁顺序 + 短事务 + tryLock 超时退避"三条。回答"怎么预防死锁"时先给这三条、再展开四条件,顺序反过来会显得只会背课本。逐条破坏里"破坏互斥"最难(大多数资源本来就不可共享),“破坏循环等待"最便宜,这个取舍要能说出来。

6.6.3 避免:银行家算法与安全序列算例

安全状态(safe state):存在一个进程序列,按此序列逐个满足每个进程的最大需求并让其完成,则系统安全;该序列叫安全序列(safe sequence)。不安全状态不等于死锁,它只表示"系统已经失去了保证不发生死锁的能力”。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
  银行家算法算例(单一资源类型,共 12 个实例)
  进程      已分配 Alloc      最大需求 Max      仍需 Need = Max − Alloc
  P1            2                  6                    4
  P2            3                  8                    5
  P3            4                  7                    3
  已分配合计 = 2 + 3 + 4 = 9  ⟹  Available = 12 − 9 = 3
  ── 判安全:找安全序列 ──
  Available = 3:P1 需 4 > 3 跳过;P2 需 5 > 3 跳过;P3 需 3 ≤ 3 ✓
      P3 跑完释放 Alloc + Need = 4 + 3 = 7 ⟹ Available = 0 + 7 = 7
  Available = 7:P1 需 4 ≤ 7 ✓ 跑完释放 2 + 4 = 6 ⟹ Available = 7 − 4 + 6 = 9
  Available = 9:P2 需 5 ≤ 9 ✓
  ⟹ 安全序列存在:P3 → P1 → P2,当前状态【安全】
  ── P3 提出新请求 Request = 3(Available 3 → 0)──
  只有 P3 的 Need 变成 0 能完成,释放 0 + 7 = 7 ⟹ Available = 7
  再检查:P1 需 4 ≤ 7 ✓ ⟹ Available = 9;P2 需 5 ≤ 9 ✓
  ⟹ 新状态仍安全,【批准 Request = 3】
  ── 若 P1 改请求 Request = 1(Available 3 → 2)──
  所有 Need 都 > 2(P1 剩 3、P2 需 5、P3 需 3)⟹ 找不到任何一个能完成的进程
  ⟹ 无安全序列 ⟹ 【拒绝】
     注意:此时资源明明够用,拒绝的理由是"分配后将进入不安全状态",
     这正是银行家算法与"资源够就给"的朴素做法最关键的区别

银行家算法的边界要能说清:需要预先知道每个进程的最大需求(工程上难以准确预知);每次分配都要跑一次安全性检查,是 O(n² × m) 级别开销;它换来的是"避免死锁",代价是并发度下降(保守拒绝)。所以它主要出现在教学与少数实时/批处理系统中,不是通用服务的默认策略。

6.6.4 检测与恢复:等待图与环检测

等待图(wait-for graph):节点是线程(或进程、事务),若 Ti 正在等待 Tj 持有的资源,则有一条有向边 Ti → Tj。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
  两把锁上的经典死锁 ⟹ 等待图恰好是一条 2-环
  资源视角                          等待图视角
  ┌──────────┐                    ┌──────────┐
  │  锁 A    │◀──持有── T1        │   T1     │
  └──────────┘         │          └──────────┘
        ▲              │等待            │ 等待 B
        │              ▼                ▼
     等待 A         ┌──────────┐   ┌──────────┐
        │           │  锁 B    │   │   T2     │
        └──── T2 ───┴──────────┘   └──────────┘
                                    ▲    │
                                    └────┘  T2 等待 A ⟹ 环
  单实例资源:环 ⟺ 死锁(充要)
  多实例资源:环只是必要条件,需用"资源分配图可完全化简"来判定

检测有两个工程问题:多实例资源不能用单纯的环检测,做法是反复找"Request 能被当前 Available 满足"的进程,假设它完成并释放资源(化简),若最终仍有进程无法化简则存在死锁——这与 6.6.3 的安全序列判定是同一个算法,只是问法相反;检测频率是纯粹的成本权衡,太频繁则环检测本身要遍历等待边并加锁保护等待图,太稀疏则系统带着一堆被阻塞的线程白等很久。恢复手段按代价排序:中止全部死锁线程 → 逐个中止直到环消失 → 回滚到检查点(需要 undo 能力)→ 抢占资源(对有状态资源常常不可行);牺牲者的选择通常基于"已做的工作量最少"“持有资源最少"“已回滚次数最少"三条。

通用操作系统的内核通常不做全局死锁检测(等待图规模太大、检测频率无法取优),而是靠"不在内核里嵌套持锁 + 固定锁顺序"的设计纪律。真正把检测与恢复做成工业级机制的是数据库:InnoDB 的等待图与牺牲者回滚、innodb_lock_wait_timeout(默认 50 s)作为兜底,见第 24 章 24.3 节。这个"OS 不做、DB 做"的对比是很好的加分点。

6.6.5 活锁、饥饿与优先级反转

概念线程在做什么系统整体有进展吗谁被卡住
死锁全部阻塞,不消耗 CPU无环内全部线程
活锁(livelock)全部在运行,不断重试、退避、再重试无(无有效进展)全部相关线程
饥饿(starvation)幸运者在推进,受害者反复申请被拒有特定线程(通常是低优先级者)

判别信号很直接:top 里 CPU 很高但任务毫无进展 → 先怀疑活锁,不是死锁(死锁的线程处于阻塞状态,不占 CPU)。活锁的典型来源是”对称的冲突解决策略":两个线程检测到冲突后都退避相同时间,于是永远同时重试、同时失败;修法是让退避随机化或引入优先级,打破对称性。优先级反转(priority inversion):低优先级线程 L 持有高优先级线程 H 需要的锁,L 被中优先级线程 M 抢占而无法运行,于是 H 被 M “间接压住”。

1
2
3
4
5
6
7
8
9
  优先级反转的经典时序(H > M > L)
  时刻    L (低)          M (中)          H (高)          结果
  ─────────────────────────────────────────────────────────────
   t0   持锁 ──▶
   t1                                   申请锁 ──▶ 阻塞      H 等 L
   t2                  就绪 ──▶ 抢占!                     L 被挤下 CPU
   t3                  长时间运行 ──▶                     L 拿不到 CPU
   t4                                   仍在阻塞           ★ H 被 M 无限期延迟
                                                          (H 优先级最高,却等最久)

优先级继承(priority inheritance) 的解法:当 H 阻塞在 L 持有的锁上时,临时把 L 的优先级提升到 H 的级别,使 M 无法抢占 L;L 释放锁后优先级回退。变体是优先级天花板(priority ceiling):预先给每个锁设定"可能使用它的最高优先级”,持有者立即提升到天花板,从而在加锁那一刻就避免反转,还能顺带防止死锁。

火星探路者(Mars Pathfinder)是优先级继承最著名的案例:机载实时系统出现看门狗复位重启,最终定位为"低优先级任务持有共享总线的互斥量,被中优先级任务抢占,导致高优先级任务无法在时限内完成",修法是启用互斥量的优先级继承属性。引用此案例只讲机制与因果链,不编造任务名、时长、日期等细节。 追问方向是"优先级继承与天花板各有什么取舍":继承只在冲突发生时提升、开销小但反转窗口仍存在;天花板提前提升、更安全,但需要静态分析每个锁的优先级上界,且可能造成不必要的提升。

6.6.6 无锁与无等待

进展保证定义单个线程会被无限延迟吗典型例子
阻塞(blocking)一个线程的挂起会导致其他线程无法推进会互斥锁保护的临界区
无锁(lock-free)系统整体始终能推进:每时每刻至少有一个线程能完成操作会(某个线程可能一直重试失败)CAS 循环、AtomicInteger.incrementAndGet
无等待(wait-free)每个线程都能在有界步数内完成不会RCU 的读侧、fetch_add、LongAdder 的写(近似)
把"用了 CAS"说成"无锁"是最常见的错答。无锁要求的是系统级进展保证,不是"没用锁"这个形式特征:一个 CAS 循环若靠无限重试等待另一个线程先完成某个不可回退的动作,就既不阻塞也不是无锁。另一个反直觉点是无等待比无锁强得多,因此现实中大多数数据结构只能做到无锁。
ABA 的解法做法代价 / 边界
版本号 / 标签(tagged pointer)把"指针 + 单调递增计数器"打包成一次更宽的 CAS(x86-64 用 cmpxchg16b 一次比较 128 位)需要平台支持双宽度 CAS;版本计数器仍会回绕(只是把窗口拉得极长)
LL/SC 风格用"期间无任何写入"的语义替代"值相等",值绕回来也能检测依赖架构支持;虚假失败带来重试放大
延迟回收 / 安全内存回收保证"被移除的节点在无人引用之前绝不被复用或释放":hazard pointer、RCU 宽限期、epoch-based reclamation、或依赖 GC需要一套回收协议;这是最彻底的解法,因为 ABA 有害的根因是"节点被释放并在别处复用",节点永不复用则 A→B→A 不再危险

CAS 循环在高竞争下的退化:N 个线程同时 CAS 同一个变量,一次只有 1 个成功,其余 N−1 个失败后重新加载缓存行并重试;而 RMW 要求独占缓存行,于是每次成功都让其他核的副本失效、缓存行在核间来回迁移,有效吞吐可能随核数上升而下降。这是"加锁反而比无锁快"的常见原因,也是第 31 章 31.4 节 AQS 用 CLH 队列把竞争收敛到队首、LongAdder 用分段计数打散热点的动机。

本章小结

关键结论一句话表述常见错误
竞态与三条件输出依赖不可控时序;互斥是安全性,进展看系统、有限等待看个体说成"偶发 bug";把进展与有限等待混答
CAS 与 LL/SCCAS 只比值、不抗 ABA;LL/SC 检测"期间无写入"、天然抗 ABA 但会虚假失败只答"LL/SC 更抗 ABA",漏掉虚假失败与代码约束
lock 前缀P6 以后锁的是缓存行(经 MESI 取独占权),未命中才退化到锁总线;同时自带全屏障答"lock 就是锁总线";以为它只保原子性不保顺序
自旋还是睡眠判据是 T_cs 与 T_switch ≈ 1~5 μs 的比较,分界约 1 μs用固定"锁等待阈值"当普适判据;忘记单核与超配场景
队列锁的价值把"所有人在同一地址自旋"变成"各自在私有节点自旋",消除缓存行争抢以为 ticket lock 已解决争抢(它只解决公平性)
信号量 vs 互斥锁信号量无所有权、可跨线程、不可重入;互斥锁有所有权用二元信号量当可重入锁 → 自死锁
条件变量必须 while理由是虚假唤醒与"唤醒后条件再次变假";丢失唤醒的根因是非原子的等待答"用 if 会丢失唤醒",两个概念串味
屏障的位置x86-TSO 唯一弱序是 StoreLoad,通用屏障是 mfence;lfence 是序列化不是"读排序"以为 sfence + lfence = mfence;以为编译器屏障能替代 CPU 屏障
死锁四条件是必要条件而非充分条件;单线程自锁不算死锁背完四条却答不出"为什么不是充分条件"、举不出反例
活锁 vs 饥饿活锁:所有人在跑,系统零进展;饥饿:系统有进展,特定线程被饿死把活锁当死锁(“CPU 高 + 无进展"可区分)

自测题

Q1(辨析)某次运行两个线程各对 count 加 100 万次得到 187 万,这能说明"没有竞态条件"吗?给 count 加 volatile 能解决吗?

答案与解析不能,而且 187 万本身就是竞态生效的证据:正确结果是 200 万,少的 13 万次更新被覆盖了。竞态的定义是"结果依赖不可控时序”,不是"结果一定错";反过来某次恰好得到 200 万也不能证明不存在竞态,只说明这次调度没在危险窗口里抢占。volatile 解决不了:它保证每次访问都真的读写内存、不把值缓存进寄存器、不被编译器优化掉,但不保证"读-改-写"三步的原子性,两个线程仍可能读到同一个旧值、各自加一后写回。错在哪:把"竞态"与"必现的错误结果"绑定,或把 volatile 当同步原语(第 30 章 30.3 节反复强调的坑)。追问方向是"volatile 到底保证什么",要落到"可见性 + 禁止重排,但不保证原子性",并用 AtomicInteger.incrementAndGet(底层 CAS 循环)作对照。

**Q2**(判断 + 计算)某服务临界区平均执行 100 ns、P999 达到 5 ms。锁等待时应该自旋还是睡眠?
答案与解析按均值算 `T_cs ≈ 100 ns` 远小于 `T_switch ≈ 1~5 μs`,纯均值口径下自旋划算;但**自旋的代价与等待时间的分布成正比,而不是与均值成正比**。粗算 1% 概率的 5 ms 长尾:每 1000 次操作里有 1 次是 5 ms,期望自旋成本 ≈ `0.001 × 5 ms = 5 μs`,**已超过 `T_switch` 的上界**,长尾一项就把收益吃光;还没算自旋者占着 CPU 挤占持有者时间片(核数接近线程数时尤甚),可能进一步拉长持有时间形成正反馈。结论:**不应无条件自旋,应用"先短自旋(几百到几千次 `pause`)再挂起"的自适应策略,并先把 5 ms 长尾找出来**——临界区里出现毫秒级停顿通常意味着缺页、I/O、`malloc` 或大对象拷贝,属于应在架构层消除的问题,锁策略补偿不了。错在哪:只用均值回答"临界区短所以自旋"。追问方向是"你怎么知道 5 ms 是什么造成的"——用 `perf` 抓栈、看缺页与 I/O 计数,归类后再定锁策略;P999 重要是因为等待时间对服务时间方差极度敏感(第 3 章 3.5 节的尾延迟放大),长尾会沿调用链串行放大。
**Q3**(推导)`pthread_cond_wait` 返回之后为什么必须重新检查条件?"用 `if` 会导致丢失唤醒"错在哪里?
答案与解析两条独立理由。**虚假唤醒(spurious wakeup)**:POSIX 规范明确允许 `pthread_cond_wait` 在没有线程调用 signal/broadcast 的情况下返回,这是为给实现留自由度,所以"返回"不能解释为"条件已成立"。**条件再次为假**:`broadcast` 唤醒 3 个消费者但队列只有 1 个元素,第一个取走后另外两个必须重新判断并继续等待;这不属于虚假唤醒,而是"多个等待者竞争同一条件"的必然结果。而"用 `if` 会丢失唤醒"错在**把两个独立缺陷合并成一个**:丢失唤醒的根因是"检查条件"与"进入等待"没有被锁或没有被原子地串在一起,典型样本是无锁保护的 `if (!cond) wait();`——**这种情况下把 `if` 改成 `while`,信号照样会丢**;`while` 修的是"醒来后条件未必成立"。错在哪:把"虚假唤醒导致判断失效"与"丢失唤醒导致永久阻塞"当成同一件事。追问方向是"signal 应在 unlock 之前还是之后":必须在持锁状态下修改条件;先 `unlock` 再 `signal` 通常能减少"醒来又拿不到锁再睡回去"的颠簸,但要确认条件已改好且没有路径能在其间改回去。
**Q4**(计算)系统共 12 个同类可重用资源,三个进程的 `Max` 为 6、8、7,当前 `Alloc` 为 2、3、4。当前状态安全吗?若 P1 请求 1 个资源,银行家算法应如何处置?
答案与解析`Need = Max − Alloc` 分别为 4、5、3;`Alloc` 合计 9 ⟹ `Available = 12 − 9 = 3`。判安全:只有 P3 的 `Need = 3` 可满足,P3 完成释放 `4 + 3 = 7` ⟹ `Available = 7`;P1 需 4 可满足、完成释放 `2 + 4 = 6` ⟹ `Available = 9`;P2 需 5 可满足,故**安全序列 P3 → P1 → P2 存在,当前状态安全**。P1 请求 1:先做前提检查(1 ≤ 4 ✓、1 ≤ 3 ✓)再试探分配,`Available = 2`;此时 P1 剩 3、P2 需 5、P3 需 3,**没有任何进程的 `Need ≤ 2`**,找不到第一个能完成的进程 ⟹ 无安全序列 ⟹ 新状态**不安全**,**拒绝该请求**。"资源够用却拒绝"正是银行家算法的本质:它做的不是"够不够"的即时判断,而是"分配之后还能不能保证所有进程都跑完"的前瞻判断;不安全的当下并没有死锁,只是系统丧失了"保证不死锁"的能力——**不安全 ≠ 死锁**。错在哪:只答"1 ≤ 3 所以批准",丢掉安全性检查;或把"不安全状态"直接说成"已死锁"。追问方向是工程上为何很少用:需预知 `Max`、每次分配都要跑 `O(n² × m)` 检查、保守拒绝降低并发度。
**Q5**(推导)ticket lock 已保证 FIFO,为什么高并发下仍可能比 MCS 锁慢一个数量级?这个差异与伪共享是什么关系?
答案与解析ticket lock 的所有等待者自旋读**同一个 `now_serving`**,它通常还与 `next_ticket` 同处一个缓存行。问题链条是:①等待者的副本由 MESI 保持为 Shared,纯读自旋不产生总线流量;②持有者 `unlock` 时的写让**所有核**的该行副本失效(Invalid);③N 个核几乎同时发读请求重新拉取,形成突发流量与缓存行迁移;④每张票的 `fetch_add(&next_ticket)` 本身也是需要独占该行的 RMW。结果是**排队越长、核数越多,每次解锁被放大的流量越大**,吞吐随核数下降。MCS 把每个等待者放到**自己的节点**上(`qnode->locked` 通常在申请者栈上、由本核独占),自旋读的是本核缓存行 ⟹ 自旋期间零总线流量;释放时**只写后继的一个节点** ⟹ 只让一个核的缓存行失效;队列变长时流量近似 `O(1)`,且天然 FIFO,同时解决公平性与争抢。与伪共享的关系:这是**真共享**(大家确实读写同一变量),但硬件事件与伪共享同类——多个核反复竞争同一缓存行;伪共享是"逻辑上不共享但物理同一行",ticket lock 是"逻辑上也共享且物理同一行",两者的修法思路一致:**把热点变量按缓存行隔离或分散到各核私有**,这正是第 1 章 1.5 节 padding/`@Contended` 与本章队列锁的共同本质。错在哪:只答"ticket lock 不公平"(它恰恰公平),或答"MCS 用了链表所以快"而不指出**自旋点从共享地址搬到了私有地址**。追问方向是"MCS 在单核上还成立吗"——此时线程不可能真正并行自旋,私有自旋的优势消失,必须改成"释放时直接唤醒后继"(本质是阻塞队列),这正是 Linux futex 与 AQS 都"自旋一小段后转入内核阻塞"的原因(见第 31 章 31.4 节)。
**Q6**(判断)"四个必要条件同时满足就一定会死锁"对吗?"等待图中有环就一定能判定死锁"对吗?
答案与解析第一句错。四条件是**必要**条件:死锁发生时它们必然成立,但成立不等于死锁必然发生。反例一:单线程递归申请一把不可重入锁,"互斥 + 持有并等待 + 不可抢占 + 自己等自己"形式上都满足,但死锁的严格定义要求一组控制流互相等待,环至少需要两个参与者。反例二(更工程):两个线程按"T1 先 A 后 B、T2 先 B 后 A"申请锁,只有调度恰好让两者都先拿到第一把、之后才申请第二把时才死锁;若调度让某一线程连续完成两次加锁则完全无事。所以四条件满足只说明**存在一条导致死锁的执行交错**。第二句只在**单实例资源**下成立(环 ⟺ 死锁,充要);**多实例资源**下环只是必要条件,必须用资源分配图的化简判定:反复找 `Request` 能被当前 `Available` 满足的进程,假设其完成并释放资源,若最终仍有进程无法化简才是死锁;而且"等待关系成环"与"实际互相阻塞"还要落到实例层面——T1 等 R1 的某个实例、T2 等 R2 的某个实例,若被等的实例其实空闲,环并不构成实际阻塞。错在哪:把必要条件当充要条件,把"可能死锁"讲成"必然死锁";或只记"有环即死锁"而不区分单/多实例资源。追问方向是"既然四条件不充分,预防为什么还按四条件破坏"——破坏任一条就**消除了死锁的可能性**(把"存在坏交错"变成"不存在"),预防针对的是可能性,不是某次必然事件。
## 面试高频问题与陷阱
面试问法考查角度陷阱 / 加分点对应本章小节
什么是竞态条件?两个线程分别自增一个共享变量 100 万次,结果一定小于 200 万吗?竞态的精确定义陷阱:答"是偶发 bug"或"加 volatile 就好"。加分点:给出"结果依赖不可控时序"的定义,并说清 volatile 只保可见性不保原子性6.1
CAS 的 ABA 问题是什么?为什么 LL/SC 没有这个问题,代价又是什么?硬件原语对比陷阱:只答"加个版本号就行了",说不出 ABA 如何撕裂无锁栈结构;漏掉 LL/SC 的虚假失败与 LL 到 SC 之间的代码约束6.2.1、6.2.2
x86 的 lock 前缀到底锁了什么?为什么现代 CPU 不再锁总线?指令与缓存一致性陷阱:答"锁总线"就结束。加分点:区分 P6 之前的锁总线与之后的锁缓存行(RFO + MESI 独占),并指出 lock 同时自带全屏障6.2.3
自旋锁和互斥锁怎么选?给我一个数字判据。判据与量化陷阱:答"临界区短就自旋",说不出阈值从哪来。加分点:T_cs 与 T_switch ≈ 1~5 μs 比较、分界约 1 μs,并补上多核、时间分布(P999)、自旋上界三个限定6.3.2
ticket lock 已经很公平了,为什么高并发下还是不行?MCS 锁解决了什么?缓存行争抢陷阱:答"ticket lock 不公平"。加分点:说清自旋点从共享 now_serving 搬到私有节点、释放时只失效一个核的缓存行,并挂到与伪共享同一类硬件事件上6.3.4、6.3.5
信号量和互斥锁有什么区别?二元信号量能当互斥锁用吗?所有权语义陷阱:答"信号量是计数器"就停。必须给出无所有权、可跨线程/进程、不可重入三点,并指出误用会导致自死锁6.4.1
条件变量为什么必须和互斥锁一起用?为什么判断条件必须用 while 而不是 if?条件变量正确性陷阱:把"用 if 会丢失唤醒"当答案。加分点:给出虚假唤醒与条件二次变假两条独立理由,并说清丢失唤醒的真正根因是非原子的等待6.4.2
x86 上为什么只需要 mfence?lfence 和 sfence 分别管什么?内存模型与屏障陷阱:把 lfence 当"读屏障"、以为 sfence + lfence = mfence;或以为编译器屏障能替代 CPU 屏障6.5
银行家算法怎么判断一个请求能不能批准?为什么资源够用也可能拒绝?安全序列推导陷阱:只比较 Request ≤ Available。必须跑出安全序列,并区分"不安全状态"与"已死锁",再说清工程上很少用的原因6.6.3
什么是优先级反转?优先级继承和优先级天花板有什么区别?实时系统机制陷阱:只答"优先级高的先执行"。加分点:讲清"中优先级线程间接压住高优先级线程"的链条,并说出继承与天花板的取舍6.6.5
用了 CAS 就是无锁吗?无锁和无等待有什么区别?进展保证的层级陷阱:把"没用锁"等同于 lock-free。必须给出"系统级进展 vs 每线程有界步数"的定义差,并承认现实中大多结构只能做到无锁6.6.6
ABA 问题有哪几种解法?哪一种最彻底?无锁算法工程陷阱:只会答"加版本号"。加分点:指出根因是"节点被释放并复用",因此延迟回收(hazard pointer / RCU 宽限期 / GC)才是最彻底的一类解法6.6.6
高并发下 CAS 循环为什么可能比加锁还慢?竞争退化陷阱:答"CAS 是无锁的所以更快"。必须说出 RMW 需独占缓存行、失败者重新加载并重试、吞吐随核数下降,并指向 AQS/LongAdder 的分散热点思路6.6.6

延伸阅读

  • Intel 64 and IA-32 Architectures Software Developer’s Manual, Volume 3A 第 8 章(Multiple-Processor Management):lock 前缀语义、“锁缓存行"的实现说明与 LOCK 操作的栅栏语义;Volume 2A 中 XCHG/CMPXCHG/MFENCE/LFENCE/SFENCE 的指令参考。
  • Sewell, Sarkar, Owens, Nardelli, Myreen, x86-TSO: A Rigorous and Usable Programmer’s Model for x86 Multiprocessors, Communications of the ACM(6.5 节 StoreLoad 论述的规范依据)。
  • John M. Mellor-Crummey, Michael L. Scott, Algorithms for Scalable Synchronization on Shared-Memory Multiprocessors, ACM TOCS(MCS 队列锁原文,含与 ticket lock / TAS 锁的对比);Leslie Lamport, A New Solution of Dijkstra’s Concurrent Programming Problem(理解"进展"与"有限等待"的经典原文);Dijkstra, Cooperating Sequential Processes(信号量 P/V 的原始出处)。
  • IEEE Std 1003.1(POSIX.1)中 pthread_cond_wait、pthread_cond_signal、pthread_mutex_*、sem_wait/sem_post 的规范条款:虚假唤醒与"必须在循环中重新检查条件"的规范依据;man 3 pthread_cond_wait、man 7 futex(Linux man-pages):自适应自旋与 futex 的挂起/唤醒路径。
  • Paul E. McKenney 等 Read-Copy Update 及 Linux 内核文档 Documentation/RCU/(宽限期、synchronize_rcu、hazard pointer 与延迟回收)。
  • Maurice Herlihy, Nir Shavit, The Art of Multiprocessor Programming(第 2 章互斥、第 3 章进展保证、第 79 章自旋锁与队列锁、第 10 章 ABA 与内存回收);Abraham Silberschatz 等 Operating System Concepts 第 78 章(死锁四条件、银行家算法与等待图的经典表述);Glenn Reeves(JPL)关于火星探路者优先级反转的公开技术报告(引用时只讲机制因果,不引具体任务名与时间数字)。