Skip to content

死锁:四个必要条件与处理策略 ​

考频 ⭐⭐⭐⭐⭐。并发 / 系统 / 后端岗高频。 主线:死锁是什么 → 四个必要条件 → 预防/避免/检测/解除 → 代码上怎么治 → 和锁选型的关系。 一句话:死锁不是「锁用错了」,而是多个线程互相持有对方需要的资源、又都不肯放手形成的等待环。

一、死锁的定义与成因 ​

Q1. 什么是死锁?产生的原因是什么? ​

答案

死锁(Deadlock):一组进程(线程)中的每一个都在等待一个只能由组内其他进程释放的资源,导致这组进程永远无法推进。

产生需要两个前提:

  1. 资源竞争:多个进程竞争不可剥夺的资源(互斥锁、文件、设备、信号量、连接池等)。
  2. 加锁顺序不当:进程获取资源的顺序不一致,形成循环等待。这是代码层最常见的原因。

典型场景:线程 A 先锁 m1 再锁 m2,线程 B 先锁 m2 再锁 m1。

text
T1: lock(m1) ---- 想 lock(m2)  <-- 被 T2 占用
T2: lock(m2) ---- 想 lock(m1)  <-- 被 T1 占用
         → 互相等待,永久阻塞

除死锁外,还有三种「活不了」的状态:活锁、饥饿、优先级反转(见 Q3)。

二、四个必要条件 ​

Q2. 死锁的四个必要条件分别是什么? ​

答案

四者同时成立才可能死锁,缺一不可;破坏任意一条即可预防死锁。

条件含义如何破坏
互斥资源同一时刻只能被一个进程占用尽量把资源变成可共享 / 无锁 / 只读副本
占有并等待(请求与保持)持有至少一个资源,同时还在等新资源一次性申请全部资源,或申请新资源前先释放已持有的
不可剥夺资源不能被强行夺走,只能自愿释放允许抢占:拿不到就释放已有、超时回滚重试
循环等待存在一个进程—资源的循环等待链给资源全局编号,按序申请(最常用)

记忆口诀:互斥、占有并等待、不可剥夺、循环等待。

Q3. 死锁 vs 活锁 vs 饥饿? ​

答案
现象状态表现
死锁阻塞,不消耗 CPU互相等待,谁都不动
活锁运行,消耗 CPU 但无进展都在谦让/重试,如两人走廊上互相让路都往同侧闪
饥饿就绪但长期得不到资源低优先级线程永远抢不到锁/CPU
优先级反转高优先级被低优先级间接阻塞低优先级持锁被中优先级抢占,高优先级干等(可用优先级继承解决)

活锁的解法:引入随机退避(backoff)、打破对称(按身份定顺序)。饥饿的解法:公平锁、老化(aging)、优先级队列。

三、处理策略 ​

死锁的处理分四类:预防、避免、检测与恢复、鸵鸟策略。

Q4. 死锁预防怎么做? ​

答案

预防 = 破坏四个必要条件之一(最简单粗暴,但可能牺牲并发度或资源利用率):

  1. 破坏互斥:资源尽量可共享(如读共享、无锁结构 CAS、只读快照)。对互斥锁本身往往做不到。
  2. 破坏占有并等待:一次性申请所有资源(要么全拿到要么一个不拿),或先释放、再申请。
  3. 破坏不可剥夺:申请不到就释放已持有的资源、稍后重试(超时 + 回滚)。
  4. 破坏循环等待:资源全局排序,所有线程都按同一顺序加锁——工程上最常用、最有效。
cpp
// 反例:加锁顺序不一致 → 可能死锁
void transferA() { std::lock_guard<std::mutex> a(m1); std::lock_guard<std::mutex> b(m2); }
void transferB() { std::lock_guard<std::mutex> b(m2); std::lock_guard<std::mutex> a(m1); }

Q5. 死锁避免:银行家算法是怎么回事? ​

答案

避免不改变必要条件,而是在分配资源前先判断这次分配会不会让系统进入「不安全状态」,会就拒绝。

  • 安全状态:存在一个安全序列,按此序列逐个分配资源,每个进程都能顺利拿到所需资源并最终释放,系统不会死锁。
  • 银行家算法:维护每个进程的 Max(最大需求)、Allocation(已分配)、Need = Max - Allocation,以及系统 Available。每次请求时试探性分配,然后跑安全性检查:
    • 找 Need ≤ Available 的进程 → 让它跑完并归还资源(Available += Allocation);
    • 反复找下去,若能让所有进程都完成 → 安全,允许分配;否则撤销试探、拒绝。
text
安全性检查:
  Work = Available
  找 Need[i] <= Work 的进程 i: Work += Allocation[i]  (标记完成)
  重复直到无进程可加入
  若全部完成 → 安全; 否则 → 不安全(拒绝本次分配)

评价:需要预知最大需求、开销大、实践中很少真正用于通用 OS/业务代码,但面试常考(考的是「安全状态/安全序列」的理解)。

Q6. 死锁检测与恢复? ​

答案

不预防也不避免,而是允许死锁发生,定期检测并恢复。

  • 资源分配图(Resource Allocation Graph):进程是圆形节点、资源是方形节点,申请边 P→R、分配边 R→P。
  • 死锁定理:若图中存在环(且每类资源只有一个实例时),则死锁;多实例时需用类似银行家算法的化简(能完全化简则无死锁)。
  • 检测频率:可定期跑,也可在「CPU 利用率低但线程都阻塞」时触发。
  • 恢复手段:
    • 抢占资源:从某些进程剥夺资源给别的进程(需能回滚)。
    • 回滚:把进程回退到某个**检查点(checkpoint)**重跑。
    • 杀进程(kill):最简单粗暴,按优先级/代价最小者杀,直到环消除。数据库死锁检测后就是回滚代价最小的事务。

MySQL InnoDB 有死锁检测器,发现死锁后回滚「undo 量最小」的那个事务并报 Deadlock found。

Q7. 鸵鸟策略是什么?为什么现实中也用它? ​

答案

鸵鸟策略:假装死锁不会发生,不理会。听起来很蠢,但大多数通用 OS(Linux、Windows)对死锁就是鸵鸟态度。

原因:死锁发生的概率极低(需要特定时序),而检测/避免的开销是持续性的;一旦真发生,靠重启/杀进程恢复即可。对比代价后,鸵鸟策略性价比反而高。

面试话术:OS 层是鸵鸟;应用层必须自己防——用固定加锁顺序、std::lock、超时等(见 Q8~Q10)。

四、代码实践:怎么在工程中防死锁 ​

Q8. 用两个互斥锁交叉加锁写一个死锁,再修复它。 ​

答案
cpp
#include <mutex>
#include <thread>

std::mutex m1, m2;
long balanceA = 100, balanceB = 100;

// ❌ 死锁版:两个线程加锁顺序相反
void bad_transfer_a_to_b(long amt) {
    std::lock_guard<std::mutex> a(m1);   // 先 m1
    std::lock_guard<std::mutex> b(m2);   // 再 m2
    balanceA -= amt; balanceB += amt;
}
void bad_transfer_b_to_a(long amt) {
    std::lock_guard<std::mutex> b(m2);   // 先 m2
    std::lock_guard<std::mutex> a(m1);   // 再 m1  → 与上面交叉,可能死锁
    balanceB -= amt; balanceA += amt;
}

修复方式(推荐第一种):

cpp
// ✅ 方式一:std::lock 一次性锁多个,内部用避免死锁的算法
void good_transfer(long amt, bool a_to_b) {
    std::lock(m1, m2);                                   // 原子地锁住两把
    std::lock_guard<std::mutex> lk1(m1, std::adopt_lock);
    std::lock_guard<std::mutex> lk2(m2, std::adopt_lock);
    if (a_to_b) { balanceA -= amt; balanceB += amt; }
    else        { balanceB -= amt; balanceA += amt; }
}

// ✅ 方式二:固定顺序(按 mutex 地址/全局编号决定先锁哪个)——破坏循环等待
void good_transfer2(long amt, bool a_to_b) {
    std::mutex& first  = (std::less<std::mutex*>{}(&m1, &m2)) ? m1 : m2;
    std::mutex& second = (&first == &m1) ? m2 : m1;
    std::lock_guard<std::mutex> l1(first);
    std::lock_guard<std::mutex> l2(second);
    // ... 业务
}

// ✅ 方式三:scoped_lock(C++17)自动处理多锁
// std::scoped_lock lk(m1, m2);

要点:std::lock / scoped_lock 内部使用「尝试加锁失败则释放重试」的策略来避免死锁;固定顺序是最通用、最省心的工程手段。

Q9. 悲观锁 vs 乐观锁? ​

答案
维度悲观锁乐观锁
假设冲突很常见冲突很少见
做法先加锁,独占访问(mutex、数据库 SELECT ... FOR UPDATE)不加锁,提交时校验是否被改过
实现互斥锁、读写锁CAS(比较并交换)、版本号(读版本→改→CAS 版本)
优点简单可靠,适合写多无锁/低开销,适合读多写少
缺点阻塞、上下文切换、可能死锁冲突高时重试多、ABA 问题
适用临界区短且冲突频繁并发读、偶尔写
cpp
// 乐观锁:版本号 + CAS
bool cas_update(std::atomic<int>& ver, int expected, T& data, const T& newval) {
    int e = expected;
    if (!ver.compare_exchange_strong(e, e + 1)) return false; // 被改过 → 重试
    data = newval;                                            // 版本落后,先读后改需注意
    return true;
}

ABA 问题:值从 A 变 B 又变回 A,CAS 看不出变化。可用带版本号的原子对(std::atomic<struct{ptr,version}>,或 128 位 CAS)解决。详见无锁专题。

Q10. 自旋锁 vs 互斥锁?和死锁什么关系? ​

答案
  • 自旋锁:拿不到就忙等(循环 CAS),不让出 CPU。适合临界区极短、多核的场景;单核上若持有者被调度走会干等(甚至死锁),所以内核里用它要关抢占。
  • 互斥锁:拿不到就睡眠,等持有者释放后由内核唤醒(Linux 用 futex)。适合临界区可能较长、切换成本可接受。
维度自旋锁互斥锁
等待方式忙等(烧 CPU)睡眠(让出 CPU)
切换开销无(但不干活)有(两次上下文切换)
适用极短临界区、多核一般业务、临界区可能长
风险单核死等、优先级反转阻塞/唤醒开销、可死锁

与死锁的关系:自旋锁在单核 / 不可抢占场景下可能出现**「持锁线程被调度走 → 其他线程无限自旋」**的活锁式死锁;这也是一种「循环等待」。详见 锁优化。

五、高频 Q&A ​

Q11. 四个必要条件背一下,以及最常见的破坏方式?

答案

互斥、占有并等待、不可剥夺、循环等待。工程上最常见的是破坏循环等待——按全局固定顺序加锁;或用 std::lock/scoped_lock 一次锁多个。

Q12. 只要按固定顺序加锁就一定不会死锁吗?

答案

同一组锁按同一顺序,基本可以;但要注意:间接顺序(A 调 B 在持锁时请求新锁)、锁与条件变量/信号量混用、动态创建锁都可能绕过顺序保证。原则:全局统一约定 + 尽量缩小临界区 + 避免在持锁时调用未知回调。

Q13. 条件变量等待时会不会死锁?

答案

condition_variable::wait 会原子地释放锁并阻塞,被唤醒后重新加锁,正常不会死锁。真正的坑是:丢失唤醒(通知早于等待)要用谓词 + while 防;notify 时持锁可能引起不必要的唤醒竞争。

Q14. 如何线上定位死锁?

答案
  • gdb:attach 后 thread apply all bt,看有多少线程卡在 pthread_mutex_lock/__lll_lock_wait;
  • pstack / eu-stack 打印所有线程栈;
  • ThreadSanitizer 运行期检测(能报潜在死锁顺序);
  • perf、/proc/<pid>/task/*/stack;
  • 数据库:MySQL SHOW ENGINE INNODB STATUS 看最近死锁;pt-deadlock-logger 持续采集。

Q15. 死锁和内存泄漏、活锁的排查有什么异同?

答案

死锁→线程栈卡在锁上、CPU 低;活锁→CPU 高但业务无进展(自旋/重试);内存泄漏→RSS 持续上升直至 OOM。定位思路不同,但都靠观测 + 栈/火焰图。

相关 ​

  • 同步与互斥:互斥锁、信号量、条件变量与经典问题。
  • 虚拟内存:资源分配与页面置换中的「死锁」风险。
  • 进程与线程:上下文切换、阻塞与唤醒。
  • /concurrency/deadlock-singleton.md:C++ 死锁与线程安全单例。
  • /concurrency/lock-optimize.md:自旋锁 vs 互斥锁、锁竞争与假共享。
  • /concurrency/lockfree.md:CAS / ABA / 无锁编程。
  • /concurrency/atomic-memoryorder.md:std::atomic 与内存序。

糌粑的面经笔记 · VitePress 版