死锁要凑齐四个条件才发生,为什么你堵住一个还是卡死

死锁不是"锁用多了"这么模糊的东西,Coffman 等人在 1971 年把它拆成四个必须同时成立的条件:互斥、占有并等待、不可剥夺、循环等待。这四条是必要条件而非充分条件——全部满足也可能侥幸跑通,缺一条则绝不可能死锁,这才是它真正的用法:当排查清单,逐条反推哪一条最容易破坏。最常见的三个失分点是把四条件当判定算法、把循环等待直接等同于死锁、以为"按顺序加锁"在所有路径上都成立。读完你能用资源分配图判断单实例与多实例的差别,分清死锁、活锁与饥饿,并知道应用层该优先破坏哪一条。

是什么

死锁是指一组执行单元(进程、线程、协程、事务)各自持有对方还需要的一份资源,并且在拿到之前都不肯放手,于是一圈人都停在原地,没有外力介入就永远推进不下去。一句话直觉:四个人围坐一桌,每人手里正好握着下一位要的那把钥匙,谁都不肯先松手。

最常见的现场不是教科书里的打印机和磁带机,而是这些:两个接口分别以相反顺序更新同一对账户行,数据库里互相等对方的行锁;一段回调里先拿 A 锁再调外部服务,外部服务的回调又来拿 B 锁;线程池里的任务向同一个池提交子任务并等待结果,池里的线程全被父任务占满;两个微服务各自持有本地锁去调对方的接口。

操作系统教材把它形式化成四个条件,最早由 Coffman、Elphick 和 Shoshani 在 1971 年总结,所以常叫 Coffman 条件。四个条件必须同时成立才会出现死锁:互斥(资源一次只能被一个持有者独占)、占有并等待(拿着已有的,同时申请新的,且不释放已有的)、不可剥夺(持有的资源不能被强行收回)、循环等待(等待关系闭合成环)。

核心原理

四个条件各自在挡什么

互斥说的是资源本身的性质:一块写内存、一行数据库记录、一把互斥锁,同一时刻只服务一个人。如果资源天生可共享——只读的配置文件、常量表、多副本的缓存——这条就不成立,也就谈不上死锁。这也是为什么读锁与多版本快照能天然削弱死锁:它们把"独占"换成了"各看各的"。

占有并等待是行为层面的条件。线程拿着锁 A 去申请锁 B,中途不释放 A。注意它要求"持有"和"申请"两件事同时成立;如果线程在申请 B 之前先把 A 放掉,这条就断了。

不可剥夺是缺乏强制手段。操作系统一般不会为了解开死锁而强行从线程手里抢走锁,因为被抢的那一方可能已经把共享数据改到一半,抢走等于破坏一致性。所以现实中很少有系统靠"抢占"来解死锁。

循环等待是结构层面的条件,也是唯一只需看"谁等谁"就能验证的一条:T1 等 T2 持有的资源,T2 等 T3,……,Tn 又等 T1。它把前面三个条件串成了一个闭环。

关键的一步:必要不等于充分

这四条是必要条件,不是判定算法。四条全中,系统也可能安然无恙——因为环上某个线程可能马上就会释放资源,等待关系只是瞬时的。反过来,只要缺一条,死锁就一定不会发生,这个方向才是确定无疑的。所以四条件的正确用法是排除法的清单:出现卡死时,逐条问"这一条在我们这里是真成立,还是只是看起来成立",找到那个能被改掉的条件。

不同资源模型下,判断"是否真的成环即死锁"的精度也不一样。单实例资源用资源分配图:方框是资源、圆点是实例、箭头是持有与申请,图里有环就等价于死锁。多实例资源(比如三个数据库连接、两台打印机)里,有环只是必要条件,得靠更重的检测算法按可用实例数推算,或者干脆用银行家算法在分配前判断"这一笔给出去之后系统是否还处在安全状态"。

下面这段可直接运行,复现的是最经典的交叉加锁:

import threading, time

a, b = threading.Lock(), threading.Lock()

def worker(first, second):
    with first:
        time.sleep(0.05)          # 放大交错窗口,删掉它也不代表安全
        with second:              # 这里永远等不到
            print("done")

t1 = threading.Thread(target=worker, args=(a, b))
t2 = threading.Thread(target=worker, args=(b, a))
t1.start(); t2.start(); t1.join(); t2.join()

四个条件在这一段里全部成立:lock 是互斥的;with 里嵌套申请,是占有并等待;Python 的锁不可被抢;两个线程的等待方向相反,成环。把 args=(b, a) 改成 (a, b),循环等待被破坏,程序立刻跑完——这就是"破坏一个条件"最直接的演示。

四条路,成本天差地别

按破坏哪个条件来分,处理策略实际上是四种工程取舍:破坏互斥,用无锁结构、不可变数据、读写分离或单线程串行化;破坏占有并等待,要求一次性申请全部资源,或者申请前先释放已持有的;破坏不可剥夺,用带超时的 trylock,拿不到就全部释放、退避重试;破坏循环等待,给所有锁定一个全局全序,永远按小到大的顺序获取。工业界压倒性地偏爱后两条,因为前两条要么要求资源本身可改造,要么要求提前知道全部需求——后者在分层调用里几乎做不到。

常见考点与误区

  • 把四条件当成"判定":面试常问"满足这四个条件就一定死锁吗",答"是"就直接失分。四条件是必要条件,全中只说明"有风险",能判定的只有单实例资源分配图上的环。
  • 把循环等待等同于死锁:单实例资源图里有环才等价于死锁;多实例资源里,一个环上如果有别的实例可用,等待方照样能拿到资源。另外"等待图有环"和"线程被永久阻塞"之间还隔着超时与外部干预。
  • 以为"全局按顺序加锁"就万事大吉:只在所有代码路径都遵守同一顺序时成立。回调、事件监听、锁升级(先拿读锁再想升级为写锁)、ORM 自动生成的更新语句,都会绕开你写下的顺序。锁序是全局约定,不是局部技巧。
  • 忽略数据库死锁是被"检测并回滚"而不是被"预防"的:InnoDB 会构建等待图,发现环就回滚代价较小的事务(报 1213),所以线上表现为偶发的"事务莫名失败"。唯一索引冲突、间隙锁、二级索引与主键索引的加锁顺序都可能让实际锁序不可控,靠改一行 SQL 的书写顺序并不能保证。
  • 分不清死锁、活锁与饥饿:活锁在不停地重试、CPU 有占用但业务不前进,典型是 trylock 失败就原样重来;饥饿是某个线程长期抢不到锁,但系统整体在推进。三者现象相似,处置完全不同——活锁要加随机退避,饥饿要查公平性和优先级。
  • 在应用层把"抢占"当成免费午餐:trylock 超时后释放重来,本质是用回滚模拟剥夺,但它要求临界区里的副作用可撤销。如果已经发出了消息、写了日志、扣了库存,回滚就成了新的 bug 源。

实践建议

  • 把锁序写进代码,而不是记在人脑里。给每把锁标一个层级编号,规定只能从低层往高层加锁;Java 里可以用带层级的锁封装或静态检查插件,Go 里至少把顺序写进注释并在 code review 时当硬规矩查。
  • 持锁期间不要调用外部、不要发回调、不要阻塞等待。跨服务调用、读磁盘、等线程池结果都该移到锁外:先把需要的数据在锁内快照出来,出锁再执行。这一条能同时削弱"占有并等待"和"临界区过长"两个问题。
  • 给等待设出口。所有跨模块的锁都用带超时的版本,超时后按统一顺序释放并退避重试(退避要带随机量,否则几个线程会同步重试,退化成活锁)。代价是临界区必须可重入、可重试。
  • 把死锁做成可观测事件,而不是靠用户投诉。JVM 里定期调 ThreadMXBean.findDeadlockedThreads() 并上报;Go 里 pprof 的 goroutine profile 加 GODEBUG 或 SIGQUIT 抓全量栈;MySQL 直接看 SHOW ENGINE INNODB STATUS 里最近一次死锁的加锁明细,它会告诉你两个事务各自持有什么、在等什么。
  • 压测要跑"反向路径"。死锁只在特定交错下出现,单接口压测几乎测不出来。构造一对互相回调的接口、让两个任务竞争同一组资源的正反顺序,把并发打满,才是发现死锁概率的有效方式。
  • 能取消等待就不必解死锁。消息队列单分区串行、Actor 模型单线程处理、按 key 分片把冲突降到单机内,都是从结构上消灭"两个持有者争同一对资源"的前提,往往比事后调锁序更省心。

一句话总结:四个条件是排除法清单——死锁要四条全中,你只需确保常规路径上至少有一条永远不成立。

相关阅读

← 返回文章志