凌晨五点的餐厅后厨,是整座城市最紧张的地方。十位厨师共享一个烤箱、六个灶台和一口大炒锅。如果两位厨师同时把蛋糕塞进同一个烤箱,或者同时伸手去拿同一把炒勺,轻则菜品全毁,重则厨房瘫痪。于是后厨立了一套规矩:烤箱前挂一块“使用权牌”,谁拿到牌子谁烤;灶台数量有限,进门先报数,满了就在外头等;谁要用炒锅,必须等上一位厨师大喊“用完啦”才准进来。这套规矩,就是计算机世界里鼎鼎大名的“互斥与同步原语”。 现代程序动辄开几十个线程同时干活,就像一支几十人的厨师队伍。多个线程同时读写同一个内存变量,就像多个厨师抢同一个烤箱——如果不加约束,程序的输出就会像被搅乱的蛋糕糊一样不可预测。互斥(Mutual Exclusion)保证同一时刻只有一个线程进入“禁区”;同步(Synchronization)则协调线程的执行节奏,让它们像训练有素的厨师一样排队、交接、配合。本文将从临界区、锁机制、信号量、条件变量、读写锁、死锁、性能调优到原子操作,用一间厨房的烟火气,帮你把操作系统的并发核心彻底看明白。
这篇文章面向所有 AI 从业者:无论你训练大模型、写推理服务还是调数据库,并发都是绕不开的“后厨”。掌握互斥与同步原语,你能读懂框架源码里的锁、诊断线上死锁、设计高吞吐的并发系统——这是从“会调 API”走向“懂系统”的关键一跃。
可视化演示
展示自旋锁与互斥锁、信号量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把共享变量 x 从内存读到 CPU 寄存器——厨师先瞄一眼单子上写了多少道菜。
- Add 1在寄存器里做加 1 运算——厨师在心里把数量加一。
- Store x把结果写回内存——厨师把新数量誊写到单子上。
互斥锁机制
上节说到的“牌子”,就是互斥锁(Mutex)。但牌子也分性格。自旋锁(Spinlock)像守在门口干等的厨师——进不去就在门口来回踱步,CPU 被白白耗着。互斥锁则像先去备菜、等喊号再回来的厨师——进不去就挂起线程,把 CPU 让给别的任务。两者各有适用场景:临界区极短、CPU 核多,自旋更划算;临界区长、并发量大,互斥锁更省资源。
现代语言里到处是这块牌子:Python 的 threading.Lock、Java 的 synchronized 和 ReentrantLock、Go 的 sync.Mutex。选锁时还要考虑公平性:先到的厨师先拿到牌子,还是可能被后来的插队?公平锁能避免“饿死”,但会牺牲一点吞吐。
- S=0锁处于空闲状态,任何线程都可尝试获取——牌子安安静静挂在挂钩上。
- S=1锁已被某线程持有,其他线程只能等待——牌子被厨师 A 攥在手里。
- 获取锁用一条不可打断的原子指令完成“检查并占用”——厨师看到牌子在就一把拿走,这个动作不容旁人插手。
信号量机制
烤箱只有一个要守门,灶台却有六个。如果给每个灶台单独挂锁,厨师们来回跑腿累得够呛。不如在门口挂一块“余位牌”:写着剩余可用灶台数,每次进去一个人数字就减一,出来再加一。这个“余位牌”就是信号量(Semaphore),由计算机科学家 Dijkstra 提出,配套两个原语:P(Proberen,荷兰语“测试”)和 V(Verhogen,“增加”)。
信号量的计数值为 1 时退化为二值信号量,但它和互斥锁有个微妙差异:互斥锁讲究“谁持有谁释放”,信号量却允许别的线程代为 V 操作。这既是灵活性,也是容易踩坑的地方。
- P(S) 减一申请一个资源,信号量减一,表示余位少了一个。
- S<0 则阻塞减完发现是负数,说明已经没有多余资源,当前线程阻塞——只能站门口等有人腾出灶台。
- V(S) 加一释放一个资源,信号量加一——厨师干完活,把灶台让出来。
- S≤0 则唤醒加完发现仍有负值,说明有人排队,唤醒其中一个——把腾出的灶台交给排最前的厨师。
条件变量
厨师把蛋糕放进烤箱后,总不能搬把椅子守在烤箱前干瞪眼。后厨的做法是:烤箱装上铃铛,烤好了“叮”一声。厨师可以去休息室眯一会儿,听到铃声再起来干活。这个“铃铛”就是条件变量(Condition Variable)。它必须配合互斥锁使用:线程发现条件不满足时,释放锁并进入休眠;条件满足时,别的线程发信号唤醒它。
实际编程中要警惕虚假唤醒(Spurious Wakeup):被唤醒不一定代表条件真的成熟了。所以要用 while 循环重新检查条件,就像厨师被喊醒后先揉揉眼确认——哎,是隔壁的人在喊自己的亲戚,那接着睡。
- 释放 mutex进入等待前必须先释放互斥锁,让别的线程有机会改变条件——厨师睡前去把牌子挂回挂钩。
- 阻塞等待 cond线程进入条件变量的等待队列,CPU 让出——厨师在休息室躺下。
- signal 唤醒条件满足时,一个线程发出信号,把等待者从队列移到就绪队列——铃声响起,厨师睁开眼睛。
读写锁
餐厅里,菜单是公开的,所有客人同时看都没问题(读);但改菜单的主厨只有一个(写)。如果每次修改菜单都用一把互斥锁把整本菜单锁死,想看菜单的客人也得排队,那生意就没法做了。读写锁正是为此设计:读锁可以多线程同时持有,写锁必须独占。核心规则是:读读并发、读写互斥、写写互斥。
读写锁有两种调度策略:读者优先下,只要一直有读者来,写者可能被活活饿死;写者优先则让新读者在写者到达后暂停入场,避免写者饥饿。多数数据库系统采用写者优先或“先到先得”的公平策略。
- n_r ≥ 0读者数量没有上限,多个读者可同时进入——菜单可以同时递给十位客人看。
- n_w ∈ {0,1}写者要么没有、要么只有一个——动笔的只能是一位主厨。
- n_r × n_w = 0读与写不能同时发生——有人看菜单时,主厨不许涂改。
死锁与饥饿
两位厨师,A 握着炒锅要等 B 手里的锅盖,B 握着锅盖要等 A 手里的炒锅。谁也不肯先松手,后厨彻底僵住——这就是死锁(Deadlock)。死锁的发生需要四个条件同时满足:互斥(资源一次只能一人用)、持有并等待(拿着资源还要等别的)、不可剥夺(不能强行抢)、循环等待(A 等 B、B 等 A 形成环)。
应对死锁有三种思路:预防(破坏四个条件之一,比如规定所有厨师必须按“先锅后盖”的顺序拿工具)、避免(银行家算法,动态判断这次分配是否安全)、检测与恢复(用工具检测等待环,强制打断)。此外还有饥饿:不是死锁,但某些线程一直抢不到资源,像总排不到灶台的学徒——没被卡死,却活不下去。
- M1 互斥资源不能同时共享——炒锅一次只能一人用。
- M2 持有并等待厨师拿着炒锅不撒手,还等着要锅盖。
- M3 不可剥夺不能从别人手里强行抢走资源——锅里还有菜,谁也不能硬夺。
- M4 循环等待A 等 B、B 等 A,形成闭合等待环。
锁性能调优
餐厅客流越大,锁的粒度问题越突出。粗粒度锁:整个厨房只挂一块牌子,谁进去谁全包,实现简单但效率极低——一个人炒菜,全员围观。细粒度锁:每个台面独立挂牌,不同厨师可同时在各区域干活,并发度高但管理复杂——每个牌子都要防抢、防错、防死锁。
调优手段还有:分段锁(把一个大烤箱拆成多个小烤箱,如 ConcurrentHashMap 的分段设计)、读写锁替换互斥锁、无锁数据结构(用 CAS 实现)。评估指标看两个:吞吐量(单位时间出多少菜)和延迟(一道菜从下单到上桌多久)。
- p串行比例:程序中只能串行执行的比例,锁保护的临界区是其中的大头——必须一个人独占的烤箱时间占比。
- N并行度:同时能干活的人数——后厨的厨师数量(CPU 核心或线程数)。
- 整体加速比并行部分最多缩短到 1/N,串行部分纹丝不动——烤箱只有一台,再多厨师也快不起来。
内存与原子
切菜最怕切到一半接电话。有没有一种“一刀到底”的操作?CAS(Compare-And-Swap,比较并交换)就是硬件提供的一条原子指令:先比较内存中的值是否等于期望值,如果等于,就换成新值。整个操作只花一条指令的时间,谁都打断不了。
CAS 有著名的 ABA 问题:值从 A 变成 B 又变回 A,CAS 会误以为“没人动过”。解法是带上版本号(如 Java 的 AtomicStampedReference)。还有内存模型问题:各 CPU 核心有本地高速缓存,线程 A 修改了变量,线程 B 的缓存里可能还是旧值。原子变量和内存屏障(Memory Barrier)就是强制“把誊写结果同步给大家看”的规矩。
- 比较 *A 与 E检查内存当前值是否等于期望值——先确认牌子确实挂在挂钩上。
- 更新为 N相等则写入新值——确认无误后一把拿走牌子。
- 返回旧值返回操作前的值,调用方据此判断 CAS 是否成功——牌子拿没拿到,看一眼返回值就知道。
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 演示
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()
no-lock 行往往会出现少于 4000 的结果,直观展示竞态条件。counter = 0共享变量,就像那块所有人都要用的切菜台,是竞态发生的现场。with lock:获取互斥锁(拿牌子),离开缩进自动释放(挂回牌子),是保护临界区的关键一行。counter += 1一行代码在字节码层面是“读→加→写”三步,正是竞态窗口所在。threading.Thread(target=fn)创建厨师线程,每个线程独立执行fn函数。t.start()` / `t.join()前者让厨师开工,后者等厨师干完活再收工,保证主线程最后打印的是全部线程完成后的结果。
常见误区
误区我总担心数据出错,所以把所有共享变量都加上锁,一个方法加三把锁,这样肯定最安全。
正解锁是“安全与性能的跷跷板”。过度加锁会让本可并行的代码退化成串行,吞吐量暴跌,还可能引入新的死锁风险。正确做法是“尽量减小临界区,只在真正需要的地方加锁”——就像厨房里只给烤箱挂牌子,而不是给每把刀都上锁。
为何常见:学习时接触的都是“不加锁会出错”的警告,容易形成“锁越多越稳”的直觉,忽略了并发系统的另一面——性能。
误区自旋锁不切换线程、不睡不醒,开销肯定最小,所以处处用自旋锁准没错。
正解自旋锁等待时不释放 CPU,会一直空转烧核。如果临界区较长或锁竞争激烈,多个线程疯狂空转,CPU 被白白烧掉,反而比互斥锁的上下文切换慢一个量级。正确用法是:临界区极短、多核机器上才优先考虑自旋锁。
为何常见:程序员被“避免线程切换”这一原则吸引,却忽略了自旋空转同样是巨大开销。
误区我先判空再加锁判空,双重检查,肯定能安全地实现单例。
正解在 Java 等语言里,双检锁依赖 volatile 保证可见性和有序性。没有 volatile 时,另一个线程可能看到一个“构造到一半”的对象——对象引用非空但内部字段还未初始化。这就是为什么 instance 必须声明为 volatile。其他语言也要找对应的内存屏障原语。
为何常见:双检锁的代码看起来天衣无缝,但“可见性”问题藏在 CPU 缓存和指令重排里,肉眼看不见。
误区信号量初值设为 1 就是互斥锁,两者可以随意替换。
正解互斥锁有所有权概念:同一线程加锁就必须同一线程解锁,且支持锁重入。二值信号量没有所有权限制,任何线程都能执行 V 操作释放它,也不允许重入——若一个线程连续 P 两次,会把自己锁死。两者语义不同,不能混用。
为何常见:从“能否互斥”这个表面行为看,二者确实相似;但深入理解所有权和重入规则后就会发现差异很大。
误区我只要小心加锁顺序,就不会有死锁了。
正解死锁的根源是“资源争夺 + 循环等待”。除锁外,数据库连接池、线程池、I/O 资源、事务等都可能造成死锁。比如线程 A 占用一个数据库连接等另一个连接,线程 B 反之,同样死锁。锁只是最常见的资源,不是唯一的资源。
为何常见:教材和文章都在讲“锁与死锁”,让人把死锁和锁强绑定;实际上任何“线程持有资源继续等资源”的模式都可能死锁。
面试题
Q1. 下列关于临界区的说法,正确的是?
- A.临界区代码越短,并发度一定越低
- B.临界区是访问共享资源、必须互斥保护的代码段← 正确答案
- C.多个线程可以同时进入同一个临界区
- D.临界区内只能写一行代码
答案:B。临界区是访问共享变量的代码段,必须保证同一时刻只有一个线程进入。选项 B 正确。A 错在临界区越短往往并发度越高;C 恰恰相反,多个线程同时进入就会产生竞态;D 把临界区的长度限制为一行,没有依据。就像烤箱前的台面,一次只能站一位厨师。
Q2. 以下哪个是同步原语?
- A.哈希表
- B.数组
- C.信号量← 正确答案
- D.缓存
答案:C。信号量是经典同步原语,用来控制多个线程对有限资源的访问。哈希表、数组是数据结构,缓存是性能优化手段,都不是同步原语。厨房里的“余位牌”就是信号量的化身。
Q3. 自旋锁等待锁时,线程处于什么状态?
- A.阻塞睡眠
- B.进入等待队列
- C.忙等待(空转检查)← 正确答案
- D.直接放弃执行
答案:C。自旋锁的特点就是“等不到就转圈”,线程在原地循环检查锁是否释放,这叫忙等待(忙等)。它不睡眠、不切换上下文,所以临界区极短时效率高。选项 A、B 是互斥锁的行为;D 描述的是干脆放弃执行,也不对。
Q4. 信号量初始值为 3,三个线程各执行一次 P 操作后,信号量的值是多少?
- A.0← 正确答案
- B.1
- C.-1
- D.3
答案:A。P 操作每次让信号量减一,初始 3,三个线程各 P 一次就是 3 − 3 = 0。值为 0 表示资源恰好用完、没有等待者。这就像 3 个灶台各被一位厨师占用,第 4 位厨师再来 P,值才会变成 −1 并阻塞等待。
Q5. 条件变量的 wait 操作在阻塞前必须做什么?
- A.获取互斥锁
- B.释放互斥锁← 正确答案
- C.销毁互斥锁
- D.唤醒其他线程
答案:B。wait 的核心语义是“释放锁 + 睡眠”,必须先释放互斥锁,否则其他线程无法进入临界区改变条件,就会出现死等——厨师睡前去休息室之前,必须把牌子挂回去。选项 A 是进入临界区的操作,C 和 D 都与 wait 的语义无关。
Q6. 死锁的四个必要条件包括哪些?
- A.互斥条件← 正确答案
- B.持有并等待← 正确答案
- C.多核 CPU
- D.循环等待← 正确答案
答案:ABD。死锁需要同时满足:互斥(资源一次一人用)、持有并等待(拿一个等另一个)、不可剥夺(不能强抢)、循环等待(形成等待环)。多核 CPU 只是让并发更容易发生,并不是死锁的必要条件。四者缺一不可,打破任意一个就能解除死锁。
Q7. CAS 操作存在 ABA 问题,是指什么?
- A.比较和交换永远失败
- B.值从 A 变成 B 又变回 A,CAS 无法发现中间变化← 正确答案
- C.原子操作速度太慢
- D.内存地址被操作系统交换
答案:B。ABA 问题指内存值从 A 被改为 B、又被改回 A,CAS 比较时发现还是 A,就误以为“没有被修改过”,从而错误地继续执行。它像一个厨师把炒锅借给同事、同事用完还回来,过程虽然发生过,但只看“锅在不在”是察觉不到的。解决思路是加版本号。
Q8. 写者优先的读写锁中,当写者到达等待时,新到达的读者会怎样?
- A.立即进入临界区
- B.被阻塞等待← 正确答案
- C.与写者并发执行
- D.抢占写者的锁
答案:B。写者优先策略规定:一旦有写者在等待,新到达的读者将被阻塞,直到写者完成。这是为了避免“读者源源不断来,写者永远排不上”的饥饿问题。选项 A 是读者优先的行为;C 违反读写互斥;D 更不可能,锁不能抢占。
Q9. 下列哪些策略可以避免死锁?
- A.所有线程按固定顺序加锁← 正确答案
- B.使用 tryLock 超时回退← 正确答案
- C.增加 CPU 核心数
- D.一次申请全部所需资源← 正确答案
答案:ABD。按固定顺序加锁破坏了循环等待条件;tryLock 超时回退让线程拿不到锁就释放已持有的锁,打破“持有并等待”;一次申请全部资源同样破坏“持有并等待”。增加 CPU 核心数不会改变死锁条件,反而可能加剧竞争。
Q10. 阿姆达尔定律中,串行比例 p = 0.5,并行度 N = 8,整体加速比约为多少?
- 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 倍。这就像厨房里一半流程必须一个人完成,再请多少帮厨也快不起来。