死锁深度解析:从四个必要条件到银行家算法
从经典问题到银行家算法:死锁的预防、避免、检测与解除
引言
你正在食堂吃饭,左手抓着筷子,右手去拿酱油瓶。旁边的人也在吃饭,他左手拿着酱油瓶,右手想来抓你的筷子。你们两个都等着对方先松手,但谁也不肯先放——饭凉了,人还饿着。这就是死锁。
操作系统中,死锁(Deadlock)指两个或以上进程因互相等待对方持有的资源而永久阻塞的现象。如果没有外力干预,这些进程将永远卡在原地。这是我们操作系统第二章"进程与线程"的第四篇,也是收官篇。前三篇我们讨论了进程基础、调度算法与同步互斥——今天要解决的问题是:当进程间的资源竞争陷入僵局,操作系统该如何应对?
📌 核心要点
- 死锁的四个必要条件缺一不可:互斥、不剥夺、请求保持、循环等待——破坏任一条件即可防止死锁
- 三种处理策略各有取舍:预防简单粗暴但资源利用率低,避免(银行家算法)折中但需预知需求,检测解除追求利用率但恢复代价高
- 银行家算法的核心操作只有两步:假设分配 → 检查是否存在安全序列。手动推演是408必考技能
- 死锁、饥饿、死循环是三个完全不同的概念——这个选择题考点区分度极高,务必精确记忆
- 哲学家进餐问题是死锁的经典模型,四种解法恰好对应预防死锁的四种思路
死锁的概念
什么是死锁?
死锁(Deadlock) 是指多个进程因竞争资源而造成的一种僵局——每个进程都在等待其他进程释放资源,但没有一个进程能主动释放自己已占有的资源,结果大家都无法向前推进。
三个关键点定义了死锁:至少两个进程参与;每个进程已占有某些资源,同时在等待其他资源;等待关系构成了闭环。三者缺一,就不构成死锁。
死锁一旦形成,仅靠进程自身无法打破。这也是它和"等一等就能过去"的临时阻塞的本质区别——死锁是结构性的、永久的阻塞。
为什么会发生死锁?
死锁产生的原因有三个:
- 对不可剥夺资源的竞争:多个进程竞争打印机、摄像头等独占性资源,且资源不可被强行剥夺
- 进程推进顺序非法:进程申请和释放资源的时机不当,导致循环等待链形成
- 信号量使用不当:P/V操作顺序错误会直接制造死锁——比如生产者-消费者问题中,把
P(mutex)和P(empty)顺序写反
[UNIQUE INSIGHT] 很多同学以为只有"竞争不可剥夺资源"才会导致死锁。实际上,信号量使用不当造成的死锁在考研大题中出现频率非常高——这属于"逻辑死锁"而非"物理死锁"。本质是:不管资源是物理设备还是信号量,只要满足四个必要条件,死锁就会发生。
死锁的四个必要条件
这四个条件必须同时成立才会产生死锁。换句话说,只要有一个不满足,死锁就不会发生:
| 序号 | 条件 | 含义 | 能否破坏? |
|---|---|---|---|
| 1 | 互斥条件 | 资源每次只能被一个进程使用 | 不太可行(很多资源天生互斥) |
| 2 | 不剥夺条件 | 进程获得的资源在未使用完前不能被抢走 | 可以,但代价大 |
| 3 | 请求和保持条件 | 进程已持有资源,又去申请新资源(被阻塞但旧资源不释放) | 可以破坏 |
| 4 | 循环等待条件 | 存在一个"进程-资源"的闭环等待链 | 可以破坏 |
⚠️ 考点辨析:发生死锁一定存在循环等待;但存在循环等待未必发生死锁。如果同类资源有多个实例(比如有2台打印机),循环等待链可能被"多出来"的资源打破。408真题多次利用这个细节出迷惑选项。
下面用一个资源分配图直观展示:
[IMAGE] 资源分配图:进程P1持有资源R1、等待R2;进程P2持有资源R2、等待R1。P1→R2→P2→R1→P1 形成闭环,两个进程死锁。
┌─────── 等待 ───────┐
▼ │
┌───┐ ┌───┐
│ P1 │ │ P2 │
└─┬─┘ └─┬─┘
│ 持有 │ 持有
▼ ▼
┌───┐ ┌───┐
│ R1 │ │ R2 │
└───┘ └───┘
死锁 vs 饥饿 vs 死循环(408高频考点)
这三个概念容易被混用,但在408考试中是明确定义的不同术语:
| 对比维度 | 死锁(Deadlock) | 饥饿(Starvation) | 死循环(Infinite Loop) |
|---|---|---|---|
| 涉及进程数 | 至少两个进程互相等待 | 可能是一个进程长期得不到资源 | 可能是一个进程 |
| 进程状态 | 阻塞态 | 阻塞态或就绪态均可 | 运行态(占着CPU不撒手) |
| 本质原因 | 操作系统资源分配策略或进程推进顺序有问题 | 操作系统调度策略不公(如SPF长进程饿死) | 程序员的代码逻辑bug |
| 责任归属 | 操作系统(管理者)的问题 | 操作系统的问题 | 应用程序开发者(被管理者)的问题 |
| 解决方案 | 预防/避免/检测解除 | FCFS等公平调度策略 | 程序员debug |
| 能否自行解开 | 不能——无外力介入永远阻塞 | 可能——如果短进程都结束 | 不能——除非外部中断 |
💡 记忆技巧:死锁是"互相等"(至少两人,都在阻塞态),饥饿是"一直等"(受害者一个人),死循环是"自己转"(运行态,代码写错了)。408选择题中经常混着考——看到"某个进程运行态一直执行"直接排除死锁和饥饿的选项。
死锁的处理策略总览
面对死锁问题,操作系统有三种态度,对应三类策略:
[CHART] 死锁三种处理策略对比图
| 策略 | 思路 | 时机 | 资源利用率 | 实现复杂度 | 典型算法 |
|---|---|---|---|---|---|
| 预防(Prevention) | 破坏四个必要条件之一 | 死锁发生前 | 较低 | 低 | 一次性分配、顺序资源分配 |
| 避免(Avoidance) | 判断分配是否导致不安全状态 | 死锁发生前 | 中等 | 中 | 银行家算法 |
| 检测与解除(Detection & Recovery) | 允许死锁发生,检测到后解除 | 死锁发生后 | 高 | 高 | 资源分配图化简、进程终止 |
📌 死锁的预防和避免都属于事先预防策略,但"避免"并不等同于"预防"——408考试中需严格区分为三种策略。
[INTERNAL-LINK] 如果对进程调度策略(如SPF)不够熟悉,建议回顾本系列第二篇《调度算法:从FCFS到多级反馈队列》。
死锁的预防:破坏四个必要条件
死锁预防(Deadlock Prevention)的思路直截了当:不让四个必要条件同时满足。破坏其中任意一个,死锁就不会发生。
破坏互斥条件
思路:让资源可以被同时访问,而不需要互斥使用。
可行性分析:不太可行。打印机、摄像头、写文件等资源本身就是互斥的——让两个进程同时写入同一块磁盘扇区会造成数据损坏。只有少数资源(如只读文件)可以共享。SPOOLing技术可以将独占设备改造成共享设备(如打印机假脱机),但这属于设备管理的范畴,不能推广到所有资源。
破坏不剥夺条件
思路:当进程申请的资源被占用时,操作系统可以强行夺走它已持有的资源。
具体做法:进程在申请新资源失败时,必须释放手中已有的全部资源,再重新申请。
缺点:代价较大。一个进程写文件写到一半,被剥夺了文件访问权——之前写的内容怎么办?回滚?保存断点?实现复杂且增加系统开销。只适用于处理器和内存这类"状态可保存恢复"的资源。
破坏请求和保持条件
思路:进程在开始运行前,一次性申请所有需要的资源。
具体做法(静态分配法):进程运行前一次性申请全部资源——要么全部拿到,要么一个都不拿。拿不到就等待,等待期间不占任何资源。
优点:简单、安全。
缺点:资源利用率极低——进程可能很晚才用到某个资源,但一开局就占着不放,其他进程只能干等。此外,很多进程在运行前并不知道自己需要哪些资源的全部。
另一种实现:进程申请新资源被阻塞时,先释放手中已有的全部资源。这实际上是在请求和保持条件上打了补丁。
⚠️ 这两种方法的核心区别:静态分配法强调"一开始就全拿",释放重申请法强调"拿不到就全放"。
破坏循环等待条件
思路:给系统中所有资源编号,规定进程只能按编号递增顺序申请资源。
具体做法(顺序资源分配法):假设资源有类型1、2、3、4、5。进程先申请资源2,之后只能申请编号大于2的资源(3、4、5),不能再回头申请资源1。这从根本上杜绝了循环等待链——循环等待链要求"等待关系可以首尾相连",而递增规则让箭头只能朝一个方向走。
优点:相比前几种方法,对资源利用率的折损最小。
缺点:给资源合理编号并不容易。如果进程真的需要先大后小的资源呢?只能被迫改变资源申请策略。新增资源时编号体系可能需要重新调整。
四种预防方案小结
| 破坏的条件 | 方法 | 实用程度 |
|---|---|---|
| 互斥 | SPOOLing(有限的场景) | 低 |
| 不可剥夺 | 强行剥夺已占有资源 | 低(代价大) |
| 请求和保持 | 静态分配(一次性申请) | 中 |
| 循环等待 | 顺序资源分配(按递增编号) | 中 |
[PERSONAL EXPERIENCE] 实际工程中,死锁预防用得并不多——因为资源利用率损失太大。现代操作系统更倾向于"允许死锁,但不让它扩散"的策略(如Linux对内核锁的lockdep检测机制)。但对于考研,四种方法必须完整掌握。
死锁的避免:银行家算法
死锁避免(Deadlock Avoidance)比预防更聪明——它不是一刀切地破坏条件,而是在每次资源分配前做安全判断。如果这次分配会导致系统进入"不安全状态",就拒绝本次分配。
安全状态与安全序列
安全状态:系统能按照某个顺序(安全序列)将资源全部分配完毕,所有进程都能顺利完成。处于安全状态的系统,一定不会发生死锁。
不安全状态:找不到这样的顺序。注意——不安全状态不等于死锁!只能说"有可能"发生死锁。就像闯红灯未必出车祸,但风险急剧上升。
安全序列的存在性:如果存在一个序列 P1, P2, …, Pn,使得对每个 Pi,Pi 当前还需要的资源量(Need)≤ 系统当前可用的资源量(Available)+ 排在 Pi 前面的进程已持有的资源量(因为前面的进程完成后会释放资源)——那么这个序列就是安全序列。
⚠️ 408高频考点:安全状态一定无死锁,不安全状态不一定死锁。这一条被翻来覆去地考。
银行家算法原理
**银行家算法(Banker’s Algorithm)**由 Dijkstra 在1965年提出,名字的由来是一个形象的比喻:操作系统就像银行家,进程就像客户。银行家手里有一笔现金(系统资源),客户会申请贷款(资源请求)。银行家放款前需要判断——把钱借给这个客户后,剩下的钱还够不够满足所有其他客户的最大贷款额度,从而保证所有客户最终都能还钱。
算法需要维护四个数据结构:
| 数据结构 | 含义 | 维度 |
|---|---|---|
| Available 向量 | 系统当前每种资源的可用数量 | m(资源类型数) |
| Max 矩阵 | 每个进程对每种资源的最大需求 | n × m(n为进程数) |
| Allocation 矩阵 | 每个进程当前已分配的资源量 | n × m |
| Need 矩阵 | 每个进程还需要的资源量 | n × m |
满足恒等式:Need[i][j] = Max[i][j] - Allocation[i][j]
算法核心逻辑:
当进程 Pi 请求一组资源 Request[i] 时:
1. 如果 Request[i] > Need[i] → 拒绝(申请超过了声明的最大需求)
2. 如果 Request[i] > Available → 让 Pi 等待(资源不够用)
3. 假设分配(试探性):
Available = Available - Request[i]
Allocation[i] = Allocation[i] + Request[i]
Need[i] = Need[i] - Request[i]
4. 对假设分配后的状态执行安全性检查
如果存在安全序列 → 批准分配
否则 → 拒绝分配,恢复原状态
安全性检查算法步骤
1. 初始化 Work = Available,Finish[i] = false(对所有 i)
2. 从进程集合中找一个满足以下条件的 Pi:
Finish[i] == false 且 Need[i] ≤ Work
3. 如果找到:
Work = Work + Allocation[i] (模拟 Pi 完成后释放所有资源)
Finish[i] = true
回到步骤2
4. 如果所有 Finish[i] 都为 true → 系统处于安全状态
否则 → 系统处于不安全状态
完整推演示例
下面用一个完整例子演示银行家算法的手动计算过程。这是408考研大题的标准步骤。
初始状态:假设系统中有5个进程 P0~P4,3类资源 A、B、C。已知:
Max 矩阵(每个进程对每类资源的最大需求):
| 进程 | A | B | C |
|---|---|---|---|
| P0 | 7 | 5 | 3 |
| P1 | 3 | 2 | 2 |
| P2 | 9 | 0 | 2 |
| P3 | 2 | 2 | 2 |
| P4 | 4 | 3 | 3 |
Allocation 矩阵(已分配):
| 进程 | A | B | C |
|---|---|---|---|
| P0 | 0 | 1 | 0 |
| P1 | 2 | 0 | 0 |
| P2 | 3 | 0 | 2 |
| P3 | 2 | 1 | 1 |
| P4 | 0 | 0 | 2 |
Available 向量:A=3, B=3, C=2
第一步:计算 Need 矩阵
Need = Max - Allocation:
| 进程 | A | B | C |
|---|---|---|---|
| P0 | 7 | 4 | 3 |
| P1 | 1 | 2 | 2 |
| P2 | 6 | 0 | 0 |
| P3 | 0 | 1 | 1 |
| P4 | 4 | 3 | 1 |
第二步:寻找初始安全序列
Work = Available = (3, 3, 2),Finish 全部为 false。
| 轮次 | 可选进程 | 选中 | Work变化 | Finish | 理由 |
|---|---|---|---|---|---|
| 1 | P1(1,2,2)≤(3,3,2) ✓ P3(0,1,1)≤(3,3,2) ✓ |
P1 | (3,3,2)+(2,0,0)=(5,3,2) | P1=true | P1的Need小且Work够 |
| 2 | P3(0,1,1)≤(5,3,2) ✓ P4(4,3,1)≤(5,3,2) ✓ |
P3 | (5,3,2)+(2,1,1)=(7,4,3) | P3=true | 选P3因为它的Need最小 |
| 3 | P0(7,4,3)≤(7,4,3) ✓ P2(6,0,0)≤(7,4,3) ✓ P4(4,3,1)≤(7,4,3) ✓ |
P4 | (7,4,3)+(0,0,2)=(7,4,5) | P4=true | 选三者均可,先选P4 |
| 4 | P0(7,4,3)≤(7,4,5) ✓ P2(6,0,0)≤(7,4,5) ✓ |
P2 | (7,4,5)+(3,0,2)=(10,4,7) | P2=true | P2只剩Need A |
| 5 | P0(7,4,3)≤(10,4,7) ✓ | P0 | Done | 全部true | P0最后完成 |
安全序列:P1 → P3 → P4 → P2 → P0(不唯一,P3→P1→P4→P2→P0 同样可行)
初始状态是安全的。
第三步:处理资源请求
现在 P1 申请资源 Request = (1, 0, 2)。判断能否批准:
- Request(1,0,2) ≤ Need1 ✓
- Request(1,0,2) ≤ Available(3,3,2) ✓
- 假设分配——更新各表:
| Available | P1-Alloc | P1-Need | |
|---|---|---|---|
| 分配前 | (3,3,2) | (2,0,0) | (1,2,2) |
| 分配后 | (2,3,0) | (3,0,2) | (0,2,0) |
- 安全性检查——寻找安全序列:
Work = (2, 3, 0)。寻找 Need ≤ Work 的进程:
- P1: (0,2,0) ≤ (2,3,0) ✓ → Work = (2,3,0)+(3,0,2)=(5,3,2),P1完成
- P3: (0,1,1) ≤ (5,3,2) ✓ → Work = (5,3,2)+(2,1,1)=(7,4,3),P3完成
- P4: (4,3,1) ≤ (7,4,3) ✓ → Work = (7,4,3)+(0,0,2)=(7,4,5),P4完成
- P2: (6,0,0) ≤ (7,4,5) ✓ → Work = (7,4,5)+(3,0,2)=(10,4,7),P2完成
- P0: (7,4,3) ≤ (10,4,7) ✓ → 全部完成
存在安全序列 P1 → P3 → P4 → P2 → P0,批准分配。
再测试 P4 申请 Request = (3, 3, 0)(注意现在状态已经变化):
- Request(3,3,0) ≤ Need4 ✓
- Request(3,3,0) ≤ Available(2,3,0)?——B只有3个,申请需要3个,刚好够。但A只有2个,不够3个 → ❌ 资源不足,拒绝(让P4等待)
再测试 P0 申请 Request = (0, 2, 0):
-
Request(0,2,0) ≤ Need0 ✓
-
Request(0,2,0) ≤ Available(2,3,0) ✓
-
假设分配:Available=(2,1,0),P0-Alloc=(0,3,0),P0-Need=(7,2,3)
-
安全性检查:Work=(2,1,0),检查每个进程Need:
- P1: Need(0,2,0) ≤ (2,1,0)?B=2 > Work的1 → ❌
- P3: Need(0,1,1) ≤ (2,1,0)?C=1 > Work的0 → ❌
- 没有任何进程的Need ≤ Work → 找不到安全序列
拒绝分配。即使当时资源数量够,但分配后会让系统进入不安全状态。
📌 这是银行家算法考生最容易犯错的地方:资源数量够 ≠ 可以分配。分配后系统是否仍处于安全状态,才是判断依据。
银行家算法的工程局限性
[PERSONAL EXPERIENCE] 银行家算法在实际操作系统中几乎没有被完整实现过。原因有三:(1) 进程需要在运行前声明最大资源需求——多数程序做不到;(2) 进程数量和资源数量动态变化,矩阵维护开销大;(3) 安全性检查算法复杂度 O(n²m),在大规模系统中不可接受。但它在理论上的价值极高——它是"避免死锁"这一策略最优雅的形式化表达,也是408考试大题的核心考点。
死锁的检测与解除
有些系统采取"先不管,出了问题再说"的策略——允许死锁发生,然后检测它、解除它。
死锁检测:资源分配图化简
资源分配图由两类节点和两类边组成:
- 进程节点(圆圈):代表一个进程
- 资源节点(方框):代表一类资源,框内的圆点代表该资源的实例数
- 分配边(资源→进程):资源已分配给该进程
- 请求边(进程→资源):进程在等待该资源
化简算法(死锁定理):反复执行以下操作——找到一个既不阻塞也不孤立的进程节点(即该进程所有请求的资源都能被满足),删除该节点及其所有关联边(模拟该进程获得资源、执行完毕、释放所有资源)。如果最终所有节点都能被消除,则图是可完全简化的,系统没有死锁;否则存在死锁。
示例推演:
[IMAGE] 资源分配图逐步化简示意:原始图→消除进程P3→消除进程P2→消除进程P1→全图清空,可完全简化,无死锁。
初始状态:
P1 ─────→ R1 (有1个实例,已分给P2)
│ ↑
│ │
└────→ R2 (有2个实例,已分给P1和P2各1个)
↑
│
P2 ─────→│
│
P3 ───→ R2(请求)
用文字描述化简过程:
| 步骤 | 可用资源 | 找到的进程 | 动作 |
|---|---|---|---|
| 初始 | R1=0, R2=0 | — | — |
| 1 | R1=0, R2=0 | P3(P3请求R2但无资源可用) | ❌ 跳过——P3被阻塞 |
| — | — | P2(P2不请求,只占有R1和R2) | ✅ 消除P2,释放R1(1个)+R2(1个) |
| 2 | R1=1, R2=1 | P1(P1请求R1和R2) | ✅ 消除P1,释放P1占有的R2(1个) |
| 3 | R1=1, R2=2 | P3(P3请求R2) | ✅ 消除P3 |
所有节点消除 → 可完全简化 → 无死锁。
如果 P1 占有的不是 R2 而是 R1,那情况就不一样了——P2和P1互相等待,P3也被牵连,谁也释放不了资源。
检测时机
- 定时检测:每隔固定时间间隔执行一次检测算法
- 按需检测:当CPU利用率异常下降、大量进程长时间阻塞时触发
死锁解除
一旦检测到死锁,必须用外力打破僵局。三种方法按破坏性递增排列:
| 方法 | 操作 | 代价 | 适用场景 |
|---|---|---|---|
| 资源剥夺法 | 从部分进程中抢占资源,分配给死锁进程 | 中 | 资源状态可保存恢复时 |
| 撤销进程法 | 终止部分或全部死锁进程 | 高 | 无法单独剥夺资源时 |
| 进程回退法 | 让进程回退到足够消除死锁的检查点 | 中高 | 需要系统支持检查点机制 |
选择"牺牲品"时通常考虑:进程优先级(低优先的先牺牲)、已运行时间(短的可少浪费)、已使用的资源量(占用资源多的优先牺牲,一次释放足够多资源)。
经典问题:哲学家进餐
哲学家进餐问题(Dining Philosophers Problem)由 Dijkstra 在1965年提出,是死锁问题的标准模型。五位哲学家围坐圆桌,每人面前一盘意面。每两位哲学家之间放一根筷子,共5根。哲学家只有拿到左右两根筷子才能吃饭,吃完后放下筷子。
为什么会死锁?
五位哲学家同时感到饥饿,都拿起了左手边的筷子,然后都去拿右手边的筷子——但每根右边的筷子都已经被右边的哲学家拿走了(对那个人而言是左手边的)。5个人每个人手里握着一根,等着另一根,循环等待形成——死锁。
四种解法对应四种预防思路
| 解法 | 破坏的条件 | 具体做法 |
|---|---|---|
| 最多4人同时进餐 | 循环等待 | 限制同时申请资源的进程数(设置上限为n-1) |
| 奇数先左后右,偶数先右后左 | 循环等待 | 打破对称性,让等待链不能首尾闭合 |
| 同时拿两根 | 请求和保持 | 要么两根一起拿,要么一根都不拿(加互斥信号量保护取筷子操作) |
| 限制一根先 | 互斥(变相) | 实际上是通过限制并发度来减缓解除的复杂度 |
[UNIQUE INSIGHT] 四种解法中,“同时拿两根"在概念上最接近预防死锁中的"静态分配法”——要么全拿要么全不放。"奇数先左后右、偶数先右后左"最巧妙,它用极低的代价(只需一个if判断奇偶)就打破了循环等待。这提醒我们:在并发编程中,打破对称性是避免死锁最经济的手段之一。
生产者-消费者中的死锁
将生产者-消费者问题的P操作顺序写错,会直接制造死锁:
/* 错误写法——会导致死锁 */
void producer() {
while (1) {
P(mutex); // 先锁住缓冲区
P(empty); // 再等空位——如果没有空位,阻塞在这里
// 但mutex还没释放!消费者永远无法进入缓冲区取数据
// ... 生产数据
V(mutex);
V(full);
}
}
为什么死锁:当缓冲区满了(empty=0),生产者先拿到mutex再去P(empty),因为empty=0而阻塞。消费者想进入缓冲区必须先P(mutex),但mutex被生产者占着——双方互相等待,死锁。正确的顺序是先P(empty)再P(mutex)。
[INTERNAL-LINK] 关于信号量和PV操作的详细讲解,参见本系列第三篇《进程同步与互斥:从信号量到管程》。
代码示例:死锁复现与银行家算法模拟
死锁复现(C语言/Pthreads)
下面用两线程竞争两把锁来复现最简单的死锁场景:
#include <stdio.h>
#include <pthread.h>
#include <unistd.h>
pthread_mutex_t lock1 = PTHREAD_MUTEX_INITIALIZER;
pthread_mutex_t lock2 = PTHREAD_MUTEX_INITIALIZER;
void *thread_a(void *arg) {
pthread_mutex_lock(&lock1);
printf("Thread A: got lock1\n");
sleep(1); // 留时间让Thread B拿到lock2
pthread_mutex_lock(&lock2); // 等待lock2——但已被Thread B持有
printf("Thread A: got lock2 (never reached)\n");
pthread_mutex_unlock(&lock2);
pthread_mutex_unlock(&lock1);
return NULL;
}
void *thread_b(void *arg) {
pthread_mutex_lock(&lock2);
printf("Thread B: got lock2\n");
sleep(1); // 留时间让Thread A拿到lock1
pthread_mutex_lock(&lock1); // 等待lock1——但已被Thread A持有
printf("Thread B: got lock1 (never reached)\n");
pthread_mutex_unlock(&lock1);
pthread_mutex_unlock(&lock2);
return NULL;
}
int main() {
pthread_t ta, tb;
pthread_create(&ta, NULL, thread_a, NULL);
pthread_create(&tb, NULL, thread_b, NULL);
pthread_join(ta, NULL); // 永远不会返回
pthread_join(tb, NULL);
printf("Done\n");
return 0;
}
运行预期:程序输出两行 “got lock1” 和 “got lock2” 后永久卡死,pthread_join 永不返回。这就是最简化的死锁——两个线程、两把锁、相反的获取顺序。
银行家算法C语言简化实现
#include <stdio.h>
#include <stdbool.h>
#define N 5 // 进程数
#define M 3 // 资源类型数
int available[M] = {3, 3, 2};
int max[N][M] = {
{7, 5, 3}, {3, 2, 2}, {9, 0, 2}, {2, 2, 2}, {4, 3, 3}
};
int allocation[N][M] = {
{0, 1, 0}, {2, 0, 0}, {3, 0, 2}, {2, 1, 1}, {0, 0, 2}
};
int need[N][M];
// 计算Need矩阵 = Max - Allocation
void calculate_need() {
for (int i = 0; i < N; i++)
for (int j = 0; j < M; j++)
need[i][j] = max[i][j] - allocation[i][j];
}
// 安全性检查:存在安全序列返回true,否则false
bool is_safe() {
int work[M];
bool finish[N] = {false};
for (int j = 0; j < M; j++) work[j] = available[j];
int count = 0;
int safe_seq[N]; // 记录安全序列
while (count < N) {
bool found = false;
for (int i = 0; i < N; i++) {
if (finish[i]) continue;
bool can_allocate = true;
for (int j = 0; j < M; j++) {
if (need[i][j] > work[j]) {
can_allocate = false;
break;
}
}
if (can_allocate) {
// 模拟Pi完成,释放资源
for (int j = 0; j < M; j++)
work[j] += allocation[i][j];
finish[i] = true;
safe_seq[count++] = i;
found = true;
}
}
if (!found) return false; // 找不到可完成的进程
}
printf("安全序列: ");
for (int i = 0; i < N; i++) printf("P%d ", safe_seq[i]);
printf("\n");
return true;
}
// 处理进程pid的资源请求req[M],成功返回true
bool request_resources(int pid, int req[M]) {
// 验证请求合法性
for (int j = 0; j < M; j++) {
if (req[j] > need[pid][j]) {
printf("错误:请求超过声明的最大需求\n");
return false;
}
if (req[j] > available[j]) {
printf("资源不足,P%d等待\n", pid);
return false;
}
}
// 试探性分配
for (int j = 0; j < M; j++) {
available[j] -= req[j];
allocation[pid][j] += req[j];
need[pid][j] -= req[j];
}
// 安全性检查
if (is_safe()) {
printf("批准P%d的资源请求\n", pid);
return true;
} else {
// 不安全——回滚
for (int j = 0; j < M; j++) {
available[j] += req[j];
allocation[pid][j] -= req[j];
need[pid][j] += req[j];
}
printf("拒绝P%d的资源请求(会导致不安全状态)\n", pid);
return false;
}
}
int main() {
calculate_need();
printf("=== 初始安全状态检查 ===\n");
is_safe();
printf("\n=== P1请求(1,0,2) ===\n");
int req1[M] = {1, 0, 2};
request_resources(1, req1);
printf("\n=== P4请求(3,3,0) ===\n");
int req4[M] = {3, 3, 0};
request_resources(4, req4);
printf("\n=== P0请求(0,2,0) ===\n");
int req0[M] = {0, 2, 0};
request_resources(0, req0);
return 0;
}
运行结果:P1(1,0,2)批准,P4(3,3,0)因资源不足拒绝,P0(0,2,0)因会导致不安全状态拒绝——与我们手动推演一致。
常见疑问
银行家算法的安全性检查中,如果同时有多个进程满足条件,选哪个?
选哪个都可以——安全序列不是唯一的。通常选择Need最小的进程优先(贪心策略),能更快释放资源。对于408考试,只要写出任意一条安全序列就行。建议按进程编号从小到大依次检查,这样操作规范且不易遗漏。
循环等待一定死锁吗?
不一定。如果同类资源有多个实例,循环等待链可能被"空闲的那个实例"打破。例如R1有两个实例——P1占一个等R2,P2占一个等R3,P3占R3等R1。还剩一个R1空闲,P3可以直接拿到——链就断了。死锁一定是循环等待,循环等待不一定是死锁。
死锁避免和死锁预防的核心区别是什么?
预防是不管当前资源够不够,直接在规则层面让四个必要条件无法同时成立——比如顺序资源分配法永远禁止你申请编号更小的资源。避免则是在每次分配前做一次安全判断,分配本身在规则上是被允许的,只有当它会导致不安全状态时才被拒绝。打个比方:预防是"禁止逆行",避免是"允许超车,但要先看对向来车"。
三种死锁处理策略实际用在什么系统?
[PERSONAL EXPERIENCE] 预防几乎没有被现代通用操作系统全面采用(代价太大),但在嵌入式实时系统中常见(一次性分配,保证确定性)。避免(银行家算法)主要在教学中出现,工程中极少有系统完整实现——但有近似思想的应用(如数据库事务管理中的死锁检测与回滚)。检测与解除在数据库系统(MySQL InnoDB的死锁检测)和Linux内核(lockdep死锁检测框架)中大量使用——这些系统允许部分死锁发生,但通过定期检测和受害者回滚来保证整体可用性。
考研大题中银行家算法考到什么程度?
408统考真题中,银行家算法以计算题形式出现。给定Max/Allocation/Available数据,要求:(1) 计算Need矩阵;(2) 找出一个安全序列;(3) 判断某次资源请求能否被批准。步骤必须完整,每一步的Work变化要写清楚。建议在草稿纸上用表格跟踪Work、Finish的变化,避免遗漏。
小结
死锁管理的三种策略构成了一条从"保守"到"激进"的光谱:
- 预防最保守——在规则层面消灭死锁的可能性,代价是资源利用率低
- 避免折中——每次分资源前做一次安全预判,银行家算法是其最优美的表达
- 检测与解除最激进——允许死锁发生,出事了再收拾,资源利用率最高但恢复代价也最大
408考试中,死锁四大必要条件和三策略对比是选择题高频考点,银行家算法的大题几乎每年都有变体。哲学家进餐问题则是死锁理论最浓缩的模型,四种解法恰好映射四种预防思路。
延伸阅读
- [INTERNAL-LINK] 《进程的基本概念与状态转换》—— 本系列第1篇,回顾进程状态(阻塞态、就绪态与死锁的关系)
- [INTERNAL-LINK] 《调度算法:从FCFS到多级反馈队列》—— 本系列第2篇,理解饥饿与调度策略的关系
- [INTERNAL-LINK] 《进程同步与互斥:从信号量到管程》—— 本系列第3篇,掌握PV操作与死锁的关联
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)