资讯动态

不安全状态≠死锁:从银行家算法到真实系统排查指南

发布时间:2026/10/1 19:06:02 来源:尧图企业网站定制
学习操作系统的时候几乎每本教材都会放一张嵌套图安全状态套着不安全状态再套着死锁状态然后配一句“死锁一定处于不安全状态但不安全状态不一定会死锁”。这句话我在考试前背得滚瓜烂熟但真正理解它反而是后来在真实系统里排查“假死锁”时才做到的——两个服务互相等了一段时间看起来符合死锁的全部特征结果我正准备 kill 的时候它们自己跑完了。后来做调度模拟器、读线程 dump、查数据库死锁又无数次验证了同一个结论操作系统里的不安全状态只是一个风险标记不是一个终局判决。这篇文章就把这件事讲透。1. 先从定义边界入手安全状态、不安全状态、死锁不是一条直线上的三站1.1 安全序列到底在“排”什么要理解安全状态先得接受一个很“悲观”的设定操作系统在分配资源时并不知道每个进程后面会怎么运行它唯一能参考的是进程自己声明的“最大需求”。假设进程 A 说“我最多需要 8 个资源”那么系统在做安全性判断时就必须假定 A 有可能在某一个时刻一口气把剩余的额度全部申请走。所谓安全状态就是在这种最坏的假定下仍然存在至少一个进程执行顺序能让所有进程依次完成。这个顺序就叫安全序列。比如系统当前可用资源为 3进程 A 还需要 2进程 B 还需要 4那就可以让 A 先申请 2 个资源跑完A 释放资源后可用量变大B 再跑完。因为总能找到这样一条“保底路线”所以系统是安全的。这里的关键词是“存在”不是“必然”。系统并不会真的按这个序列去调度它只是确认一条“哪怕所有进程都来抢资源也不会卡死”的出路存在。只要这条路存在无论实际运行怎么乱理论上都不会进入死锁。1.2 不安全状态的本意是“不受控”不是“已僵死”不安全状态的定义反过来当前剩余资源无法满足任何进程的剩余需求系统里不存在一条能保证所有进程都顺利完成的执行序列。很多初学者会在这里卡住因为听起来“所有进程都没法继续了”这不就是死锁吗其实不是。不安全状态只是说“系统已经不能保证安全结局了”但它没有描述“当下发生了什么”。它只描述了一种风险等级而不是一个具体的阻塞形态。真实的资源分配系统中进程的运行是动态的有的进程会超时放弃、有的进程会提前退出、有的进程会分阶段释放资源甚至外部操作员会手动终止某个任务。这些动作都不在银行家算法的静态模型里但它们在实际系统中经常发生。打个比方。一个路口没有交警、没有红绿灯所有方向的车都有机会同时堵在一起——这是“不安全状态”意思是交通秩序已经不受控了。但如果这时有一个司机放弃左转或者有一辆车倒出去绕路路口又能恢复通行。而死锁更像是四辆车分别从四个方向抢占同一个十字中心每辆车都被另外一辆车堵住去路谁也不让谁也无法后退。两者差距非常大。1.3 一句话记住三者关系死锁状态一定是不安全状态因为一个真正卡死的系统肯定不存在能让所有进程完成的安全序列。但反过来不成立因为不安全状态只是“存在导致死锁的可能性”而可能性变成现实还需要满足一系列条件同时爆发。如果一个系统处于不安全状态最直接的解读是系统已经失去了“正常调度的保底能力”接下来能不能跑完只看运气和进程自身的行为。而死锁是不安全状态在特定条件下演化出的最坏结局之一不是必然结局。理解了这一层后面看实例就会清晰很多。2. 一个“理论上不安全、实际没死锁”的资源分配实例2.1 构造一个乍看没有出路的资源分配局面用一个经典模型来说话系统里有 12 个同类型资源三个进程分别占有了一部分当前状态如下表进程已分配最大需求还需P0583P1363P2264已分配总量是 5 3 2 10因此系统当前空闲资源是 12 - 10 2。现在检查安全性P0 还需 3空闲只有 2无法满足P1 还需 3同样无法满足P2 还需 4更无法满足。这是一个典型的不安全状态因为找不到任何一个进程可以先走完。但请注意此刻 P0、P1、P2 之间并没有形成“你占着我需要的我占着你需要的”这种互相抱死的依赖链。它们只是在等待系统分配更多空闲资源而资源池暂时空了。它们卡住的原因是整体资源供给不足而不是资源分配关系形成了环。2.2 如果系统没有任何干预会发生什么如果完全不干预在标准的资源请求模型下三个进程都会一直阻塞系统表现为所有任务停滞。这个状态确实非常接近死锁但它缺少了死锁的必要条件之一循环等待。也就是说这里不存在一个“资源持有者同时也是资源等待者”的闭合环路每个进程等待的都是系统中尚未分配的自由资源而不是被另一个进程攥在手里的资源。这一点在实际系统里很重要。如果只是整体资源不足那么系统停滞的表象之下仍有“松动”的可能。比如管理员向系统添加一个资源实例空闲从 2 变成 3P0 或 P1 的需求立刻就能满足又比如一个外部任务被取消释放出它持有的资源同样能让系统恢复推进。这些操作都不是“破环”只是“补足供给”它不能解决真正的死锁但完全可以解除一个不安全状态。2.3 “撤销进程”和“增加资源”如何让系统走出困境再看一种更实际的脱离路径进程在等待资源时并不都是无限期死等。很多系统会给资源申请设置超时。P2 等待超时后主动放弃执行操作系统回收它所占用的 2 个资源空闲资源从 2 变成 4。此时 P0 需要 3 个资源可以被满足P0 跑完释放 5 个资源空闲变成 9P1 需要 3也能跑完。整个系统恢复了安全性。这就是不安全状态不等于死锁最直接的证据它可能因为没有形成循环等待从而在某个等待者退出时自然恢复。死锁则相反即使你把外部资源补进来如果资源总数足够死锁也不会自动解开因为每个进程持有的资源正是其他进程等待的资源谁先释放这个环形依赖都无法解开只能靠外力强行终结其中一个参与者。2.4 这个例子在银行家算法眼里意味着什么如果用银行家算法处理上面的例子系统的结论只有一句话拒绝任何新的资源请求或者如果当前请求已经导致这种状态就判定“不安全”。但注意银行家算法说“不安全”时它并没有能力告诉你系统几秒钟后会不会因为某个进程超时回滚而回到安全状态它也没有判断出这里其实没有循环等待。它用的是一种极其保守的策略只要存在风险就不允许。这种保守不是完全没有代价。它会让系统拒绝掉很多实际上能正常完成的资源申请直接拉低资源利用率和并发度。所以真实世界中通用操作系统并没有把银行家算法作为内核的默认策略这引出下一部分要聊的核心取舍。3. 为什么银行家算法宁可误伤也不放行目标函数不是“不死锁”而是“绝不冒险”3.1 银行家算法的判断逻辑看起来很简单背后是“悲观调度”银行家算法的判断过程简单说就是模拟一遍“把所有进程都调度完”的可行性。伪代码写出来非常直观def is_safe(available, allocated, need): work available[:] finish [False] * len(allocated) while True: found False for i in range(len(allocated)): if not finish[i] and all(need[i][j] work[j] for j in range(len(work))): work [work[j] allocated[i][j] for j in range(len(work))] finish[i] True found True break if not found: break return all(finish)每次循环找一个“剩余需求能被当前空闲资源满足”的进程假定它运行完并把资源全部归还然后反复执行。如果能标记所有进程为完成系统就是安全的否则就是不安全。这个算法的核心假设其实有两个第一进程在运行期间会一直持有已分配的资源直到整体结束才统一释放第二进程的剩余需求必须一次性全部分配给它它才会继续运行。但真实软件的行为几乎不满足这两个假设。一个进程可能先用一小部分资源完成一段计算然后释放临时缓冲区也可能在运行中主动释放已经不再使用的资源。银行家算法完全忽略这些动态变化把所有进程都当成“要么不跑要么一口气跑到死”的模型因此它的判断非常悲观。很多在不安全判定下被拒绝的请求在实际运行时根本不会造成问题。3.2 为什么现代操作系统不把银行家算法当默认策略抛开理论模型银行家算法在通用操作系统里落地有几个现实困难。第一进程很难预声明最大资源需求。Linux 下 fork 出来的子进程、动态加载库、按需分配堆内存资源需求是运行时才逐渐明确的。要求每个进程在一开始就告诉内核“我这一辈子最多会打开多少个文件描述符、占多少内存”这既违背设计也几乎不可操作。第二资源类型的组合让算法复杂度变得难以接受。真实系统里的资源类型极多内存、CPU 时间片、文件句柄、锁、信号量、GPU 显存、网络缓冲区。每增加一种资源安全性判断就要多维护一个维度的矩阵而很多资源之间还有复杂的换算关系。第三也是最根本的通用系统能承受死锁检测和恢复的成本。对桌面操作系统和服务器来说进程崩溃和重启是可控的杀掉一个进程来打破死锁是代价可以接受的方案。因此 Linux、Windows 这些主流系统选择了“鸵鸟算法”假装死锁不存在等真出了问题再想办法恢复而不是在每次资源申请时都做一遍代价高昂的安全检查。3.3 用“银行备付金”类比为什么不公平有人喜欢把银行家算法比作银行不会把所有存款都贷出去所以永远留下备付金。其实这个类比只说对了一半。银行家算法的保守程度相当于银行要求每个客户在贷款时申报“我这辈子最多可能需要多少钱”然后银行按照“所有客户同时把额度拉满”的情景准备现金。这样做确实永远不会出现资金链断裂但代价是大量正常业务会被拒之门外。真实世界的银行不会这么干因为有存款保险、有央行拆借、有贷后管理它允许出现短期的流动性紧张再用补充手段化解。操作系统的思路也是类似的与其预先阻断所有风险不如在风险真正变成死锁时用回收、回滚、杀进程这些“事后手段”来兜底。这也是后面要讲的工程实操里大家更关注“检测和恢复”而不是“预防”的原因。4. 工程世界里真正排查死锁的人都在看什么从 jstack 到 InnoDB 冲突检测4.1 通用操作系统的鸵鸟政策允许死锁提供恢复手段现代操作系统在处理死锁问题上普遍默认了“放任发生、事后恢复”的策略。内核里虽然也有类似 lockdep 这样的死锁检测机制但它检测的是“潜在死锁风险”而不是在每次分配前做安全判定。用户态进程之间出现死锁时操作系统通常不会主动介入而是通过超时、信号、用户手动终止等方式打破僵局。这也是实际操作中风险最大的部分如果一个死锁发生在关键服务里又恰好没有外部兜底服务就会永久挂起。因此工程上反而要自己在应用层做防御比如规定加锁顺序、使用超时锁、用读写锁替代互斥锁。理解了操作系统的默认选择你就能明白为什么很多死锁问题不是靠内核解决的而是靠应用开发者的自律和一系列检测工具解决的。4.2 Linux/Java 现场用 jstack 抓出循环等待排查线程死锁最常见的方法是抓线程 dump。Java 环境下执行jstack pid thread_dump.txt然后打开 dump 文件重点看两个东西java.lang.Thread.State: BLOCKED和waiting to lock 0x...。JVM 很贴心的一个地方是如果检测到 Java 层的死锁它会在 dump 文件开头直接打印一行Found one Java-level deadlock:但很多时候 jstack 并不会直接给出这个结论尤其是死锁涉及多个锁、多个线程、还有本地代码时。这时就需要手动画等待图把每个线程“持有”的锁和“等待”的锁列出来看能不能组成一个闭环。我写过一个小脚本辅助排查先正则提取所有locked和waiting to lock行按 monitor 地址建立映射然后模拟有向图找环。这个方法很土但很有效。遇到线程 A 持有锁 1 等待锁 2线程 B 持有锁 2 等待锁 1这就是教科书级的死锁如果 Thread Dump 里只是大量线程在等待同一个连接或同一个内存池那就不是死锁而是资源供给问题后面专门讲。4.3 数据库的死锁检测与回滚选择数据库系统对死锁的态度又不一样。因为事务一旦提交就不能随便回滚数据库很重视“检测到再处理”的机制。以 MySQL InnoDB 为例它维护了一张 wait-for graph也就是“谁在等谁”的图每次等待关系发生变化时都会检查这个图里有没有环。检测到环后InnoDB 会选择回滚 undo 量较少或者代价较小的事务来打破死锁。查看最近一次死锁信息执行SHOW ENGINE INNODB STATUS\G在输出里找LATEST DETECTED DEADLOCK段落它会把两个事务分别持有哪些锁、在等哪些锁写得很清楚。排错思路和操作系统里的死锁一模一样找到环杀掉其中一个参与者让整个系统恢复推进。区别在于数据库的“杀掉”是回滚事务而且这个动作是自动完成的不需要管理员手动介入。如果应用场景对实时性要求高承受不了数据库自动检测的时间成本也可以关掉死锁检测靠innodb_lock_wait_timeout作为兜底。但这需要清晰知道自己在做什么否则事务会长时间处于等待状态最终被超时一刀切反而破坏业务。4.4 lockdep 的思路在死锁发生前发现潜在环Linux 内核的 lockdep 机制提供了一种更接近“不安全状态预警”的思路。它不是等死锁发生后才报告而是在运行中持续记录锁的依赖关系一旦发现“新依赖会让已有的锁依赖图形成环”立刻输出一条possible circular locking dependency detected的警告。这其实就是在把“潜在死锁”当成一个风险评估在提前暴露。lockdep 报告的问题并不代表系统当时已经死锁它只是发现代码里存在一条“如果将来两个线程以某个特定顺序交错执行就会形成循环等待”的路径。这和“不安全状态不一定变成死锁”的逻辑特别像——风险被标记了但风险是否会兑现取决于后续运行的具体时序。我在内核模块开发时见过不少把 lockdep 警告当噪音忽略的同事结果过几个月就在特定压力测试下撞上一个随机死锁。这种教训非常贵。遇到 lockdep 报警正确做法是直接当作一份未爆发的死锁清单逐条检查锁顺序而不是祈祷它不出现。5. 把“循环等待”当作关键判据如何区分资源饥饿、集体阻塞和真死锁5.1 一张表判断是死锁还是只是“卡住”实际排查线上问题时我最常做的一件事就是先区分“它是哪一种卡住”。下面这张表我每次培训都会放现象等待图特征典型恢复手段真死锁存在闭合环终止环中一个进程/事务不安全状态/资源不足无环所有线程在等待系统空闲资源增加资源、清理泄漏、缩小请求量资源饥饿无环某些线程长期轮不到锁调整调度策略、保证公平性、限流连接/线程池耗尽大量线程等待同一个池无环调大池、缩短占用时间、定位泄漏这张表的第一行和第三行是最容易混淆的。死锁一定伴随循环等待而资源饥饿只是部分线程长期得不到资源其他线程可能正常运行。表现在日志里饥饿线程和死锁线程都会长时间卡住但它们的依赖关系完全不同。定性判断出一个“卡住”问题里是否存在环比背诵死锁四个必要条件有用得多。5.2 最容易误判成死锁的“连接池/线程池耗尽”我遇到过很多次告警里写着“疑似死锁”最后查出来全是连接池耗尽。典型场景是业务线程在持有业务锁的情况下去数据库连接池申请连接连接池里的连接被另一批执行慢 SQL 的线程占满了新请求全在排队等连接。从线程栈上看有的线程持有锁在等连接有的线程持着连接在等锁释放表面上有环但环的末端其实是一个“池”不是一把具体的锁。这种问题的本质是资源池容量不够或连接被长期占用不属于死锁。恢复手段是调大连接池、优化慢 SQL、给连接加超时而不是挑一个线程 kill 掉。如果按真死锁去处理杀掉持有连接但暂时空闲的线程反而会让业务报错问题却不会根治。判断方法并不复杂把等待链画出来如果箭头最终指向一个连接池、内存池或线程池的队列那它就不是死锁。5.3 线上遇到的不安全状态多数不需要“破环”而是需要“补资源”或“砍进程”结合前面的分析我觉得可以给出一套比较实用的处置顺序拉取所有相关线程的 dump确认每个线程的阻塞点。列出每个线程持有哪些资源、等待哪些资源手动画等待图。检查图中是否存在真正的环。没有环优先怀疑资源供给问题。如果有环还要确认环上的等待者是否真的不可能主动退出。如果其中一个等待者设了超时它大概率会自己解开。只有确认存在真实循环等待才考虑 kill 一个参与者或者回滚事务。这套流程对操作系统内核、Java 服务、MySQL 事务都适用。它本质上是在回答一个问题当前状态到底是已经形成的死锁还是只有风险的“不安全状态”。两者处置手段完全不同。5.4 一个小实验随机调度下不安全状态真正转为死锁的概率为了让自己更有体感我写过一个很简单的随机模拟器模拟 10 个资源、5 个进程每个进程随机申请 1 到 3 个资源随机持有 2 到 5 个时间片后释放。运行 1000 轮的过程中只要发现当前状态被银行家算法判定为“不安全”就记录下来然后继续模拟看它最终是会恢复、还是演化成死锁。本地跑出来的结果大致是出现不安全状态大约有 400 多次最终真正形成死锁的只有 20 多次绝大部分不安全状态都会因为某个进程主动释放资源而恢复。这个实验不严谨但很直观地证明了“不安全状态不一定会死锁”随机调度下它死锁的概率不低但也远不到必然发生的程度。它的另一面也同样重要正因为不安全状态有相当概率演变成死锁所以才不能用“大概率没事”来合理化不安全的资源分配。系统设计者的职责是在安全判定、资源利用率和恢复成本之间做取舍而排查者的职责是准确判断当前状态到底属于哪一种再决定是用“补资源”还是“破环”的方式处理。我自己的体会是看到应用告警里出现 deadlock 字样先别急着杀进程。把线程栈拉出来数一数参与阻塞的线程数量画一下等待关系。如果根本画不了一个环那它就是资源不足型阻塞加锁超时、放宽资源池或者定位资源泄漏比杀进程有效得多。理解了不安全状态和死锁的区别你在排查这类问题时才不至于一上来就选错方向。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑