ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

死锁的四个必要条件与Linux复现:从资源分配图到银行家算法

死锁的四个必要条件与Linux复现:从资源分配图到银行家算法 简介操作系统并发编程中死锁是资源分配与进程调度的经典难题。这份压缩包面向操作系统学习者与并发编程初学者系统整理哲学家问题、消费者生产者问题与管道问题三大典型死锁场景并给出相应的C解决方案。资源共3个文件均为cpp源码分别对应哲学家进餐、生产者消费者与管道读写三个独立实例压缩包仅2KB轻量易读便于直接编译运行与对照学习。已有284人浏览学习。通过阅读代码可理解哲学家如何通过取筷策略避免循环等待、生产者与消费者如何借助信号量协调缓冲区满空状态、多进程读写管道时如何防止阻塞死锁这些案例贴近操作系统课程核心考点既能辅助理论理解也为后续学习死锁预防与检测策略提供了可运行的参考实践。1. 典型死锁问题程序没崩却卡死在最不该卡的地方典型死锁问题指的是两个或两个以上任务一起卡在一个谁都无法推进的等待环里。你在 linux 操作系统的课设里八成撞过两个线程都打印完“已锁第一把锁”然后谁也不撒手程序不崩、不跑、CPU 占用归零日志停在那两行纹丝不动。这种“没崩但彻底死掉”的现场就是典型死锁问题最真实的面目也是操作系统原理课上讲得最细、考得最多的并发模型。它能帮你解决两类实际问题一是真正看懂系统为什么会冻结拿到现场能画得出资源分配图、说得清等链二是在写多线程代码时提前绕开这个坑顺带把操作系统期末复习和头歌操作系统实验平台上的死锁实验拿满。适合读这篇笔记的人很明确——正在准备考试的学生、刚接触并发编程的开发者、以及被死锁复现折磨得想砸电脑的实验党。下面按我自己的实验路径来从四个必要条件一路讲到可编译的 C 代码、银行家算法的实现再收在避坑和验证手段上。2. 死锁的底层逻辑资源分配图、四个必要条件与手工判断五步法2.1 从资源分配图看死锁节点、分配边和请求边怎么画学死锁先学画图。资源分配图是死锁分析的通用语言画法很固定圆圈代表进程方框代表资源类型方框里的小圆点是资源实例个数。一台打印机画一个点内存划分成三个分区就画三个点。从资源方框指向进程的边叫分配边表示“这个实例已经给了你”从进程指向资源方框的边叫请求边表示“我正在等你手里那个资源”。死锁分析的全部起点就是把这四类元素摆成一张有向图。我画图的习惯是先列表再连线。拿最常见的两进程死锁举例P1 持有 R1正在申请 R2P2 持有 R2正在申请 R1。列表出来是四条边R1→P1、P1→R2、R2→P2、P2→R1。按顺序连好之后P1、R2、P2、R1 正好首尾相接成一个环这就是死锁的图形信号。注意一个细节请求边和分配边的方向相反一个指向进程一个离开进程环的方向性很容易画乱实验报告里经常有人把箭头画反导致结论对不上。资源实例数也要画准。单实例资源和多实例资源的死锁判定逻辑不一样这是后面“有环不一定死锁”的关键伏笔。很多新手在纸上画图时只画方框不画实例点到了判断环节就开始含糊其实只要实例点数齐了判断就变成了“环上的资源是否被占满”这样一个单调问题。2.2 四个必要条件缺一不可死锁为什么一定要凑齐它们死锁要成立四个条件同时满足才行。互斥条件资源同一时刻只能被一个进程占用打印机不能同时打两份文档。持有并等待进程占着一个资源同时还在等另一个。不可剥夺已经分出去的资源系统不能强抢只能等持有者主动释放。循环等待多个进程的请求顺着一个方向构成闭合链。四个条件看着简单但少一个都不叫死锁只能叫阻塞或饥饿。我把死锁和饥饿对比一下考试和面试都喜欢在这里挖坑死锁是全体冻结谁都不能推进饥饿是某个进程一直分不到资源但系统整体还在跑。二者的成因不同解法也不同。死锁必须从资源分配结构上拆环饥饿则要靠调度策略保证公平。很多人在实验里把“一个线程等不到锁”误判成死锁实际上另一个线程正在正常执行只是排队排得久而已。四个条件对应的破坏手段做成表格复习时能省不少事必要条件典型含义破坏手段互斥资源同一时刻只能归一个进程多数资源无法破除只能换读写锁折中持有并等待占着一个资源又去等下一个一次性申请全部资源申请不到就释放已有不可剥夺资源只能由持有者主动释放申请失败时系统强抢回收代价高循环等待等待关系成环给资源编号所有进程按同一顺序申请预防、避免、检测、解除这四类策略也分别对应这张表的不同行。预防是在设计阶段打破某个条件避免是分配资源前先做安全性判断银行家算法就是代表检测是允许死锁发生、定期扫描等待图解除是检测到之后挑一个进程结束或回滚。操作系统里真正用检测和解除的场合比银行家算法多这一点教材讲得少但实验题爱考。2.3 判断死锁的手工五步法有环不一定死锁教科书上最容易被误读的一句话是“资源分配图里有环就是死锁”这个说法只在所有资源都是单实例时才成立。多实例资源存在时环里某个请求可能命中环外空闲实例从而解开等待链。经典反例是P1 持有 R1 申请 R2P2 持有 R2 申请 R1看上去是一个环可 R2 有两个实例P2 只占了一个另一个还闲着那么 P2 的申请立刻被满足环就化掉了。手工判断死锁我一般按五步走。第一步列进程清单每行标“持有资源”和“等待资源”。第二步画资源分配图实例点全部画全。第三步从任意等待进程出发沿着“等待某资源→该资源被谁持有→继续等那个进程持有的下一个资源”的方向走。第四步判断是否回到起点回不来就是普通阻塞。第五步回到起点说明有环再检查环上每个请求边指向的资源实例是否全部被环内进程占满全占满才是死锁否则只是暂时等待。这套流程不走捷径手工算大题时能少丢一半分。多资源死锁的例子在 linux 操作系统课程设计里很常见。两个进程各自持有一类缓冲区的使用权又互相等着对方释放另一类区的锁从 /proc 看两个进程的状态都是 D这时再用五步法走一遍结论就非常明确。要注意的是五步法只能判断当前状态判断不出“下一秒会不会死锁”所以它对应的是检测不是避免。3. 在 Linux 下复现典型死锁最小 C 程序与银行家算法实现3.1 用两把锁和两个线程复现死锁的最小 C 程序实验环境我习惯用 Ubuntu 或 CentOS编译器选 gcc线程库用 pthread。要复现的模型是循环等待线程 T1 先锁 mutexA 再拿 mutexB线程 T2 反着来先锁 mutexB 再拿 mutexA。只要两个线程的执行轨迹在“第一把锁已持有、第二把锁未拿到”处交叉现场就出现了。#include pthread.h #include stdio.h #include unistd.h pthread_mutex_t mutexA PTHREAD_MUTEX_INITIALIZER; pthread_mutex_t mutexB PTHREAD_MUTEX_INITIALIZER; void* worker1(void* arg) { pthread_mutex_lock(mutexA); /* T1 先占有 A */ printf([T1] locked A, now trying B...\n); usleep(50000); /* 50ms 窗口强制交叉 */ pthread_mutex_lock(mutexB); /* 等待 B会卡在这里 */ printf([T1] got B, done.\n); pthread_mutex_unlock(mutexB); pthread_mutex_unlock(mutexA); return NULL; } void* worker2(void* arg) { pthread_mutex_lock(mutexB); /* T2 先占有 B */ printf([T2] locked B, now trying A...\n); pthread_mutex_lock(mutexA); /* 与 T1 形成循环等待 */ printf([T2] got A, done.\n); pthread_mutex_unlock(mutexA); pthread_mutex_unlock(mutexB); return NULL; } int main() { pthread_t t1, t2; pthread_create(t1, NULL, worker1, NULL); pthread_create(t2, NULL, worker2, NULL); pthread_join(t1, NULL); pthread_join(t2, NULL); return 0; }代码只有一个核心设计点两把锁的申请顺序互为逆序。T1 拿 A 再拿 BT2 拿 B 再拿 A这是制造典型死锁问题的标准姿势只要顺序一致程序大概率正常跑完。usleep(50000) 的作用是放大时间窗口单位是微秒50ms 在大多数虚拟机上够用如果在多核物理机上跑窗口太短很可能两个线程没来得及交叉建议调成 200ms 再试。编译和运行命令如下gcc -o deadlock deadlock.c -lpthread -pthread ./deadlock运行后进程卡住日志停在两行CtrlC 都未必有效。编译参数里 -pthread 和 -lpthread 一起写是为了兼容不同版本的 gcc老版本只认 -lpthread新版本对宏处理更严格加上 -pthread 更稳妥。我这几年在虚拟机里跑这个实验最常翻车的就是没加 -pthread导致编译报错后误以为代码有问题。3.2 银行家算法安全性检测函数need、work、finish 三个数组怎么配合实验做完死锁复现下一个必考点是银行家算法。它的目标是分配前先判断“这次分配之后系统是否仍安全”安全性检测函数是核心。数据结构不复杂Available 是系统空闲资源Max 是每个进程需要的最大资源数Allocation 是已分配资源Need 是还缺的资源Work 是检测过程中的临时可用资源Finish 标记进程是否能跑完。int safe_check(int n, int m, int available[], int max[][m], int alloc[][m]) { int need[n][m]; for (int i 0; i n; i) for (int j 0; j m; j) need[i][j] max[i][j] - alloc[i][j]; /* 还缺多少 */ int work[m]; for (int j 0; j m; j) work[j] available[j]; /* 从系统空闲开始 */ int finish[n]; for (int i 0; i n; i) finish[i] 0; int seq[n], cnt 0; while (cnt n) { int found 0; for (int i 0; i n; i) { if (finish[i]) continue; int ok 1; for (int j 0; j m; j) if (need[i][j] work[j]) { ok 0; break; } if (ok) { for (int j 0; j m; j) work[j] alloc[i][j]; /* 假定进程跑完归还资源 */ finish[i] 1; seq[cnt] i; found 1; break; } } if (!found) return 0; /* 一整轮找不到可执行的进程 */ } printf(safe sequence: ); for (int i 0; i n; i) printf(P%d , seq[i]); printf(\n); return 1; }这段代码把操作系统实验里最常见的矩阵计算写成了独立函数。逻辑核心是循环尝试每一轮从剩余进程中找一个 need 全部小于等于 work 的把它标记为可完成并把它占用的资源归还到 work 里如果某轮一个都找不到说明系统不安全。参数 n 是进程数m 是资源类型数二维数组 max 和 alloc 的行是进程、列是资源类型。注意 need 必须用 max - alloc 计算不能拿 alloc 直接比这是新手第一个容易错的地方。实际填数据时主程序需要按行读入 Max 和 Allocation再调用 safe_check。一个典型的输入序列是 3 个进程、3 类资源Available 为 10 5 7Max 矩阵分别为 7 5 3、3 2 2、9 0 2Allocation 分别为 0 1 0、2 0 0、3 0 2。输出 safe sequence 为 P1、P3、P0 之类的结果时说明系统处于安全状态。返回 0 则说明当前资源分配已经不安全不能继续给任何进程分配。算法里“一整轮找不到可执行进程”的判断就是死锁避免判定的临界点。3.3 运行输出怎么解读死锁现场与资源分配图如何对上跑死锁复现程序预期输出是两条日志然后进程永久停住[T1] locked A, now trying B... [T2] locked B, now trying A...拿输出对照四个必要条件T1 持有 A满足“持有”T1 在等 B而 B 在 T2 手里T2 又在等 A满足“循环等待”pthread_mutex 保证了互斥与不可剥夺。这个对照练习值得写在操作系统笔记里它把抽象概念变成了可见的进程状态。如果拿上面那段 safe_check 去检测同样的资源状态也会得到“不安全”的结论这就是死锁复现与死锁避免两个实验在输出层面的呼应。3.4 让死锁稳定复现的两个改动栅栏与循环重试usleep 制造时间窗口属于碰运气在多核机器上经常复现不出来。更稳的做法有两个。第一个是使用 pthread_barrier,让两个线程在“都拿到第一把锁”之后同时放行死锁交叉就是必然事件。第二个是对锁申请加入循环重试,每个线程拿不到第二把锁时反复重试而不是只试一次就退出,同样能把等待窗口拉长。把代码里的 usleep 换成 barrier 之后复现成功率接近百分之百这个经验在实验课上特别管用。10 个人里 7 个人复现失败不是逻辑写错是交叉时机不够巧。4. 死锁实验避坑指南复现失败、假死锁与输出不一致的排查4.1 同样代码换台机器就正常跑完现象在自己笔记本上能卡死换到同学电脑上两秒钟退出。原因锁的申请没有形成真实交叉T1 拿完 A 立刻申请 BT2 还没来得及拿 BT1 已经拿到 B 并全部释放。解决把 usleep 从 50ms 调到 200ms或者用 pthread_barrier 让两个线程同步在“已锁第一把锁”的位置再继续。在单核虚拟机上还可以强制线程绑定到不同 CPU 核心避免调度器掐断交叉窗口。4.2 日志停在“locked”但程序最后自己跑完了现象输出里两个线程都说自己在尝试第二把锁几秒后却正常结束。原因一个线程的等待被调度延后另一个线程全程跑完释放锁等前者醒来时锁已经空闲于是顺利通过。解决在申请第二把锁前加一段空转循环比如 for(int i0;i10000;i); 人为制造重叠区让两个线程确实同时处于“捏着一把锁等另一把锁”的状态。这个坑最容易让人误以为死锁原理没掌握其实只是时序问题。4.3 银行家算法输出的安全序列和标准答案不一致现象相同的 Max、Allocation、Available你跑出的安全序列是 P1→P3→P0→P2同学跑出的是 P1→P0→P3→P2两人都认为自己对。原因安全序列本来就不唯一每一轮可能有多个进程同时满足 need 小于等于 work谁先被遍历到谁先进序列。解决只要每个进程被选入时 need 确实都满足序列就是合法的。实验报告里写清“此序列为其中一条安全序列”即可不需要和参考答案强行一致。4.4 用 gdb attach 到死锁进程时直接卡住现象死锁出现后想调试gdb attach -p PID 没有反应或者报错。原因进程阻塞在 pthread_mutex_lock 的系统调用里attach 信号处理异常。解决用 ps -ef | grep deadlock 拿到 PID再执行 gdb attach -p PID进入后用 thread apply all bt 打印全部线程栈。看到线程停在 __lll_lock_wait 或 pthread_mutex_lock 时基本就是锁等待现场配合日志缺少“got A”或“got B”就能确认死锁点。这条排查流程建议亲手走一遍考试和面试里问到排查手段时可以直接答出栈符号。4.5 多进程死锁时日志混在一起看不出谁在等谁现象用 fork 创建多个进程模拟死锁输出全混在终端里无法判断哪个进程持有哪些锁。原因标准输出缓冲顺序不可控多个进程的日志交错刷新。解决在每条日志里带上进程号和时间戳并强制行缓冲。改成 printf([%d][%ld] ..., getpid(), time(NULL)); 后时间线立刻清晰。这个改动对排查多进程死锁非常有效比反复读代码猜状态快得多。5. 用 gdb/pstack 验证死锁现场并把实验报告写出差异化5.1 验证死锁是否真实存在的三个命令手段死锁实验最怕把“卡顿”误判成“死锁”验证手段很重要。第一招是 gdb attach进入后执行 thread apply all bt看两个线程是否都停在锁等待函数上。第二招是 pstack PID一条命令打印所有线程栈输出比 gdb 轻量判断“是不是死锁”完全够用。第三招是在最小化系统里直接看 /proc/PID/stack没有 gdb 也能确认内核态的等待函数。三个手段选一个就够但过程一定要截图这是实验报告最有力的证据。5.2 操作系统面对死锁的三条现实路径现实系统很少跑银行家算法更多是从锁的设计上直接拆招。一是 trylock 模式pthread_mutex_trylock 拿不到锁就返回错误码发现第二把锁拿不到时主动释放已有锁对应破坏“持有并等待”。二是锁顺序规范所有模块按资源编号从小到大申请锁对应破坏“循环等待”。三是死锁检测线程系统专门扫描等待图发现成环就挑一个进程结束代价高但通用适合开发人员守不住规矩的复杂系统。三条路径和前面表格里的破坏手段一一对应理解了这张表面试里的“你怎么避免死锁”就有三个层次的答法。5.3 实验报告与期末复习的差异化写法实验报告的得分点从来不在字数在于现场证据。放三样东西最稳手画的资源分配图、程序卡死后的终端输出、gdb 的线程栈截图。银行家算法部分把每一轮选中的进程、当时的 need 和 work 写成一列推演表比直接贴输出更有说服力。期末复习时把“有环不一定死锁”当作核心考点反复练配合一句例外情景多实例资源在环外还有空闲实例时系统不会被卡死。这道题在选择题里出现的频率相当高。我自己早年做这个实验被 usleep 玄学折磨了一整晚第二天换成 barrier 三分钟拿到死锁现场。那之后我写并发代码养成了两个习惯拿第二把锁之前先问锁顺序是否全局唯一trylock 失败能不能回退。这俩习惯帮我避开了不少线上并发雷也希望帮到你。本文还有配套的精品资源点击获取
返回列表