凌晨五点的餐厅后厨,是整座城市最紧张的地方。十位厨师共享一个烤箱、六个灶台和一口大炒锅。如果两位厨师同时把蛋糕塞进同一个烤箱,或者同时伸手去拿同一把炒勺,轻则菜品全毁,重则厨房瘫痪。于是后厨立了一套规矩:烤箱前挂一块“使用权牌”,谁拿到牌子谁烤;灶台数量有限,进门先报数,满了就在外头等;谁要用炒锅,必须等上一位厨师大喊“用完啦”才准进来。这套规矩,就是计算机世界里鼎鼎大名的“互斥与同步原语”。 现代程序动辄开几十个线程同时干活,就像一支几十人的厨师队伍。多个线程同时读写同一个内存变量,就像多个厨师抢同一个烤箱——如果不加约束,程序的输出就会像被搅乱的蛋糕糊一样不可预测。互斥(Mutual Exclusion)保证同一时刻只有一个线程进入“禁区”;同步(Synchronization)则协调线程的执行节奏,让它们像训练有素的厨师一样排队、交接、配合。本文将从临界区、锁机制、信号量、条件变量、读写锁、死锁、性能调优到原子操作,用一间厨房的烟火气,帮你把操作系统的并发核心彻底看明白。

岗位关联

这篇文章面向所有 AI 从业者:无论你训练大模型、写推理服务还是调数据库,并发都是绕不开的“后厨”。掌握互斥与同步原语,你能读懂框架源码里的锁、诊断线上死锁、设计高吞吐的并发系统——这是从“会调 API”走向“懂系统”的关键一跃。

目录
  1. 可视化演示
  2. 临界区与竞态
  3. 互斥锁机制
  4. 信号量机制
  5. 条件变量
  6. 读写锁
  7. 死锁与饥饿
  8. 锁性能调优
  9. 内存与原子
  10. AI 应用场景
  11. Python 演示
  12. 常见误区
  13. 面试题

可视化演示

展示自旋锁与互斥锁、信号量P/V、原子操作CAS与死锁预防。

本节配有交互动效(进入视口后自动加载,约 3KB)。

自旋锁与互斥锁对比 自旋锁忙等待循环检测锁状态适合短临界区 互斥锁阻塞等待线程休眠适合长临界区 Spin = busy_wait; Mutex = sleep+wake 锁选择取决于临界区长度与上下文切换开销

临界区与竞态

切菜台是整个后厨最紧张的地方,因为所有人共用一张台面。如果两位厨师同时把手伸向同一摞盘子,轻则菜撒一地,重则盘子摔碎。计算机世界里,这段“同一时刻只允许一个线程进入”的代码区域,就叫临界区(Critical Section)。而多个线程同时读写共享数据、导致结果不可预测的现象,叫做竞态条件(Race Condition)。

为什么一行代码就能引发混乱?因为高级语言里的 counter++,在 CPU 眼中其实是三步动作:先从内存读出值,在寄存器里加一,再写回内存。A 线程读到 50,还没写回,B 线程也读到 50,两个线程都把 51 写回——本该加两次变成 52,结果只有 51。这就是“丢了更新”。

这三步必须“一气呵成”,中间不能被别的线程打断。这种不可分割的性质就叫原子性(Atomicity)。厨房的解决办法简单粗暴:在切菜台放一块牌子,谁拿到牌子谁进去,出来再把牌子挂回去。计算机里的这块牌子,就是互斥锁。

Load x;Add 1;Store x\text{Load } x; \quad \text{Add } 1; \quad \text{Store } x
这三条指令翻译成厨房语言:看单子(Load)、心里添一道菜(Add)、把新数量记上(Store)。如果两个厨师同时“看单子”,就会把同一道菜记两遍。
  1. Load x把共享变量 x 从内存读到 CPU 寄存器——厨师先瞄一眼单子上写了多少道菜。
    Load x\text{Load } x
  2. Add 1在寄存器里做加 1 运算——厨师在心里把数量加一。
    Add 1\text{Add } 1
  3. Store x把结果写回内存——厨师把新数量誊写到单子上。
    Store x\text{Store } x

互斥锁机制

上节说到的“牌子”,就是互斥锁(Mutex)。但牌子也分性格。自旋锁(Spinlock)像守在门口干等的厨师——进不去就在门口来回踱步,CPU 被白白耗着。互斥锁则像先去备菜、等喊号再回来的厨师——进不去就挂起线程,把 CPU 让给别的任务。两者各有适用场景:临界区极短、CPU 核多,自旋更划算;临界区长、并发量大,互斥锁更省资源。

现代语言里到处是这块牌子:Python 的 threading.Lock、Java 的 synchronized 和 ReentrantLock、Go 的 sync.Mutex。选锁时还要考虑公平性:先到的厨师先拿到牌子,还是可能被后来的插队?公平锁能避免“饿死”,但会牺牲一点吞吐。

S={0,锁空闲1,锁被持有S = \begin{cases} 0, & \text{锁空闲} \\ 1, & \text{锁被持有} \end{cases}
锁变量 S 像牌子在不在挂钩上:0 表示没人拿,1 表示已被某位厨师拿走。拿锁是用原子指令把 S 从 0 变 1,放锁就是还回 0。
  1. S=0锁处于空闲状态,任何线程都可尝试获取——牌子安安静静挂在挂钩上。
    S=0S = 0
  2. S=1锁已被某线程持有,其他线程只能等待——牌子被厨师 A 攥在手里。
    S=1S = 1
  3. 获取锁用一条不可打断的原子指令完成“检查并占用”——厨师看到牌子在就一把拿走,这个动作不容旁人插手。
    acquire: 原子地把 S 从 0 置为 1\text{acquire: 原子地把 } S \text{ 从 } 0 \text{ 置为 } 1

信号量机制

烤箱只有一个要守门,灶台却有六个。如果给每个灶台单独挂锁,厨师们来回跑腿累得够呛。不如在门口挂一块“余位牌”:写着剩余可用灶台数,每次进去一个人数字就减一,出来再加一。这个“余位牌”就是信号量(Semaphore),由计算机科学家 Dijkstra 提出,配套两个原语:P(Proberen,荷兰语“测试”)和 V(Verhogen,“增加”)。

信号量的计数值为 1 时退化为二值信号量,但它和互斥锁有个微妙差异:互斥锁讲究“谁持有谁释放”,信号量却允许别的线程代为 V 操作。这既是灵活性,也是容易踩坑的地方。

P(S): S=S−1; 若 S<0 则阻塞等待P(S):\ S = S - 1;\ \text{若 } S < 0 \text{ 则阻塞等待}
V(S): S=S+1; 若 S≤0 则唤醒一个等待者V(S):\ S = S + 1;\ \text{若 } S \leq 0 \text{ 则唤醒一个等待者}
P 像是“进厨房先拿一个名额”:拿不到就堵在门口排队。V 像是“出厨房归还名额”:还完发现门口有排队的,就喊最前面的那个进来。
  1. P(S) 减一申请一个资源,信号量减一,表示余位少了一个。
    P(S): S=S−1P(S):\ S = S - 1
  2. S<0 则阻塞减完发现是负数,说明已经没有多余资源,当前线程阻塞——只能站门口等有人腾出灶台。
    若 S<0 则阻塞等待\text{若 } S < 0 \text{ 则阻塞等待}
  3. V(S) 加一释放一个资源,信号量加一——厨师干完活,把灶台让出来。
    V(S): S=S+1V(S):\ S = S + 1
  4. S≤0 则唤醒加完发现仍有负值,说明有人排队,唤醒其中一个——把腾出的灶台交给排最前的厨师。
    若 S≤0 则唤醒一个等待者\text{若 } S \leq 0 \text{ 则唤醒一个等待者}

条件变量

厨师把蛋糕放进烤箱后,总不能搬把椅子守在烤箱前干瞪眼。后厨的做法是:烤箱装上铃铛,烤好了“叮”一声。厨师可以去休息室眯一会儿,听到铃声再起来干活。这个“铃铛”就是条件变量(Condition Variable)。它必须配合互斥锁使用:线程发现条件不满足时,释放锁并进入休眠;条件满足时,别的线程发信号唤醒它。

实际编程中要警惕虚假唤醒(Spurious Wakeup):被唤醒不一定代表条件真的成熟了。所以要用 while 循环重新检查条件,就像厨师被喊醒后先揉揉眼确认——哎,是隔壁的人在喊自己的亲戚,那接着睡。

wait(cond,mutex): 释放 mutex→阻塞等待 cond\text{wait}(cond, mutex):\ \text{释放 } mutex \to \text{阻塞等待 } cond
signal(cond): 唤醒一个等待 cond 的线程\text{signal}(cond):\ \text{唤醒一个等待 } cond \text{ 的线程}
wait 是“放下手里的牌子,去休息室睡觉”;signal 是“跑到休息室喊醒一个人”。注意 wait 必须先释放锁,否则别人进不来,条件永远无法改变,就成了死等。
  1. 释放 mutex进入等待前必须先释放互斥锁,让别的线程有机会改变条件——厨师睡前去把牌子挂回挂钩。
    释放 mutex\text{释放 } mutex
  2. 阻塞等待 cond线程进入条件变量的等待队列,CPU 让出——厨师在休息室躺下。
    阻塞等待 cond\text{阻塞等待 } cond
  3. signal 唤醒条件满足时,一个线程发出信号,把等待者从队列移到就绪队列——铃声响起,厨师睁开眼睛。
    signal: 唤醒一个等待者\text{signal: 唤醒一个等待者}

读写锁

餐厅里,菜单是公开的,所有客人同时看都没问题(读);但改菜单的主厨只有一个(写)。如果每次修改菜单都用一把互斥锁把整本菜单锁死,想看菜单的客人也得排队,那生意就没法做了。读写锁正是为此设计:读锁可以多线程同时持有,写锁必须独占。核心规则是:读读并发、读写互斥、写写互斥。

读写锁有两种调度策略:读者优先下,只要一直有读者来,写者可能被活活饿死;写者优先则让新读者在写者到达后暂停入场,避免写者饥饿。多数数据库系统采用写者优先或“先到先得”的公平策略。

nr≥0,nw∈{0,1},nr×nw=0n_r \ge 0,\quad n_w \in \{0, 1\},\quad n_r \times n_w = 0
数一数店里的人数:看菜单的读者可以任意多,动笔写菜单的写者最多一个,而且只要有人在看菜单,写者就不能动笔。
  1. n_r ≥ 0读者数量没有上限,多个读者可同时进入——菜单可以同时递给十位客人看。
    nr≥0n_r \geq 0
  2. n_w ∈ {0,1}写者要么没有、要么只有一个——动笔的只能是一位主厨。
    nw∈{0,1}n_w \in \{0, 1\}
  3. n_r × n_w = 0读与写不能同时发生——有人看菜单时,主厨不许涂改。
    nr×nw=0n_r \times n_w = 0

死锁与饥饿

两位厨师,A 握着炒锅要等 B 手里的锅盖,B 握着锅盖要等 A 手里的炒锅。谁也不肯先松手,后厨彻底僵住——这就是死锁(Deadlock)。死锁的发生需要四个条件同时满足:互斥(资源一次只能一人用)、持有并等待(拿着资源还要等别的)、不可剥夺(不能强行抢)、循环等待(A 等 B、B 等 A 形成环)。

应对死锁有三种思路:预防(破坏四个条件之一,比如规定所有厨师必须按“先锅后盖”的顺序拿工具)、避免(银行家算法,动态判断这次分配是否安全)、检测与恢复(用工具检测等待环,强制打断)。此外还有饥饿:不是死锁,但某些线程一直抢不到资源,像总排不到灶台的学徒——没被卡死,却活不下去。

死锁  ⟺  M1∧M2∧M3∧M4\text{死锁} \iff M_1 \wedge M_2 \wedge M_3 \wedge M_4
四个条件像四把锁把后厨的门焊死。缺一不可,而只要打破任意一把锁,死锁就解开了。
  1. M1 互斥资源不能同时共享——炒锅一次只能一人用。
    M1:互斥M_1: \text{互斥}
  2. M2 持有并等待厨师拿着炒锅不撒手,还等着要锅盖。
    M2:持有并等待M_2: \text{持有并等待}
  3. M3 不可剥夺不能从别人手里强行抢走资源——锅里还有菜,谁也不能硬夺。
    M3:不可剥夺M_3: \text{不可剥夺}
  4. M4 循环等待A 等 B、B 等 A,形成闭合等待环。
    M4:循环等待M_4: \text{循环等待}

锁性能调优

餐厅客流越大,锁的粒度问题越突出。粗粒度锁:整个厨房只挂一块牌子,谁进去谁全包,实现简单但效率极低——一个人炒菜,全员围观。细粒度锁:每个台面独立挂牌,不同厨师可同时在各区域干活,并发度高但管理复杂——每个牌子都要防抢、防错、防死锁。

调优手段还有:分段锁(把一个大烤箱拆成多个小烤箱,如 ConcurrentHashMap 的分段设计)、读写锁替换互斥锁、无锁数据结构(用 CAS 实现)。评估指标看两个:吞吐量(单位时间出多少菜)和延迟(一道菜从下单到上桌多久)。

Slatency=1(1−p)+pN\displaystyle S_{\text{latency}} = \frac{1}{(1 - p) + \dfrac{p}{N}}
阿姆达尔定律:若串行部分占比为 p,并行部分最多加速 N 倍,整体加速比由上式给出。锁冲突就是那个怎么都优化不掉的“串行部分”。
  1. p串行比例:程序中只能串行执行的比例,锁保护的临界区是其中的大头——必须一个人独占的烤箱时间占比。
    p=串行时间占比p = \text{串行时间占比}
  2. N并行度:同时能干活的人数——后厨的厨师数量(CPU 核心或线程数)。
    N=并行线程数N = \text{并行线程数}
  3. 整体加速比并行部分最多缩短到 1/N,串行部分纹丝不动——烤箱只有一台,再多厨师也快不起来。
    S=1(1−p)+p/N\displaystyle S = \frac{1}{(1 - p) + p / N}

内存与原子

切菜最怕切到一半接电话。有没有一种“一刀到底”的操作?CAS(Compare-And-Swap,比较并交换)就是硬件提供的一条原子指令:先比较内存中的值是否等于期望值,如果等于,就换成新值。整个操作只花一条指令的时间,谁都打断不了。

CAS 有著名的 ABA 问题:值从 A 变成 B 又变回 A,CAS 会误以为“没人动过”。解法是带上版本号(如 Java 的 AtomicStampedReference)。还有内存模型问题:各 CPU 核心有本地高速缓存,线程 A 修改了变量,线程 B 的缓存里可能还是旧值。原子变量和内存屏障(Memory Barrier)就是强制“把誊写结果同步给大家看”的规矩。

CAS(A,E,N): if ∗A=E then ∗A=N, return ∗A\text{CAS}(A, E, N):\ \text{if } *A = E \ \text{then } *A = N,\ \text{return } *A
CAS 像“对暗号”:你说出期望的现状 E,如果内存里的值确实等于 E,就把它换成新值 N。全程一步到位,没人能插话。
  1. 比较 *A 与 E检查内存当前值是否等于期望值——先确认牌子确实挂在挂钩上。
    if ∗A=E\text{if } *A = E
  2. 更新为 N相等则写入新值——确认无误后一把拿走牌子。
    then ∗A=N\text{then } *A = N
  3. 返回旧值返回操作前的值,调用方据此判断 CAS 是否成功——牌子拿没拿到,看一眼返回值就知道。
    return ∗A\text{return } *A

AI 应用场景

数据库事务与银行转账

阶段:数据存取 / 并发控制

解决的问题:银行转账必须“扣款 + 入账”两步原子完成。若并发执行不加锁,同一账户收到两笔转账时,两笔都会读到相同的旧余额,最后只增加一笔。数据库用行级锁(本质是互斥锁)保证同一行的写操作串行。

具体案例:用户账户余额为 100 元,两笔 500 元转账同时到达。行锁让两笔事务排队执行:第一笔读到 100、写入 600,第二笔读到 600、写入 1100。若没有行锁,两笔都从 100 开始算,结果只有 600。

分布式训练中的梯度聚合

阶段:模型训练 / 参数更新

解决的问题:多卡训练时,每个 GPU 算出梯度,汇合到参数服务器更新模型权重。若多个更新线程同时 weight += gradient,会发生竞态导致训练不收敛。现代框架用原子加(atomicAdd)或锁分段来降低冲突。

具体案例:PyTorch DDP 在 all-reduce 阶段把梯度放进桶(bucket)中,使用融合通信减少小消息次数,配合原子操作更新权重。锁冲突大时,还可以用梯度累积分桶减少加锁频率。

秒杀系统的分布式锁

阶段:系统架构 / 高并发控制

解决的问题:单机互斥锁只能管住本进程内的线程,管不住多个应用实例。需要分布式锁:用 Redis SETNX 加过期时间实现,保证同一时刻只有一个实例能扣减库存,防止超卖。

具体案例:某电商秒杀 100 件商品,流量打到 10 个应用实例上。每个实例先抢 Redis 分布式锁,抢到者才允许扣减数据库库存,扣完释放锁。锁要设置合理过期时间,并配合 Redlock 或 etcd 方案处理宕机风险。

Python 演示

python39 行
import threading counter = 0lock = threading.Lock()N = 1000T = 4  def increment_locked():    global counter    for _ in range(N):        with lock:            counter += 1  def increment_unlocked():    global counter    for _ in range(N):        counter += 1  def run(fn, label):    global counter    counter = 0    threads = [threading.Thread(target=fn) for _ in range(T)]    for t in threads:        t.start()    for t in threads:        t.join()    print(label, counter, "expected", T * N)  def main():    run(increment_unlocked, "no-lock:")    run(increment_locked, "locked: ")  if __name__ == "__main__":    main()
目的:模拟四个厨师(线程)同时往烤箱里放蛋糕(累加计数器),对比“无锁”与“加锁”两种情况下最终计数是否准确。在 CPython 中多次运行 no-lock 行往往会出现少于 4000 的结果,直观展示竞态条件。
  1. counter = 0共享变量,就像那块所有人都要用的切菜台,是竞态发生的现场。
  2. with lock:获取互斥锁(拿牌子),离开缩进自动释放(挂回牌子),是保护临界区的关键一行。
  3. counter += 1一行代码在字节码层面是“读→加→写”三步,正是竞态窗口所在。
  4. threading.Thread(target=fn)创建厨师线程,每个线程独立执行 fn 函数。
  5. t.start()` / `t.join()前者让厨师开工,后者等厨师干完活再收工,保证主线程最后打印的是全部线程完成后的结果。

常见误区

误区我总担心数据出错,所以把所有共享变量都加上锁,一个方法加三把锁,这样肯定最安全。

正解锁是“安全与性能的跷跷板”。过度加锁会让本可并行的代码退化成串行,吞吐量暴跌,还可能引入新的死锁风险。正确做法是“尽量减小临界区,只在真正需要的地方加锁”——就像厨房里只给烤箱挂牌子,而不是给每把刀都上锁。

为何常见:学习时接触的都是“不加锁会出错”的警告,容易形成“锁越多越稳”的直觉,忽略了并发系统的另一面——性能。

误区自旋锁不切换线程、不睡不醒,开销肯定最小,所以处处用自旋锁准没错。

正解自旋锁等待时不释放 CPU,会一直空转烧核。如果临界区较长或锁竞争激烈,多个线程疯狂空转,CPU 被白白烧掉,反而比互斥锁的上下文切换慢一个量级。正确用法是:临界区极短、多核机器上才优先考虑自旋锁。

为何常见:程序员被“避免线程切换”这一原则吸引,却忽略了自旋空转同样是巨大开销。

误区我先判空再加锁判空,双重检查,肯定能安全地实现单例。

正解在 Java 等语言里,双检锁依赖 volatile 保证可见性和有序性。没有 volatile 时,另一个线程可能看到一个“构造到一半”的对象——对象引用非空但内部字段还未初始化。这就是为什么 instance 必须声明为 volatile。其他语言也要找对应的内存屏障原语。

为何常见:双检锁的代码看起来天衣无缝,但“可见性”问题藏在 CPU 缓存和指令重排里,肉眼看不见。

误区信号量初值设为 1 就是互斥锁,两者可以随意替换。

正解互斥锁有所有权概念:同一线程加锁就必须同一线程解锁,且支持锁重入。二值信号量没有所有权限制,任何线程都能执行 V 操作释放它,也不允许重入——若一个线程连续 P 两次,会把自己锁死。两者语义不同,不能混用。

为何常见:从“能否互斥”这个表面行为看,二者确实相似;但深入理解所有权和重入规则后就会发现差异很大。

误区我只要小心加锁顺序,就不会有死锁了。

正解死锁的根源是“资源争夺 + 循环等待”。除锁外,数据库连接池、线程池、I/O 资源、事务等都可能造成死锁。比如线程 A 占用一个数据库连接等另一个连接,线程 B 反之,同样死锁。锁只是最常见的资源,不是唯一的资源。

为何常见:教材和文章都在讲“锁与死锁”,让人把死锁和锁强绑定;实际上任何“线程持有资源继续等资源”的模式都可能死锁。

面试题

Q1. 下列关于临界区的说法,正确的是?

单选题 · easy

  • A.临界区代码越短,并发度一定越低
  • B.临界区是访问共享资源、必须互斥保护的代码段← 正确答案
  • C.多个线程可以同时进入同一个临界区
  • D.临界区内只能写一行代码

答案:B。临界区是访问共享变量的代码段,必须保证同一时刻只有一个线程进入。选项 B 正确。A 错在临界区越短往往并发度越高;C 恰恰相反,多个线程同时进入就会产生竞态;D 把临界区的长度限制为一行,没有依据。就像烤箱前的台面,一次只能站一位厨师。

Q2. 以下哪个是同步原语?

单选题 · easy

  • A.哈希表
  • B.数组
  • C.信号量← 正确答案
  • D.缓存

答案:C。信号量是经典同步原语,用来控制多个线程对有限资源的访问。哈希表、数组是数据结构,缓存是性能优化手段,都不是同步原语。厨房里的“余位牌”就是信号量的化身。

Q3. 自旋锁等待锁时,线程处于什么状态?

单选题 · mid

  • A.阻塞睡眠
  • B.进入等待队列
  • C.忙等待(空转检查)← 正确答案
  • D.直接放弃执行

答案:C。自旋锁的特点就是“等不到就转圈”,线程在原地循环检查锁是否释放,这叫忙等待(忙等)。它不睡眠、不切换上下文,所以临界区极短时效率高。选项 A、B 是互斥锁的行为;D 描述的是干脆放弃执行,也不对。

Q4. 信号量初始值为 3,三个线程各执行一次 P 操作后,信号量的值是多少?

单选题 · mid

  • A.0← 正确答案
  • B.1
  • C.-1
  • D.3

答案:A。P 操作每次让信号量减一,初始 3,三个线程各 P 一次就是 3 − 3 = 0。值为 0 表示资源恰好用完、没有等待者。这就像 3 个灶台各被一位厨师占用,第 4 位厨师再来 P,值才会变成 −1 并阻塞等待。

Q5. 条件变量的 wait 操作在阻塞前必须做什么?

单选题 · mid

  • A.获取互斥锁
  • B.释放互斥锁← 正确答案
  • C.销毁互斥锁
  • D.唤醒其他线程

答案:B。wait 的核心语义是“释放锁 + 睡眠”,必须先释放互斥锁,否则其他线程无法进入临界区改变条件,就会出现死等——厨师睡前去休息室之前,必须把牌子挂回去。选项 A 是进入临界区的操作,C 和 D 都与 wait 的语义无关。

Q6. 死锁的四个必要条件包括哪些?

多选题 · mid

  • A.互斥条件← 正确答案
  • B.持有并等待← 正确答案
  • C.多核 CPU
  • D.循环等待← 正确答案

答案:ABD。死锁需要同时满足:互斥(资源一次一人用)、持有并等待(拿一个等另一个)、不可剥夺(不能强抢)、循环等待(形成等待环)。多核 CPU 只是让并发更容易发生,并不是死锁的必要条件。四者缺一不可,打破任意一个就能解除死锁。

Q7. CAS 操作存在 ABA 问题,是指什么?

单选题 · mid

  • A.比较和交换永远失败
  • B.值从 A 变成 B 又变回 A,CAS 无法发现中间变化← 正确答案
  • C.原子操作速度太慢
  • D.内存地址被操作系统交换

答案:B。ABA 问题指内存值从 A 被改为 B、又被改回 A,CAS 比较时发现还是 A,就误以为“没有被修改过”,从而错误地继续执行。它像一个厨师把炒锅借给同事、同事用完还回来,过程虽然发生过,但只看“锅在不在”是察觉不到的。解决思路是加版本号。

Q8. 写者优先的读写锁中,当写者到达等待时,新到达的读者会怎样?

单选题 · hard

  • A.立即进入临界区
  • B.被阻塞等待← 正确答案
  • C.与写者并发执行
  • D.抢占写者的锁

答案:B。写者优先策略规定:一旦有写者在等待,新到达的读者将被阻塞,直到写者完成。这是为了避免“读者源源不断来,写者永远排不上”的饥饿问题。选项 A 是读者优先的行为;C 违反读写互斥;D 更不可能,锁不能抢占。

Q9. 下列哪些策略可以避免死锁?

多选题 · mid

  • A.所有线程按固定顺序加锁← 正确答案
  • B.使用 tryLock 超时回退← 正确答案
  • C.增加 CPU 核心数
  • D.一次申请全部所需资源← 正确答案

答案:ABD。按固定顺序加锁破坏了循环等待条件;tryLock 超时回退让线程拿不到锁就释放已持有的锁,打破“持有并等待”;一次申请全部资源同样破坏“持有并等待”。增加 CPU 核心数不会改变死锁条件,反而可能加剧竞争。

Q10. 阿姆达尔定律中,串行比例 p = 0.5,并行度 N = 8,整体加速比约为多少?

单选题 · hard

  • A.1.33
  • B.1.78← 正确答案
  • C.4.00
  • D.8.00

答案:B。代入公式 S = 1 / ((1 − p) + p/N) = 1 / (0.5 + 0.5/8) = 1 / 0.5625 ≈ 1.78。即使并行部分加速 8 倍,串行的一半时间纹丝不动,整体只能提升不到 2 倍。这就像厨房里一半流程必须一个人完成,再请多少帮厨也快不起来。