Appearance
死锁:四个必要条件与处理策略
考频 ⭐⭐⭐⭐⭐。并发 / 系统 / 后端岗高频。 主线:死锁是什么 → 四个必要条件 → 预防/避免/检测/解除 → 代码上怎么治 → 和锁选型的关系。 一句话:死锁不是「锁用错了」,而是多个线程互相持有对方需要的资源、又都不肯放手形成的等待环。
一、死锁的定义与成因
Q1. 什么是死锁?产生的原因是什么?
答案
死锁(Deadlock):一组进程(线程)中的每一个都在等待一个只能由组内其他进程释放的资源,导致这组进程永远无法推进。
产生需要两个前提:
- 资源竞争:多个进程竞争不可剥夺的资源(互斥锁、文件、设备、信号量、连接池等)。
- 加锁顺序不当:进程获取资源的顺序不一致,形成循环等待。这是代码层最常见的原因。
典型场景:线程 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. 死锁预防怎么做?
答案
预防 = 破坏四个必要条件之一(最简单粗暴,但可能牺牲并发度或资源利用率):
- 破坏互斥:资源尽量可共享(如读共享、无锁结构 CAS、只读快照)。对互斥锁本身往往做不到。
- 破坏占有并等待:一次性申请所有资源(要么全拿到要么一个不拿),或先释放、再申请。
- 破坏不可剥夺:申请不到就释放已持有的资源、稍后重试(超时 + 回滚)。
- 破坏循环等待:资源全局排序,所有线程都按同一顺序加锁——工程上最常用、最有效。
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。定位思路不同,但都靠观测 + 栈/火焰图。