简介:操作系统银行家算法实验报告与配套源代码,面向学习操作系统课程、需要理解死锁避免与资源分配策略的高校学生及自学者。资源围绕Dijkstra提出的银行家算法展开,可用于课程实验、算法复现与安全性检查练习。压缩包共5个文件,以C++源文件与头文件为主,另含实验报告文档和初始化数据文本,整体约452KB,便于直接编译运行并对照报告梳理Max、Available、Allocated、Needs等数据结构的关系。已有5251人学习,说明其在同类实验资料中具有较高参考价值。配套代码覆盖进程管理、资源申请与释放、模拟循环及安全性检查,能帮助读者观察安全序列构造过程,并针对资源分配不当、资源泄露、安全性检查错误等常见问题形成排错思路;实验报告还对算法原理、数据结构定义和测试结果作了整理。
1. 从一次真实的死锁翻车说起:这套银行家算法实验要交付什么
操作系统课设里,死锁这一章最容易糊弄过去,也最容易在答辩现场翻车:概念背得滚瓜烂熟,真给一组资源快照让手推安全序列,笔尖就顿住了。这套银行家算法实验报告与源代码,解决的就是这个断层——把 Available、Max、Allocation、Need 这几个抽象矩阵,变成一个能读入快照、能打印安全序列、能对每次资源请求给出“准予/阻塞/拒绝”结论的可运行程序,同时配一份结构完整的实验报告。它适合正在做操作系统课程设计、需要当场演示的读者,也适合想把死锁避免从“背算法”改成“跑算法”的教学场景。下面按需求、数据结构、核心算法、交互与报告、避坑、验收六段拆开讲。
2. 数据结构与状态建模:把“银行家”翻译成四个矩阵加一个向量
银行家算法的代码量其实不大,一百多行就能跑通,但它对状态一致性极为敏感——任何一个矩阵在某个分支里漏更新一次,后面所有安全性判断就全成了玄学。因此这一章不讲算法本身,先讲清楚“状态存在哪、由谁维护、什么时候重算”,这是整份实验报告里最能体现设计能力的部分。
2.1 先写清楚前提假设,再动代码
银行家算法不是通用的死锁解决方案,它成立依赖一组很强的假设,这些假设必须写在报告的问题分析一节里,而不是藏在代码注释中:
- 每个进程在进入系统前就必须声明它对每类资源的最大需求量,运行过程中这个数字不能改;
- 进程之间相互独立,不存在因为同步顺序才产生的循环等待;
- 系统资源总量固定,运行期间不增加也不销毁。
这三条只要缺一条,安全性检查算出来的“安全”就只是幻觉。我一般会在报告里用三行字点明假设,再补一句“本实验处理的是进程数固定、需求预声明、多类资源单实例的情形”,这样后面所有结论都有了适用范围。
还有一个必须提前说明的点:安全序列通常不唯一。同一组快照,从不同的进程下标开始扫描,可能得到 P1→P3→P4→P0→P2,也可能得到 P1→P3→P4→P2→P0。两条都合法,程序只需要输出其中一条,报告里要写明“本实现按进程编号升序扫描,故输出的是字典序最小的一条”,否则答辩时被问一句“为什么和我算的不一样”就要卡壳。
2.2 四个矩阵一个向量,各自由谁写入
这一层用一张表说清楚,比写十行注释管用:
| 名称 | 维度 | 含义 | 写入时机 |
|---|---|---|---|
| Available | 1×m | 系统当前各类空闲资源数 | 初始快照读入;每次分配成功或回收后更新 |
| Max | n×m | 每个进程对每类资源的最大需求 | 只在初始快照读入时写一次,之后只读 |
| Allocation | n×m | 每个进程当前已持有的资源 | 初始快照读入;试探分配与回滚时更新 |
| Need | n×m | 每个进程还需要的资源,Need = Max − Allocation | 由前两者推导,随分配同步维护 |
| Request_i | 1×m | 第 i 个进程本次的请求向量 | 每次请求时由交互层输入 |
这里有个高频错误:把 Need 当成独立输入,让使用者手工敲一遍。短期看没毛病,一旦 Max 或 Allocation 在测试过程中被改动,Need 立刻和它们对不上,程序就开始输出莫名其妙的安全序列。稳妥的做法是二选一——要么 Need 只读,由一个recompute_need()在每次打印状态和每次安全性检查之前重算;要么维护 Need,但把所有会改动它的语句收敛到try_allocate()和rollback()两个函数里,别的地方一律不许碰。
提示:报告里的“数据结构设计”小节建议直接放这张表,再配一句“Need 为推导量,不单独作为输入”,这是评审最容易认可的设计说明。
2.3 接口与快照文件格式
把数据结构和接口单独放在一个头文件里,好处是报告里可以按“数据结构—功能函数—主流程”三层讲,代码截图也不会糊成一坨:
/* banker.h —— 数据与接口声明,报告第四章可直接引用 */ #define PROC_NUM 5 /* 进程数,报告里需说明为何取 5 */ #define RES_NUM 3 /* 资源类数,对应 A/B/C 三类 */ extern int Available[RES_NUM]; extern int Max[PROC_NUM][RES_NUM]; extern int Allocation[PROC_NUM][RES_NUM]; extern int Need[PROC_NUM][RES_NUM]; int read_snapshot(const char *path); /* 读初始快照,成功返回 0 */ void recompute_need(void); /* Need = Max - Allocation */ void print_state(void); /* 打印四张表,供报告截图 */ int is_safe(int seq[]); /* 安全性检查,安全返回 1 */ int request(int pid, const int req[]);/* 处理一次资源请求,返回状态码 */PROC_NUM和RES_NUM用宏而不是硬编码数字,是因为测试环节要换快照——把 5 进程 3 资源换成 4 进程 2 资源做对照实验时,只改这两个宏即可,函数体一行都不用动。这一点写进报告能加不少分。
初始状态不从终端一个个敲,改成读文本文件。原因是答辩现场要临时改数据,改文件里一行再重跑,比在终端连敲 15 个数字再祈祷没敲错靠谱得多:
5 3 3 3 2 7 5 3 3 2 2 9 0 2 2 2 2 4 3 3 0 1 0 2 0 0 3 0 2 2 1 1 0 0 2第一行是进程数与资源类数,第二行是 Available,接下来 5 行 Max,最后 5 行 Allocation。读取时用fscanf的返回值做校验,读到一半失败就报错退出,不要让半截数据进内存:
int read_snapshot(const char *path) { FILE *fp = fopen(path, "r"); if (!fp) return -1; int n, m; if (fscanf(fp, "%d %d", &n, &m) != 2) { fclose(fp); return -1; } /* 文件声明的规模必须和编译期常量一致,否则直接拒绝 */ if (n != PROC_NUM || m != RES_NUM) { fclose(fp); return -2; } for (int j = 0; j < RES_NUM; j++) if (fscanf(fp, "%d", &Available[j]) != 1) { fclose(fp); return -1; } for (int i = 0; i < PROC_NUM; i++) for (int j = 0; j < RES_NUM; j++) if (fscanf(fp, "%d", &Max[i][j]) != 1) { fclose(fp); return -1; } for (int i = 0; i < PROC_NUM; i++) for (int j = 0; j < RES_NUM; j++) { if (fscanf(fp, "%d", &Allocation[i][j]) != 1) { fclose(fp); return -1; } if (Allocation[i][j] > Max[i][j]) { fclose(fp); return -3; } } fclose(fp); recompute_need(); return 0; }这段的关键不在读取,而在两个校验:规模校验拦住“文件写了 4 个进程、程序按 5 个分配”的越界,Allocation > Max校验拦住让 Need 变成负数的非法快照。少了这两处,后面安全性检查会读出负数,然后神奇地判定“安全”,查起来相当痛苦。
3. 核心算法实现:安全性检查与请求处理的两段式结构
整份代码真正有含金量的只有两个函数。安全性检查回答“当前这个状态还能不能走通”,请求处理回答“这个请求能不能给”。前者是后者的子过程,把两者的边界划清楚,代码就不会互相污染。
3.1 安全性算法的推演过程
安全性检查的思路是模拟:假设所有进程现在都停下来,系统按某种顺序逐个把资源喂给它们,看能不能全部喂完。能喂完就安全,中途卡住就不安全。
变量只有两组:Work是“如果现在开始按这个顺序跑,系统手里还有多少资源”,初值等于 Available;Finish标记每个进程是否已被假定完成。每一轮扫描所有未完成的进程,找出一个 Need 不超过 Work 的,把它的 Allocation 还回 Work,标记完成,记进安全序列;一轮下来一个都没找到,剩下的一律推不动,判定不安全。
/* 安全性检查:安全返回 1 并把安全序列写入 seq,不安全返回 0 */ int is_safe(int seq[]) { int Work[RES_NUM]; int Finish[PROC_NUM] = {0}; int count = 0; for (int j = 0; j < RES_NUM; j++) Work[j] = Available[j]; while (count < PROC_NUM) { int found = 0; /* 本轮是否找到可满足的进程 */ for (int i = 0; i < PROC_NUM; i++) { if (Finish[i]) continue; int ok = 1; for (int j = 0; j < RES_NUM; j++) if (Need[i][j] > Work[j]) { ok = 0; break; } if (!ok) continue; for (int j = 0; j < RES_NUM; j++) Work[j] += Allocation[i][j]; Finish[i] = 1; seq[count++] = i; found = 1; /* 注意:这里不 break,本轮继续往后扫描, 让编号靠前的进程优先进入安全序列,输出更稳定 */ } if (!found) break; /* 一整轮无人可满足,提前退出 */ } return count == PROC_NUM; }几个细节值得在报告里点出来。第一,found这个标志不能省,它是“一整轮无进展”的唯一证据;靠count是否增长来判断也行,但可读性差。第二,内层找到可满足进程后不break,而是继续扫完本轮,这样输出的安全序列在同一份快照上永远一致,方便写进报告做对照。第三,Finish数组必须每调用一次就重新清零,如果写成static或者全局变量,第二次调用时上一轮的结果还在,就会出现“明明不安全却判定安全”的诡异现象。
复杂度是 O(n²m):外层最多 n 轮,每轮扫 n 个进程,每个进程比较 m 类资源。5 进程 3 资源的数据规模下完全无感,但报告里把这个式子写出来,说明你知道它在进程数上去之后会变慢。
3.2 资源请求处理的四个返回分支
请求处理是银行家算法的核心逻辑所在。一个请求进来,要依次过四道关卡,任何一道不过就返回对应状态码,一道都过了才真正修改状态。
第一道:请求量是否超过该进程声明的剩余需求。超了就是非法请求,程序员的错,直接报错。
第二道:请求量是否超过系统当前可用。超了不是错误,是正常现象——进程得排队等,返回“阻塞”。
第三道是试探性分配:先把资源真给出去,改 Available、Allocation、Need 三张表。
第四道是安全性检查:如果分配之后系统不安全,必须把刚才三张表的修改原样退回去,这就是回滚。
/* 返回 1 成功;0 资源不足需等待;-1 超过最大需求;-2 不安全已回滚 */ int request(int pid, const int req[]) { /* 关卡一:不能超过该进程声明的剩余最大需求 */ for (int j = 0; j < RES_NUM; j++) if (req[j] > Need[pid][j]) return -1; /* 关卡二:不能超过系统当前可用资源 */ for (int j = 0; j < RES_NUM; j++) if (req[j] > Available[j]) return 0; /* 关卡三:试探性分配,三张表一起改 */ for (int j = 0; j < RES_NUM; j++) { Available[j] -= req[j]; Allocation[pid][j] += req[j]; Need[pid][j] -= req[j]; } /* 关卡四:分配后是否仍存在安全序列 */ int seq[PROC_NUM]; if (is_safe(seq)) return 1; /* 安全,分配正式生效 */ /* 不安全,把三张表原样退回去,一条语句都不能少 */ for (int j = 0; j < RES_NUM; j++) { Available[j] += req[j]; Allocation[pid][j] -= req[j]; Need[pid][j] += req[j]; } return -2; }回滚这一段是整份代码最容易写错的地方,因为它是“对称操作”,看起来啰嗦,很容易手滑只回滚两张表。漏回滚 Need 的后果是:这次请求被拒绝了,但进程的剩余需求凭空少了一块,下一次它请求同样的量会被判成“超过最大需求”,逻辑就彻底乱了。血泪经验是把这个函数写成“三张表永远同进同退”,任何一张表的新增修改都必须在这一段里成对出现。
把返回码整理成表放进报告,测试结果一节会非常好写:
| 返回码 | 含义 | 程序动作 |
|---|---|---|
| 1 | 分配成功 | 打印本次安全序列,状态保留 |
| 0 | 当前可用资源不足 | 进程阻塞等待,状态不变 |
| -1 | 请求超过声明的最大需求 | 报错,视为非法请求 |
| -2 | 试探分配后系统进入不安全状态 | 回滚三张表,拒绝分配 |
4. 交互层与实验报告:让程序可演示,让报告可复现
算法写对只是及格线。课程设计真正拉开差距的是两件事:程序能不能在别人手里跑出同样的结果,报告能不能让评审在三分钟内看懂你做了什么。这一章解决这两个问题。
4.1 菜单循环与输出格式
交互层不需要做得多漂亮,但必须做到“一个动作一个明确回显”。菜单给四个选项:查看当前状态、发起资源请求、重新载入快照、退出。查看状态时把四张表按固定列宽对齐打印,方便直接截图贴进报告。
void menu_loop(void) { int choice; while (1) { printf("\n1.查看状态 2.发起请求 3.重载快照 0.退出\n请选择: "); if (scanf("%d", &choice) != 1) { /* 非数字输入,清空缓冲区防死循环 */ while (getchar() != '\n'); continue; } if (choice == 0) break; if (choice == 1) { print_state(); continue; } if (choice == 2) { int pid, req[RES_NUM]; printf("进程号(0-%d) 与 %d 个请求量: ", PROC_NUM - 1, RES_NUM); if (scanf("%d", &pid) != 1 || pid < 0 || pid >= PROC_NUM) { printf("进程号非法\n"); continue; } int bad = 0; for (int j = 0; j < RES_NUM; j++) if (scanf("%d", &req[j]) != 1 || req[j] < 0) bad = 1; if (bad) { printf("请求量非法\n"); continue; } int r = request(pid, req); print_result(pid, req, r); /* 按返回码打印不同结论 */ continue; } if (choice == 3) { read_snapshot("snapshot.txt"); printf("已重载\n"); } } }scanf的返回值必须判断,这是 C 语言交互程序的老坑:用户手滑敲一个字母,scanf读失败但不消费输入,缓冲区里那个字符还在,下一轮循环立刻又读失败,屏幕上刷出一片菜单却不接受任何输入。加一句while (getchar() != '\n');清空到行尾,问题就没了。这个细节写进报告的“问题与改进”一节,比写十句“程序健壮性良好”有说服力。
print_result按返回码分支输出,重点是 -2 那一条要把“已回滚”三个字打出来,并且紧跟着打印回滚后的状态,让演示者一眼看出系统回到了分配前的样子。
4.2 五组测试快照,覆盖四个分支
测试用例不是随便敲几个数字,而要保证每个返回码至少被触发一次。下面这五组是我常用的组合,全部基于经典的五进程三资源初始快照:
| 编号 | 操作 | 预期结论 | 覆盖分支 |
|---|---|---|---|
| T1 | 载入初始快照后查看状态 | 安全,序列为 P1→P3→P4→P0→P2 | 安全性检查为真 |
| T2 | P1 请求 (1,0,2) | 准予,返回 1 | 正常分配 |
| T3 | P2 请求 (6,0,0) | 阻塞等待,返回 0 | 可用资源不足 |
| T4 | P4 请求 (3,3,0) | 拒绝并回滚,返回 -2 | 试探后不安全 |
| T5 | P0 请求 (8,0,0) | 报错,返回 -1 | 超过最大需求 |
T1 的推演过程值得在报告里完整写一遍:初始 Available 为 (3,3,2),先满足 P1 的 Need (1,2,2),回收后 Work 变成 (5,3,2);再满足 P3 的 (0,1,1),Work 变成 (7,4,3);接着 P4 的 (4,3,1) 通过,Work 变成 (7,4,5);P0 的 (7,4,3) 通过,Work 变成 (7,5,5);最后 P2 的 (6,0,0) 通过,Work 变成 (10,5,7),五个进程全部完成。这五行推演就是报告“结果分析”一节的正文。
T4 是最有价值的一组,因为它能证明回滚是真的生效了。执行完 T4 之后立刻打印状态,四张表必须和 T1 结束时逐字节一致;如果 Allocation 或 Need 有任何一格对不上,说明回滚写漏了。
注意:每组用例之间要重新载入快照,否则 T3 留下的阻塞状态会污染 T4 的判定,测试结果就没法复现了。
4.3 实验报告的章节骨架
程序跑通之后,报告按这八节组织,每一节对应代码里的一个模块,写起来不费劲,读起来也顺:
第一节实验目的,两段话说明为什么做死锁避免而不是死锁检测;第二节实验环境,编译器版本和操作系统如实写;第三节问题描述与算法原理,把 2.1 的三条前提假设和四个矩阵的定义放这里;第四节总体设计,用文字描述四层结构——数据层负责快照文件与四个矩阵,算法层是recompute_need、is_safe、request三个函数,交互层是菜单循环与打印,测试层是五组用例脚本;第五节详细实现,每个核心函数贴代码加注释说明,重点解释 Work/Finish 的含义和回滚的对称性;第六节测试与结果分析,把 4.2 那张表连同每组的输入输出截图贴全;第七节问题与改进,写清银行家算法的局限——需要预声明最大需求、实际系统难以做到、安全序列不唯一、只适用于资源类数不多且相对固定的场景;第八节参考文献。
第四节到第六节是评分主体,篇幅占比不要低于六成。第七节反而最容易被忽略,但它是唯一能体现你“知道这个方法边界在哪”的地方,务必写满。
5. 避坑与排查:银行家算法实验里最容易翻车的五个点
这一节的每一条都是我在看别人代码时反复遇到的,现象、原因、解决三件套写清楚,对着排查就行。
5.1 算法逻辑类的三处翻车
现象一:程序对任何快照都判定“安全”,包括那些明显会卡死的。原因基本是Finish数组没清零。它如果被写成全局变量或者static,第一次调用把所有位置置 1 之后,第二次调用时所有进程在扫描一开始就是“已完成”状态,while循环连一次都没进,count直接从 0 开始累计——等等,count是局部变量,会从 0 重新数,但因为所有进程都被continue跳过,循环空转到count永远到不了PROC_NUM,最后返回 0。所以更常见的表现是“永远判定不安全”。两种表现都对,取决于count放在哪。解决很简单:Finish必须定义在函数内部,用= {0}初始化,每次调用重新分配。
现象二:安全序列里出现重复进程号,或者长度超过进程数。原因是seq[count++] = i的执行位置放在了Finish[i] = 1之前,或者外层while的条件写成了while (1),靠break退出但count没有边界保护。解决是把seq的写入紧跟在Finish置位之后,并在循环条件里保留count < PROC_NUM,让它自己停下来。
现象三:请求被拒绝之后,下一次同样的请求反而成功了。这是回滚只回了两张表的典型症状。减少一张表(比如漏回滚 Need),下一次进来时 Need 偏小,req[j] > Need[pid][j]这道关卡更容易通过,于是就“通过”了。排查方法很原始但很有效:在request函数的出口和入口各打印一次完整状态,比对这两次打印是否完全一致,对不上就说明回滚不完整。
5.2 工程实现类的两处翻车
现象四:用scanf读进程号时只要输错一次,程序就再也不接受输入了,屏幕上疯狂刷菜单。原因在 4.1 已经解释过,scanf读失败不消费缓冲区。解决办法是每次scanf返回非预期值时调用while (getchar() != '\n');把当前行剩余字符清掉,然后再continue。这个坑不踩过一次很难自己想明白,属于典型的黑匣子式 bug——程序没崩,但行为完全不符合直觉。
现象五:换一份快照就段错误。最常见的原因是进程数或资源类数在文件里被改了,但代码里还是按宏的旧值分配数组。比如文件写 6 个进程,read_snapshot照单全收往Max[5][3]里塞,越界写得悄无声息。解决就是 2.3 里那段规模校验:文件声明的 n、m 跟PROC_NUM、RES_NUM不一致就直接返回错误码,宁可启动失败也不要带病运行。
提示:这五个问题里,前三个属于算法理解问题,后两个属于工程习惯问题。报告里把它们写进“问题与改进”,说明你是调试出来的而不是抄出来的。
6. 验收与答辩:三个动作证明你的程序真的不会死锁
答辩现场时间有限,评审不会看你从头读代码,通常就三个动作。把这三个动作练熟,比多写两百行注释管用。
第一个动作:让他指定一份快照,你当场手推 T1 的安全序列,然后在程序里跑一遍,两边对上。这一步考的是算法理解,不是代码。手推的时候不要慌,按“Work 初值 = Available,找 Need ≤ Work 的进程,回收它的 Allocation”三步走,每一步把 Work 念出来,评审就知道你确实会。
第二个动作:跑 T4,让他看着你输入一个会导致不安全的请求,然后你立刻打印状态证明系统原样退回去了。这是最能体现实现质量的一步,因为太多人的回滚是残缺的。提前把回滚前后的状态截图准备好,现场跑不出来的时候能顶上。
第三个动作:改文件里的一个数字,重跑 T1,让他看到结论跟着变。这一步证明程序不是把答案硬编码进去的——银行家算法实验里硬编码安全序列是常见操作,评审心里都有数,主动演示一次数据驱动,比解释十句有效。
验收标准可以按下面这张表自查,五项全过基本就是满分往上的水平:
| 检查项 | 通过标准 |
|---|---|
| 安全性检查 | 五组用例结论与手推一致,安全序列无重复、长度正确 |
| 请求四分支 | 返回码 1、0、-1、-2 各被触发至少一次 |
| 回滚完整性 | 拒绝分配前后,四张表逐格一致 |
| 数据驱动 | 修改快照文件后结论随之变化,无需重新编译 |
| 报告对应 | 每一节都能在代码里找到对应函数,反之亦然 |
最后一项最容易被忽视:报告里的每一段描述都应该能在代码里落到具体函数上,代码里的每个函数也应该在报告里被提到。有次我把print_state写得很完整但报告里只字未提,被问了一句“这个函数实现了什么设计意图”就哑了。从那以后我每次交实验报告前都强制走一遍“报告翻到哪一节、代码就翻到哪个函数”的对照,对不上的当场补。希望帮到你。
本文还有配套的精品资源,点击获取