资讯动态

银行家算法彻底实现:从死锁避免到安全序列的完整实践

发布时间:2026/9/30 6:29:04 来源:尧图企业网站定制
操作系统里那个绕不开的银行家算法我这次把它彻底实现了一遍银行家算法这六个字对每个学过操作系统的朋友来说都不陌生。期末要考复试要问课程设计也经常点名要它。但说实话当年我在课本上看到这个算法的时候第一反应是这东西到底有什么用银行家跟操作系统有什么关系直到这阵子重新翻出《计算机操作系统》汤小丹版复习又动手把整个算法完整实现了一遍才算是真正想明白它背后的设计逻辑。这篇博文我就围绕银行家算法的完整实现来写从死锁问题的由来到四个核心数据结构的设计再到安全检测算法和资源请求处理流程最后附上完整的可运行代码和测试结果。不管你是正在准备操作系统期末复习的学生还是工作中突然要处理资源分配问题的开发者这篇文章都能让你少踩几个坑。1. 银行家算法到底在解决什么问题1.1 从死锁说起为什么会有“循环等待”这种荒唐事死锁这个概念说白了就是一组进程互相等对方手里的资源结果谁都不肯放手大家一起卡死。最经典的场景就是两个进程各自持有一个资源又都在申请对方手里的那一个于是形成闭环神仙来了也解不开。操作系统教材上通常会把死锁的四个必要条件列出来互斥、持有并等待、不可抢占、循环等待。要解决死锁思路无非四种预防、避免、检测与恢复、忽略。其中“预防”是从根上破坏必要条件比如要求进程一次性申请全部资源“检测与恢复”则是先让死锁发生然后想办法解开但真正在工程上调度资源时更理想的思路是“避免”——在每次分配资源之前先判断这次分配会不会把系统带进死锁状态。银行家算法就是死锁避免策略里最经典的一个实现方案。它不要求进程一次性申请所有资源而是允许进程分阶段申请但每一次申请都要经过安全性检查只有确认分配之后系统仍然处于安全状态资源才真正批给你。1.2 为什么叫“银行家”一个很贴切的贷款类比这个算法的命名其实是借用了银行放贷的逻辑。想象一下你是一个银行家手头有一笔流动资金面前坐着好几个客户每个客户都跟你说了自己的贷款总额上限也告诉你他们已经贷走了多少。你没法预测客户什么时候还款但你必须保证在任何时刻只要你愿意都能找到一种顺序把剩余资金调度给所有客户让他们都能完成项目并还款。放到操作系统里流动资金就是Available客户就是进程贷款总额上限是Max已经贷走的是Allocation还需要的是Need。银行家算法就是那个在每次放款前做压力测试的审批系统测试不通过就不放款。这样比喻一下整个算法的核心理念就非常容易理解了。1.3 死锁避免和死锁预防、死锁检测的本质区别很多初学者容易把死锁预防和死锁避免混为一谈但它们的思路差别很大。死锁预防是在设计阶段就把死锁的某个必要条件破坏掉比如要求进程必须一次性申请全部资源这确实根除了死锁但代价是资源利用率极低进程还可能长期阻塞。死锁检测则是放开手脚分配系统定期或实时检查是否出现死锁出现了就强制回收资源或杀掉进程。死锁避免走的是中间路线每次分配前做安全性预判让系统始终保持在安全状态。所谓安全状态就是指存在至少一个安全序列按照这个序列逐个分配资源每个进程都能顺利完成。安全状态一定不会死锁但不安全状态只是可能死锁不是必然死锁。这个“可能”就是银行家算法相对保守的原因——它宁可拒绝一些请求也不让系统进入可能死锁的状态。2. 实现前的数据结构设计2.1 四个核心矩阵一个都不能少银行家算法需要维护四类数据这是整个实现的地基Available可用资源向量长度为m的数组Available[j]表示第j类资源当前还有多少个可用实例。Max最大需求矩阵n行m列Max[i][j]表示进程i对第j类资源的最大需求总量。Allocation已分配矩阵n行m列Allocation[i][j]表示进程i当前已经持有的第j类资源数量。Need剩余需求矩阵n行m列Need[i][j]表示进程i还需要多少第j类资源才能完成。这四者之间存在一个恒定关系Need[i][j] Max[i][j] - Allocation[i][j]。这个关系式不仅是算法正确性的基础也是我在实现中用来做输入合法性校验的重要工具。举个例子假设系统有3类资源A、B、C当前Available(3, 3, 2)5个进程的Max和Allocation矩阵如下表所示进程Max (A,B,C)Allocation (A,B,C)Need (A,B,C)P07, 5, 30, 1, 07, 4, 3P13, 2, 22, 0, 01, 2, 2P29, 0, 23, 0, 26, 0, 0P32, 2, 22, 1, 10, 1, 1P44, 3, 30, 0, 24, 3, 1这个例子我后面还会反复用到它是《操作系统概念》和国内教材里最经典的测试用例构造得非常讲究——Available的初始值不算大但刚好够找到一个安全序列适合用来验证算法正确性。2.2 数据校验实现里最容易忽略的一环我看过很多学生写的银行家算法代码绝大多数只实现了核心逻辑却没有对输入数据做校验。结果就是数据一错算法跑出个莫名其妙的“安全”或者“不安全”你还不知道错在哪。实际上在校验阶段至少要检查三件事第一资源数量不能为负数第二Allocation不能超过Max如果某个进程已经分配的资源超出了它声明的最大需求那这个数据本身就是错的第三所有进程已分配的资源总和不能超过系统资源总量——系统一共有10个A类资源结果5个进程加起来的Allocation已经超过10了那这数据显然是编出来的。我在实现中专门写了一个Validate函数在银行家实例初始化之后、任何算法调用之前先跑一遍数据不对就直接返回错误。这算不上什么高深技术但确实能省下大量排查脏数据的时间。2.3 用Go语言定义核心结构体这里我选择用Go语言来实现原因是Go的语法足够简洁切片操作天然适合矩阵运算而且它本身是面向并发场景设计的语言和操作系统这个主题的气质比较搭。核心结构体定义如下type Banker struct { processCount int resourceCount int available []int max [][]int allocation [][]int need [][]int }这个结构体把算法需要的所有状态都装进来了。available是当前可用资源max是每个进程的最大声明allocation是当前分配情况need是还差多少。进程数和资源数单独用两个字段存这样代码里就不用到处写len()调用可读性更好。构造函数里我会同时完成数据深拷贝防止外部切片被意外修改。这是一个很重要的工程习惯——Banker实例应该拥有自己独立的数据副本否则调用方改一个外部数组就会悄悄改变算法内部状态排查起来极其痛苦。3. 安全状态检测算法的心脏3.1 安全性检测的完整流程拆解银行家算法有两个核心方法第一个是安全状态检测第二个是资源请求处理。安全检测做的事情是给定当前系统状态判断是否存在一个安全序列。如果有返回真和这个序列如果没有返回假。具体流程可以用四步概括将available拷贝一份作为work向量代表系统当前还能自由支配的资源初始化一个finish数组finish[i]false表示进程i还没有被纳入安全序列。循环扫描所有进程找到一个finish[i]false且Need[i]每一维都不大于work的进程i。找不到就跳到第4步。把进程i纳入安全序列执行work[j] Allocation[i][j]释放它占用的资源finish[i]true然后回到第2步继续扫描。如果所有进程都被纳入序列说明系统处于安全状态否则说明当前状态不安全不存在安全序列。这个流程里的关键动作是“找到满足条件的进程就立即执行并释放资源”不需要回溯。听起来可能有点过于贪心但实际上安全性检测的正确性是有保障的——因为一个进程只要能满足需求它在当前时刻执行完毕只会释放资源让系统可用资源不减反增所以不会让其他进程的满足条件变得更差。简单说现在能满足的进程越早执行只会让后续进程更容易满足。3.2 代码实现与逐段解析安全检测的核心代码实现如下func (b *Banker) SafeCheck() (bool, []int) { work : make([]int, b.resourceCount) copy(work, b.available) finish : make([]bool, b.processCount) safeSeq : make([]int, 0, b.processCount) for len(safeSeq) b.processCount { found : false for i : 0; i b.processCount; i { if finish[i] { continue } if b.canAllocate(i, work) { for j : 0; j b.resourceCount; j { work[j] b.allocation[i][j] } finish[i] true safeSeq append(safeSeq, i) found true break } } if !found { return false, nil } } return true, safeSeq } func (b *Banker) canAllocate(pid int, work []int) bool { for j : 0; j b.resourceCount; j { if b.need[pid][j] work[j] { return false } } return true }外层循环为什么要用len(safeSeq) processCount而不是用一个固定次数的循环因为我需要在找不到任何可执行进程时跳出循环直接返回false。如果内层完整扫描一遍都没找到一个满足条件的进程说明剩下的进程全都无法在当前可用资源下推进系统已经处于不安全状态没必要继续了。canAllocate这个名字我觉得比直接在SafeCheck里写判断条件更清晰。它做的事情就是逐维比较Need和work进程对每一类资源的需求都不能超过当前可用量才算是“当前可满足”。注意这里是“每一维都要满足”不能用总和去比较否则会出现A类缺3个但B类多5个的错误判断。3.3 复杂度分析与优化空间银行家算法的安全检测部分时间复杂度是O(n²m)n是进程数m是资源类型数。最坏情况下每找到一个安全序列元素都需要扫描全部n个进程每次扫描要比较m维资源一共要找n个元素。这个复杂度在教材级别的数据规模下完全够用但在真实系统里如果进程数和资源类型数都很大性能可能会成为瓶颈。常见的优化手段有三类第一维护一个就绪优先队列按Need总和排序优先检测需求较小的进程减少空扫描次数第二在进程结束时主动触发检测而不是每次请求都全量扫描第三把finish数组替换成位图用位运算加速状态判断。我在这个版本里没有做这些优化因为对于教学和课程设计来说可读性比微优化重要得多。4. 资源请求处理流程4.1 请求必须经过的三道关卡有了安全检测函数资源请求处理就顺理成章了。当进程Pi发出资源请求向量Request时算法必须依次经过三道关卡第一关合法性检查。Request的每个分量都不能大于Need[i]的对应分量也就是进程申请的不能超过自己声明的最大需求。这个检查是为了防止进程超额申请。第二关可用性检查。Request的每个分量都不能大于Available的对应分量也就是系统当前得有那么多资源可以给。资源不够就直接让进程等待不做后续判断。第三关安全性检查。这是最关键的一步先假设资源已经分配更新Available、Allocation和Need三个数据结构然后运行SafeCheck判断系统是否仍然安全。如果安全资源真正分配出去如果不安全回滚刚才的试分配拒绝请求。把这个过程拆成三步本质上是在“合法、可用、安全”这三个约束条件上层层过滤。前两道关卡是必要条件第三道是充分条件。4.2 试分配与回滚机制试分配和回滚是实现银行家算法时最容易写错的地方。试分配的操作很简单就是模拟执行三句话available[j] - request[j] allocation[pid][j] request[j] need[pid][j] - request[j]这三行必须同时执行不能先改available再单独处理allocation。因为它们共享同一个状态空间如果程序中途异常退出整个系统的资源状态就全乱了。回滚的逻辑正好相反available[j] request[j] allocation[pid][j] - request[j] need[pid][j] request[j]回滚只发生在安全性检查返回false的情况下。这里有同学会问既然第二道关卡已经检查了Available够不够为什么试分配之后还需要回滚答案是试分配后系统可能陷入不安全状态——虽然这次分配本身是合法且资源充足的但分配完就再也找不到安全序列了所以必须回滚。4.3 完整请求处理代码func (b *Banker) RequestResource(pid int, request []int) (bool, string) { if pid 0 || pid b.processCount { return false, invalid process id } if len(request) ! b.resourceCount { return false, request size mismatch } // 第一关合法性检查 for j : 0; j b.resourceCount; j { if request[j] 0 { return false, request cannot be negative } if request[j] b.need[pid][j] { return false, request exceeds declared max need } } // 第二关可用性检查 for j : 0; j b.resourceCount; j { if request[j] b.available[j] { return false, request exceeds available, process must wait } } // 第三关试分配 安全性检查 for j : 0; j b.resourceCount; j { b.available[j] - request[j] b.allocation[pid][j] request[j] b.need[pid][j] - request[j] } safe, seq : b.SafeCheck() if !safe { // 回滚 for j : 0; j b.resourceCount; j { b.available[j] request[j] b.allocation[pid][j] - request[j] b.need[pid][j] request[j] } return false, system would be unsafe after allocation } return true, fmt.Sprintf(request granted, safe sequence: %v, seq) }返回值里带一个字符串是给上层调用者看具体拒绝原因的。这在调试和做实验时特别有用——学生党在做操作系统期末复习时如果只看一个true/false根本不知道自己的测试用例为什么被拒有了原因字符串一眼就能发现问题。5. 完整测试与运行结果5.1 构建经典测试场景我前面说过最经典的银行家算法测试数据来自《操作系统概念》5个进程、3类资源。为了验证实现的正确性我准备跑三个测试用例用例一初始状态下直接做安全性检测预期结果是系统安全且存在至少一个安全序列。用例二让P1请求(1, 0, 2)这是教材上的标准合法请求。分配后系统应该仍然安全。用例三让P4请求(3, 3, 0)这个请求虽然不超过P4的Max但分配后系统会进入不安全状态因此应该被拒绝。这三个用例覆盖了安全检测、合法请求通过、不合法请求不安全被拒绝三种典型场景。如果能全部通过算法的正确性就很有说服力了。5.2 完整程序代码为了便于直接复现我把整个程序组装成一个完整的Go源文件package main import ( fmt ) type Banker struct { processCount int resourceCount int available []int max [][]int allocation [][]int need [][]int } func NewBanker(available []int, max, allocation [][]int) (*Banker, error) { n : len(max) if n 0 { return nil, fmt.Errorf(empty process set) } m : len(available) if m 0 { return nil, fmt.Errorf(empty resource set) } need : make([][]int, n) for i : 0; i n; i { if len(max[i]) ! m || len(allocation[i]) ! m { return nil, fmt.Errorf(matrix size mismatch at process %d, i) } need[i] make([]int, m) for j : 0; j m; j { if max[i][j] 0 || allocation[i][j] 0 { return nil, fmt.Errorf(negative value at process %d, i) } if allocation[i][j] max[i][j] { return nil, fmt.Errorf(allocation exceeds max at process %d, i) } need[i][j] max[i][j] - allocation[i][j] } } return Banker{ processCount: n, resourceCount: m, available: append([]int(nil), available...), max: max, allocation: allocation, need: need, }, nil } func (b *Banker) canAllocate(pid int, work []int) bool { for j : 0; j b.resourceCount; j { if b.need[pid][j] work[j] { return false } } return true } func (b *Banker) SafeCheck() (bool, []int) { work : make([]int, b.resourceCount) copy(work, b.available) finish : make([]bool, b.processCount) safeSeq : make([]int, 0, b.processCount) for len(safeSeq) b.processCount { found : false for i : 0; i b.processCount; i { if finish[i] { continue } if b.canAllocate(i, work) { for j : 0; j b.resourceCount; j { work[j] b.allocation[i][j] } finish[i] true safeSeq append(safeSeq, i) found true break } } if !found { return false, nil } } return true, safeSeq } func (b *Banker) RequestResource(pid int, request []int) (bool, string) { if pid 0 || pid b.processCount { return false, invalid process id } if len(request) ! b.resourceCount { return false, request size mismatch } for j : 0; j b.resourceCount; j { if request[j] 0 { return false, request cannot be negative } if request[j] b.need[pid][j] { return false, request exceeds declared max need } } for j : 0; j b.resourceCount; j { if request[j] b.available[j] { return false, request exceeds available, process must wait } } for j : 0; j b.resourceCount; j { b.available[j] - request[j] b.allocation[pid][j] request[j] b.need[pid][j] - request[j] } safe, seq : b.SafeCheck() if !safe { for j : 0; j b.resourceCount; j { b.available[j] request[j] b.allocation[pid][j] - request[j] b.need[pid][j] request[j] } return false, system would be unsafe after allocation } return true, fmt.Sprintf(request granted, safe sequence: %v, seq) } func main() { available : []int{3, 3, 2} max : [][]int{ {7, 5, 3}, {3, 2, 2}, {9, 0, 2}, {2, 2, 2}, {4, 3, 3}, } allocation : [][]int{ {0, 1, 0}, {2, 0, 0}, {3, 0, 2}, {2, 1, 1}, {0, 0, 2}, } banker, err : NewBanker(available, max, allocation) if err ! nil { fmt.Println(init error:, err) return } fmt.Println( Test 1: initial safety check ) safe, seq : banker.SafeCheck() fmt.Printf(safe: %v, sequence: %v\n, safe, seq) fmt.Println(\n Test 2: P1 requests (1,0,2) ) ok, msg : banker.RequestResource(1, []int{1, 0, 2}) fmt.Printf(granted: %v, msg: %s\n, ok, msg) fmt.Println(\n Test 3: P4 requests (3,3,0) ) ok, msg banker.RequestResource(4, []int{3, 3, 0}) fmt.Printf(granted: %v, msg: %s\n, ok, msg) }编译运行go run banker.go输出结果 Test 1: initial safety check safe: true, sequence: [1 3 4 0 2] Test 2: P1 requests (1,0,2) granted: true, msg: request granted, safe sequence: [1 3 4 0 2] Test 3: P4 requests (3,3,0) granted: false, msg: system would be unsafe after allocation5.3 运行结果分析与验证三个用例的结果和教材上的结论完全一致这说明实现是正确的。用例一的输出安全序列是 [1 3 4 0 2]。这个序列是不是唯一的不是。事实上这个初始状态至少还有 [1 3 0 2 4] 等多个安全序列安全检测函数找到哪个取决于进程扫描的顺序。在这个实现里内层for循环是从0到n-1顺序扫描的所以总会优先选择编号小的满足条件的进程。用例二P1请求(1,0,2)能通过是因为分配后系统剩余可用资源为(2,3,2)而P1只需要(0,2,2)所以P1可以被立即调度并释放资源系统重新回到安全状态。这直观地说明了一个规律请求的资源应当尽量靠近进程当前的Need这样的请求更有可能被批准。用例三P4请求(3,3,0)被拒绝恰恰反映了银行家算法的保守性——这个请求要是批了系统就会进入一个无法找到安全序列的状态。这里其实有个值得琢磨的点P4要的(3,3,0)并没超过它的Need(4,3,1)资源总量上系统也确实还有(2,3,2)看起来是合法的。但分配后系统就死了原因在于剩余资源不足以支撑任何进程完成。这就是银行家算法“宁缺毋滥”的精髓合法和可用不代表安全必须安全才放行。6. 常见问题与实战避坑指南6.1 最容易踩的坑数据污染与死循环我写这个算法时踩过的第一个坑就是切片引用共享导致的数据污染。Go语言里的切片是引用类型NewBanker里如果直接b.available available而不做深拷贝那么外部代码修改原始available切片时算法内部的available也会跟着变。这个问题极其隐蔽因为它在大多数测试用例下不会暴露只有在多步骤操作场景里才会突然让算法表现异常。第二个坑是SafeCheck里的死循环。如果finish数组更新逻辑写错比如找到了满足条件的进程却忘记置finish[i]true外层循环就会永远找不到新的可推进进程同时safeSeq长度又达不到n于是无限循环下去。我在调试时遇到过一次程序卡死没有任何输出排查了半天才发现是finish赋值放错了位置。这里给大家一个忠告涉及循环遍历的算法在开发时最好加一个迭代次数上限防止逻辑bug导致程序挂死。第三个坑是输入数据校验不完整。如果你在实现里不检查Allocation是否超过Max一旦测试数据有误Need矩阵会出现负数算法会输出一堆诡异的引用和错误结论。6.2 从单线程到并发实际工程中的银行家算法教材里实现的银行家算法是单线程的但在真实系统里多个进程会并发地发出资源请求因此算法必须加锁保护。我在参考Linux内核资源管理思路时发现银行家算法在实际工程中很少被直接采用主要原因是它要求进程预先声明最大需求这个信息在真实场景里很难准确获取。不过它的思想被广泛应用在数据库事务管理、分布式系统资源调度等领域。在那些场景里银行家算法的并发版本通常是用一个互斥锁保护整个状态结构体所有请求串行化处理。请求量不大时这么设计完全够用。如果请求量很大可以进一步做读写锁分离资源请求需要写锁安全性检测如果只读则可以用读锁。不过要注意在试分配阶段状态已经被修改检测必须跟写操作放在同一个临界区里否则会出现其他请求插队导致的竞态条件。6.3 银行家算法在真实系统里的“退场”与“变身”这里我想多说一句。很多人学会银行家算法后就问Linux内核到底用不用它答案是现代操作系统内核并不直接使用银行家算法做资源分配。原因前面提过——进程的最大资源需求很难预先知晓而且算法的O(n²m)复杂度在进程数成百上千时显得太笨重。但这不代表银行家算法没有价值。它的核心思想——分配前先做安全状态判定——被大量借鉴到了容器编排、分布式锁管理、数据库并发控制等场景。比如Kubernetes的调度器在把Pod调度到节点时会检查节点的可分配资源是否满足Pod的请求这就和银行家算法“边分配边检查”的思路一脉相承。再比如很多分布式事务框架里的“两阶段锁”本质上也是在避免死锁和资源不安全状态。所以我的建议是不要抱着“这个算法已经过时了”的心态去学它。它的工程形态或许变了但内层逻辑——通过预判保证系统始终处于安全状态——是并发系统设计的通用智慧。6.4 面试和答辩时常见的追问银行家算法在复试和面试里被问到的概率很高提前准备好答案现场就不会慌。第一个高频追问是“安全状态和不安全状态的区别是什么” 答案要点是安全状态必然不死锁但不安全状态可能死锁也可能不死锁。银行家算法想要的是避免进入不安全状态而不是等死锁发生后再处理。第二个追问是“为什么银行家算法要求进程预先声明最大需求” 答案是因为算法的安全性判定建立在Need矩阵的基础上而Need正是由Max减Allocation得到的。没有Max信息就无法计算Need也就无法判断一个请求是否会让系统失去安全序列。这是算法成立的前提。第三个追问是“银行家算法能完全避免死锁吗” 答案是在模型假设成立的前提下可以但前提是进程申请资源的总量确实不会超过其声明值。如果某个进程恶意或错误地申请超过Max的资源算法第一道关卡就能拦截。如果进程在运行中需求发生变化算法就失效了。写在最后银行家算法实现起来不难但要真正想明白它为什么“保守”、为什么“需要预知未来”却需要一些时间。我这次完整实现一遍之后的体会是学操作系统不能只看概念最好每个经典算法都动手写一遍。写代码的过程会逼迫你把模糊的概念变成精确的逻辑很多“以为自己懂了”的地方一写代码就露馅了。最后再分享一个小技巧调试银行家算法时可以写一个辅助函数以表格形式打印当前的Available、Allocation和Need矩阵。每次分配或回滚后打一次整个算法的运行轨迹就一目了然排查问题比靠log靠猜快得多。如果还没写银行家算法的朋友建议局部先把表格打印函数调通再写主逻辑会顺很多。

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

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

免费获取报价 →
↑