期末前一周,我把《操作系统原理》里所有需要动手算的算法摊在一张大纸上,按“调度—内存—磁盘—同步”四摞分开摆,才发现这门课真正的难点不在概念,而在于每一个算法都有自己的一套记分规则:周转时间算不算等待、缺页次数从第一次访问开始数、寻道距离要不要算回程。这份操作系统原理算法总结,就是我当时那摞纸的整理版。它不讲抽象定义,只讲每个算法在纸面上怎么一步步算出答案、在真实系统里对应什么机制、哪些参数一动结果就全变。适合正在啃这门课的同学,也适合工作几年后需要回头补齐底层认知的开发。下面按我自己的复习顺序来,从整体地图到具体算例,中间穿插那些只有踩过才知道的坑。
1. 先把地图画出来:操作系统原理里的算法分几类
刚接触这门课的人容易陷入一个误区:把每个算法当成孤立的知识点背。背到第三个就乱了,因为页面置换的“淘汰谁”和调度算法的“谁先上”表面看是两件事,底层其实是同一个问题——资源有限,必须有一套确定的规则来决定谁占用、占用多久、什么时候让出来。把这句主线记住,后面所有算法都能挂上去。
1.1 一条主线:资源有限,谁先上
进程调度分配的是 CPU 时间,内存管理分配的是物理页框,磁盘调度分配的是磁头位置,同步机制分配的是临界区的进入权。四种资源,四种排队,四种“谁先上”的策略。它们的评价指标也高度相似:调度看平均周转时间和响应时间,内存看缺页率和访存开销,磁盘看总寻道距离,同步看是否饥饿、是否死锁。
我在复习时做了一件事:把四类算法的“输入—输出—评价指标”列成一张表贴在桌前。后来发现这张表比任何笔记都管用,因为考试和面试里最常问的恰恰是“为什么这个指标变好了、那个指标变差了”。
| 类别 | 输入 | 输出 | 核心指标 | 典型算法 |
|---|---|---|---|---|
| 进程调度 | 到达时间、服务时间、优先级 | 执行顺序、完成时间 | 平均周转、平均带权周转、响应时间 | FCFS、SJF、SRTF、RR、MLFQ |
| 内存分配 | 空闲分区表、请求大小 | 分配位置 | 碎片率、分配速度 | 首次适应、最佳适应、最坏适应、伙伴系统 |
| 页面置换 | 引用串、帧数 | 每步换出页 | 缺页次数、缺页率 | OPT、FIFO、LRU、Clock |
| 磁盘调度 | 磁头初始位置、请求柱面序列 | 服务顺序 | 总寻道距离 | FCFS、SSTF、SCAN、C-SCAN、LOOK |
| 死锁避免 | 可用资源、最大需求、已分配 | 安全序列 | 是否安全 | 银行家算法 |
这张表的价值在于它逼你回答一个问题:每个算法的“输入”到底有几个自由度。比如页面置换,帧数从 3 变成 4,缺页次数不一定减少,这就是后面要说的 Belady 异常;而磁盘调度的请求序列一旦加入回程,SCAN 和 LOOK 的差距立刻显现出来。
1.2 四类算法的输入输出长什么样
我把四类算法的输入输出再具体化一点,因为很多人失分不是不会算法,而是没看清题目给了什么。
进程调度给的是“进程—到达时间—服务时间”三列表格,有时带优先级。输出要写甘特图、完成时间、周转时间、带权周转时间。这里有个细节:带权周转时间 = 周转时间 / 服务时间,分母是服务时间,不是到达时间,也不是完成时间。我在第一次做套题时把这个分母写错过三次。
内存分配给的是空闲分区链和请求序列,输出是每次分配的起始地址与剩余空间。页面置换给的是引用串和帧数,输出是每一步的内存状态和缺页标记。磁盘调度给的是磁头当前位置和柱面请求序列,输出是访问顺序和总移动量。银行家算法给的是 Available、Max、Allocation 三张矩阵,输出是安全序列或者“不安全”的判定。
理解这些输入输出的格式,比背算法本身更重要,因为所有算错都发生在格式转换的那一步:把“完成时间”抄成“周转时间”,把“寻道距离”漏算最后一段回程,把“分配后剩余空间”写成“分配前”。
1.3 我用的“一页纸卡片法”
具体做法是:一张 A5 卡片写一个算法,正面写三行——输入、规则、输出;背面写两个例子,一个正常例、一个边界例。比如 LRU 的正面写“淘汰最久未被访问的页”,背面写“帧数为 3 的经典引用串,以及帧数为 4 时缺页反而变多的反例”。
卡片法的好处是强制你做减法。写不下就说明你没抓住核心。当时同宿舍的 A 同学把笔记抄了整整一本,考试前翻了三遍还是记不住 SCAN 和 C-SCAN 的区别;我把区别写成一句话贴在卡片上:“C-SCAN 到端点必须回起点,SCAN 是到端点掉头”,他一眼就通了。
提示:卡片背面那个“边界例”是最值钱的部分。所有算法在常规输入下都很好理解,真正的分水岭是空队列、单元素、请求恰好等于磁头位置、帧数大于引用串去重后的页数这些情况。
2. 进程调度算法:平均周转时间是一笔一笔算出来的
调度算法是这门课里最容易“看懂了但算错了”的部分。原因很朴素:规则一句话,计算要走很多步,中间任何一步的完成时间记错,后面全错。我用一组固定数据反复练了五种算法,练到能默写出每一步的甘特图。
2.1 FCFS、SJF、SRTF 的手算对照
固定数据如下,四个进程,到达时间和服务时间都在表里。
| 进程 | 到达时间 | 服务时间 |
|---|---|---|
| P1 | 0 | 8 |
| P2 | 1 | 4 |
| P3 | 2 | 9 |
| P4 | 3 | 5 |
FCFS(先来先服务)按到达顺序执行。P1 从 0 跑到 8 完成,P2 从 8 跑到 12,P3 从 12 跑到 21,P4 从 21 跑到 26。完成时间依次是 8、12、21、26,周转时间 = 完成时间 − 到达时间,得到 8、11、19、23,平均 15.25。带权周转是 8/8=1.00、11/4=2.75、19/9=2.11、23/5=4.60,平均 2.62。
FCFS 的问题一眼可见:P1 这个长作业把后面三个都堵住了,P4 只服务 5 个单位却等了 18 个单位。这就是所谓的护航效应,短作业排在大作业后面,等待时间被无限放大。现实里的表现是:你点一下鼠标,前面有个大文件拷贝在跑,界面就卡住了。
SJF(短作业优先,非抢占)每次从已到达的进程里挑服务时间最短的。t=0 只有 P1,必须跑完,到 8。此时 P2、P3、P4 都到了,服务时间是 4、9、5,选 P2 跑到 12,再选 P4 跑到 17,最后 P3 跑到 26。完成时间 8、12、17、26,周转 8、11、15、23,平均 14.25,比 FCFS 好一点。带权周转 1.00、2.75、1.67、4.60,平均 2.50。
SRTF(最短剩余时间优先,抢占式 SJF)这里要盯住每次新进程到达的时刻。t=0 P1 开始,剩余 8。t=1 P2 到达,剩余 4,小于 P1 的 7,抢占,切到 P2。t=2 P3 到达,剩余 9,P2 还剩 3,不切。t=3 P4 到达,剩余 5,P2 还剩 2,不切。t=5 P2 完成,此时 P1 剩 7、P3 剩 9、P4 剩 5,选 P4,跑到 t=10 完成。剩下 P1 剩 7、P3 剩 9,选 P1,跑到 t=17 完成。最后 P3 从 17 跑到 26。
完成时间:P2=5、P4=10、P1=17、P3=26。周转:P1=17、P2=4、P3=24、P4=7,平均 13.00。带权周转:2.13、1.00、2.67、1.40,平均 1.80。
三种算法放在一起,平均周转时间从 15.25 降到 14.25 再到 13.00,趋势很清楚:越偏向短作业,平均周转越好。但代价也清楚:P3 这个长作业从 FCFS 的 19 拖到了 24,饥饿风险随 SJF 强度上升。
2.2 时间片轮转:q 的取值决定一切
同样四个进程,时间片 q=4。
t=0 就绪队列只有 P1,跑 0 到 4,剩余 4。这期间 P2、P3、P4 分别在 1、2、3 到达,队列变成 [P2, P3, P4],P1 排到队尾,队列 [P2, P3, P4, P1]。
t=4 取 P2 跑 4 到 8,服务时间正好 4,完成。队列 [P3, P4, P1]。t=8 取 P3 跑 8 到 12,剩余 5,回队尾,队列 [P4, P1, P3]。t=12 取 P4 跑 12 到 16,剩余 1,队列 [P1, P3, P4]。t=16 取 P1 跑 16 到 20,完成。t=20 取 P3 跑 20 到 24,剩余 1,队列 [P4, P3]。t=24 取 P4 跑 24 到 25,完成。t=25 取 P3 跑 25 到 26,完成。
完成时间:P2=8、P1=20、P4=25、P3=26。周转:20、7、24、22,平均 18.25。这个数字比 SRTF 差得多,但注意响应时间:P2 在 t=4 就开始执行,P3 在 t=8,P4 在 t=12,没有任何进程等待超过一个时间片。
这就是我要强调的核心权衡:平均周转时间和响应时间经常是反向的。做题目时如果题目问“哪种算法最适合交互式系统”,答案不是周转最小的那个,而是 RR 或 MLFQ。我见过不少人把 SJF 当成万能答案,这是只看了半个指标。
q 的取值也值得算一遍。q 太大,RR 退化成 FCFS,交互性丧失;q 太小,上下文切换开销吃掉 CPU。经验公式是让 q 略大于一次典型的交互操作所需时间,同时保证一次切换开销远小于 q。假设切换开销是 0.1 ms,希望开销占比不超过 1%,那 q 至少得是 10 ms 量级。这个计算过程比死记“q 取 10 到 100 ms”有用得多。
2.3 多级反馈队列与优先级老化
MLFQ 的规则可以浓缩成四条:新进程进最高优先级队列;每个队列内部用 RR,队列级别越低时间片越长;用完整个时间片还没结束就降一级;定期把所有进程提回最高级,防止饥饿。
这四条里第四条最容易被忽略,但它恰恰是 MLFQ 能兼顾交互和吞吐的关键。没有第四条,一个 CPU 密集的长作业会一路降到最低优先级,永远拿不到 CPU。有了定期提升,它每隔一段时间能回到高优先级抢一次,交互进程的响应也不会被永久破坏。
优先级调度里的老化同理:优先级 = 基础优先级 + 等待时间 / 老化系数。比如基础优先级 10,每等 5 个单位加 1,等了 20 个单位就是 10+4=14。这个式子本身不复杂,复杂的是判断“加到什么时候该截断”,否则低优先级进程等太久会反超交互进程。
2.4 调度算法速查与常见坑
| 算法 | 抢占 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| FCFS | 否 | 实现最简单、无饥饿 | 护航效应、平均周转差 | 批处理、打印队列 |
| SJF | 否 | 平均周转接近最优 | 长作业饥饿、需预知服务时间 | 作业调度 |
| SRTF | 是 | 平均周转最优 | 切换频繁、饥饿 | 理论对照 |
| RR | 是(按时间片) | 响应时间稳定 | 周转一般、切换开销 | 分时交互 |
| 优先级 | 可抢占可不抢占 | 可表达业务权重 | 饥饿、需老化 | 实时/服务分级 |
| MLFQ | 是 | 兼顾响应与吞吐 | 参数多、难严格分析 | 通用桌面与服务器 |
坑集中在这几处:到达时间相同的进程顺序,题目没说的话一般按编号顺序,但要写清楚假设;抢占发生在“到达时刻”还是“时刻末”,SRTF 里新进程到达那一刻立即比较,不要等到当前进程跑完一个单位;带权周转的分母,永远是服务时间;平均值的分母,是进程数,不是到达的进程数。
注意:算 SRTF 时我建议画一条时间轴,每来一个新进程就在轴上标一下并写出各进程剩余时间。纯靠脑子记剩余时间,四个进程以上必错。
3. 内存管理算法:分区、分页与页面置换的账本
内存这块我分成两半复习:一半是“怎么把空间分出去”,另一半是“空间不够时怎么换”。前者是分配算法,后者是置换算法,两者的思维方式完全不同——分配看碎片,置换看访问模式。
3.1 连续分配三兄弟与伙伴系统
假设空闲分区按地址递增是 [100K 起始, 500K 起始, 200K 起始, 300K 起始, 600K 起始],大小依次是 100K、500K、200K、300K、600K。现在来了一个 120K 的请求。
首次适应从头扫描,第一个够大的就是 500K 那个,分配后剩 380K。特点是快,但低地址区会被反复切割,留下大量小碎片。最佳适应扫描全表找最小的够用分区,200K 那个最合适,分配后剩 80K。特点是碎片总量小,但会留下大量“用不掉的小洞”。最坏适应找最大的,600K 那个,分配后剩 480K,剩下的还能用,但大分区被消耗掉了。
这三种算法的对比经常被简化成“最佳适应最好”,其实不对。最佳适应用 200K 分区满足了 120K 请求,留下 80K 的空洞,这 80K 大概率再也用不上;首次适应用 500K 分区,留下 380K,下次还能塞个中等请求。碎片不只看总空闲量,还要看最大可用块。
伙伴系统是另一套思路:所有分区都是 2 的幂,请求来了就往上找最近的 2 的幂,不够就分裂,回收时看兄弟是否空闲,空闲就合并。比如请求 100K,向上取整到 128K,从 256K 的分区里分裂成两个 128K,一个分配一个留着。回收时如果兄弟也空闲,合并回 256K。这个机制的好处是合并极快,只要看地址的对应位;代价是内部碎片,请求 129K 就得占 256K。
| 算法 | 查找方式 | 外部碎片 | 内部碎片 | 回收难度 |
|---|---|---|---|---|
| 首次适应 | 顺序,首个满足 | 中,低地址集中 | 无 | 易 |
| 最佳适应 | 全表,最小满足 | 多,小空洞多 | 无 | 中 |
| 最坏适应 | 全表,最大 | 少 | 无 | 中 |
| 伙伴系统 | 按 2 的幂分裂 | 少 | 明显 | 极易(按位合并) |
3.2 页面置换四件套手算对照
引用串用最常见的那个:7, 0, 1, 2, 0, 3, 0, 4, 2, 3, 0, 3, 2, 1, 2, 0, 1, 7, 0, 1,帧数 3。
FIFO按进入顺序淘汰。逐个数下来,缺页发生在第 1、2、3、4、6、7、8、9、10、11、14、15、18、19、20 次访问,共15 次。注意第 4 次访问 2 时淘汰 7,第 6 次访问 3 时淘汰 0,第 7 次访问 0 时又淘汰 1,这种“刚淘汰的马上要用”是 FIFO 的典型病症。
LRU淘汰最久未使用的。缺页发生在第 1、2、3、4、6、8、9、10、11、14、16、18 次,共12 次。比 FIFO 少 3 次,因为它利用了时间局部性。
OPT淘汰未来最久不用的。缺页在第 1、2、3、4、6、8、9、14、18 次,共9 次。这是理论下界,无法实现,只用来做对照。
Clock是 FIFO 的改进版:每页一个访问位,扫描时遇到访问位为 1 就清零并跳过,遇到 0 就淘汰。它用一位信息近似 LRU,开销远小于真正的 LRU 链表。手算时把它当成“带二次机会的 FIFO”,缺页次数通常介于 FIFO 和 LRU 之间。
| 算法 | 缺页次数 | 实现开销 | 是否可实现 | 关键性质 |
|---|---|---|---|---|
| OPT | 9 | 无法实现 | 否 | 理论最优下界 |
| LRU | 12 | 高(需链表或栈) | 是 | 利用时间局部性 |
| Clock | 约 13 到 15 | 低(一位访问位) | 是 | LRU 的近似 |
| FIFO | 15 | 最低 | 是 | 可能出现 Belady 异常 |
3.3 Belady 异常与 Clock 改进
Belady 异常说的是:帧数增加,缺页次数反而增加。这个现象只出现在 FIFO 这类不满足“栈性质”的算法上。所谓栈性质,是指帧数为 n 时驻留的页面集合,必须是帧数为 n+1 时集合的子集。LRU 和 OPT 满足这个性质,所以帧数一多,缺页只会持平或下降。
同一个引用串 1,2,3,4,1,2,5,1,2,3,4,5,帧数 3 时 FIFO 缺页 9 次,帧数 4 时缺页 10 次。我建议亲手把这个例子算一遍,因为它是少数几个“反直觉但可验证”的结论,算过之后对“为什么内核不用纯 FIFO 做页置换”会有实感。
Clock 的改进主要是加“脏位”,形成改进型 Clock:扫描时优先淘汰(访问位 0,脏位 0)的页,其次是(0,1),遇到访问位 1 就清零。这样能减少不必要的写回,因为脏页换出要写磁盘,代价高。
提示:手算 Clock 时一定要画出指针位置。指针不画,第二轮扫描就全乱了。我习惯在帧旁边标一个小箭头,每淘汰一次挪一格。
3.4 抖动与工作集参数
抖动是指系统把大量时间花在换页上,而不是执行。判断阈值通常用“缺页率超过某个值”或者“换页时间占比超过一半”。工作集模型是它的对策:统计最近 Δ 时间内的访问页集合,只要集合大小不超过可用帧数,就不会抖动。
Δ 的取值是个经验活。太小,工作集估计不准,捕捉不到局部性;太大,工作集被高估,帧数不够用。常见做法是取 Δ 为几千次内存访问的量级,或者用缺页间隔的滑动平均来动态调整。这里的计算过程是:如果测出某进程每 1000 次访问里缺页 3 次,每次缺页处理 8 ms,那么缺页开销占比约 24 ms/1000 次访问,需要结合单次访问耗时判断是否已经抖动。
4. 磁盘调度与文件系统:寻道距离和索引结构
磁盘调度的计算量比页面置换小,但极容易在“要不要算回程”上翻车。我把磁头轨迹画在纸上,一次画一条,画完就能看出各算法的差异。
4.1 五种磁盘调度算法的磁头轨迹
磁头初始在 53,请求序列 98, 183, 37, 122, 14, 124, 65, 67。
FCFS按请求顺序走:53→98(45)→183(85)→37(146)→122(85)→14(108)→124(110)→65(59)→67(2),总计640。数值很大,因为磁头在两端来回横跳。
SSTF每次选最近的:53→65(12)→67(2)→37(30)→14(23)→98(84)→122(24)→124(2)→183(59),总计236。比 FCFS 好很多,但注意它在 14 之后突然跳到 98,中间跨越大片区域,这就是 SSTF 的饥饿问题:远端请求可能长期不被服务。
SCAN像电梯一样单向扫到底再掉头。假设向柱面增大方向扫:53→65(12)→67(2)→98(31)→122(24)→124(2)→183(59),到 183 后掉头 →37(146)→14(23),总计299。
C-SCAN单向扫描,到端点直接回起点,回程不计服务:53→65→67→98→122→124→183 这段是 130,回 0 是 183,再 0→14(14)→37(23),总计350。比 SCAN 多走了回程,但各柱面的等待时间更均匀。
LOOK和 SCAN 的区别是不走到磁道端点,扫到最远的请求就掉头。这个例子里最远请求正好是 183,所以 LOOK 和 SCAN 结果相同,都是 299。如果序列里最大的请求是 150,SCAN 要走到 199 再掉头,LOOK 到 150 就掉头,这才能看出差距。
| 算法 | 总移动量 | 优点 | 缺点 |
|---|---|---|---|
| FCFS | 640 | 公平、无饥饿 | 移动量最大 |
| SSTF | 236 | 移动量小 | 远端饥饿 |
| SCAN | 299 | 兼顾公平与效率 | 端点附近的请求等待不均 |
| C-SCAN | 350 | 等待时间最均匀 | 回程浪费 |
| LOOK | 299 | 不浪费端点行程 | 实现稍复杂 |
4.2 文件分配方式与 inode 计算
文件分配有三种:连续分配、链式分配、索引分配。
连续分配把文件放在一段连续块里,读取只需起始块号加偏移,随机访问是 O(1)。缺点是外部碎片和文件增长困难,扩展时可能要整体搬迁。
链式分配用块内指针串联,没有外部碎片,文件能随意增长。缺点是只能顺序访问,找第 n 块要走 n 次,而且指针占用块内空间。
索引分配用一个索引块存所有块号,兼顾随机访问和增长。inode 就是索引分配的典型实现,它把索引块本身也做了分级:直接指针若干、一级间接、二级间接、三级间接。
这里有个经典计算题:块大小 4 KB,每个块号 4 B,那么一个索引块能放 4096/4 = 1024 个块号。若 inode 有 12 个直接指针、1 个一级间接、1 个二级间接、1 个三级间接,最大文件大小是多少?
12 个直接指针管 12 × 4 KB = 48 KB;一级间接管 1024 × 4 KB = 4 MB;二级间接管 1024² × 4 KB = 4 GB;三级间接管 1024³ × 4 KB = 4 TB。合计约 4 TB 多一点。算这个题的关键不是乘法,而是先把“一个索引块能放多少个块号”算对,那一步错了后面全错。
4.3 一个混合计算的实例
假设块大小 1 KB,块号 4 B,inode 有 10 个直接指针,一次间接、二次间接各一。要访问文件内偏移 1 MB 处的数据,需要几次磁盘读?
一个索引块能放 1024/4 = 256 个块号。10 个直接指针覆盖 10 KB,一次间接覆盖 256 KB,累计 266 KB。1 MB 已经超过这个范围,落在二次间接里。定位过程:读 inode(1 次),读二次间接的顶层索引块(1 次),读对应的二级索引块(1 次),最后读数据块(1 次),总共 4 次。如果 inode 已经在内存里缓存,就是 3 次。
这类题的价值在于它把“文件系统布局”和“I/O 次数”连起来了。做几道之后,你再看索引节点结构图,就知道每一级间接到底省了什么、多花了什么。
5. 同步互斥与死锁:从信号量到银行家算法
同步这部分的算法不像调度那样有明确的计算流程,但它的“手算”形式是写伪代码和推演执行序列。银行家算法则是少见的有标准算式的部分,必须拿下。
5.1 信号量的三个经典模型
生产者—消费者用三个信号量:mutex 初值 1 保护缓冲区,empty 初值 n 表示空槽数,full 初值 0 表示已填槽数。
# 伪代码,仅示意顺序 producer(): while True: item = produce() P(empty) # 先申请空槽 P(mutex) # 再进临界区 put(item) V(mutex) V(full) # 通知消费者 consumer(): while True: P(full) # 先申请有数据 P(mutex) item = take() V(mutex) V(empty) # 通知生产者 consume(item)这里的顺序不能乱。如果生产者先 P(mutex) 再 P(empty),当缓冲区满时它会持有 mutex 阻塞,消费者想消费却拿不到 mutex,直接死锁。这个坑我在实验课上见过至少三次,每次都是把两个 P 的顺序写反。
读者—写者要处理的是读者优先还是写者优先。读者优先的写法是第一个读者进来时 P(wrt),最后一个读者离开时 V(wrt)。如果读者源源不断,写者会饿死。写者优先要加一个额外的信号量拦住后续读者。
哲学家进餐的经典解法是给叉子编号,奇数号先拿左再拿右,偶数号先拿右再拿左,破坏循环等待条件。另一种是限制同时只能有四位哲学家拿叉子,用信号量初值 4 控制。
5.2 银行家算法手算流程
给一组数据。Available = (3, 3, 2),Max 和 Allocation 如下。
| 进程 | Max | Allocation | Need |
|---|---|---|---|
| P0 | 7 5 3 | 0 1 0 | 7 4 3 |
| P1 | 3 2 2 | 2 0 0 | 1 2 2 |
| P2 | 9 0 2 | 3 0 2 | 6 0 0 |
| P3 | 2 2 2 | 2 1 1 | 0 1 1 |
| P4 | 4 3 3 | 0 0 2 | 4 3 1 |
Need = Max − Allocation,逐个减。P0 是 7-0=7、5-1=4、3-0=3。
Work 初值取 Available = (3,3,2)。第一轮找 Need ≤ Work 的进程。P1 的 Need (1,2,2) ≤ (3,3,2),可以。P1 结束后归还,Work = (3,3,2)+(2,0,0) = (5,3,2)。把 P1 记入序列。
第二轮,Need ≤ (5,3,2) 的有 P3 的 (0,1,1),还有 P4 的 (4,3,1) 因为 4>5 不成立?4 ≤ 5 成立,3 ≤ 3 成立,1 ≤ 2 成立,所以 P4 也可以。这里就有两条路。
走 P3:Work = (5,3,2)+(2,1,1) = (7,4,3)。此时 P0 的 Need (7,4,3) ≤ (7,4,3),成立;P4 的 (4,3,1) 也成立;P2 的 (6,0,0) 也成立。三条路都可走。
取 P4:Work = (7,4,3)+(0,0,2) = (7,4,5)。再取 P0:Work = (7,4,5)+(0,1,0) = (7,5,5)。最后 P2:Need (6,0,0) ≤ (7,5,5),Work = (10,5,7)。全部完成。
所以安全序列 P1、P3、P4、P0、P2 成立。P1、P3、P4、P2、P0 同样成立。做这类题要注意:安全序列不唯一,写出任意一条即可,但检查过程要写全,否则阅卷时会被认为跳步。
如果此时 P0 请求 (0,2,0),怎么判断?先看 Request ≤ Need(0≤7,2≤4,0≤3,成立),再看 Request ≤ Available(2≤3,成立),然后试探性分配:Available 变成 (3,1,2),P0 的 Allocation 变 (0,3,0),Need 变 (7,2,3)。再做安全性检查,如果找不到安全序列,就回滚,让请求方等待。
| 检查步骤 | 条件 | 不满足时的处理 |
|---|---|---|
| 合法性 | Request ≤ Need | 报错,超出最大需求 |
| 可用性 | Request ≤ Available | 等待资源 |
| 试探分配 | 修改 Available/Allocation/Need | — |
| 安全性检查 | 存在安全序列 | 回滚,恢复原状态 |
5.3 死锁的排查顺序
死锁四个必要条件:互斥、占有并等待、不可抢占、循环等待。排查时我按以下顺序看:先看有没有人同时持有多个资源(占有并等待),再看资源能不能被强制收回(不可抢占),再看资源分配图有没有环(循环等待)。互斥一般是资源本身的性质,改不了,所以实际操作都是破坏后三个条件。
银行家算法破坏的是“占有并等待”里最危险的组合,它要求进程一次性声明最大需求,由系统动态判断。代价是开销大、需要预知需求,实际内核很少用完整版,更多用资源有序分配(破坏循环等待)加上超时回退。
注意:判断安全状态时,Work 每次都要加上“当前进程的 Allocation”,不是加上 Need,也不是加上 Max。这一步我写错过,导致整张表重算。
6. 常见问题与排查技巧实录
这一节是我自己踩过的坑和帮别人排查过的问题汇总,按类型分。
6.1 手算高频错误清单
| 现象 | 可能原因 | 排查方法 |
|---|---|---|
| 平均周转时间比预期大 | 把到达时间当成了等待时间 | 重算周转 = 完成 − 到达 |
| SRTF 结果和答案对不上 | 新进程到达时刻没立即比较 | 在时间轴标注每次到达 |
| 缺页次数多算一次 | 把第一次访问也算成“置换” | 首次装入算缺页,但不换出 |
| FIFO 与 LRU 缺页次数相同 | 引用串太短,没区分度 | 换长引用串重算 |
| SCAN 总距离偏小 | 漏算掉头后的回程 | 画出完整轨迹 |
| 银行家算法判不安全 | Work 累加用了 Need | 严格用 Allocation 累加 |
| 带权周转异常大 | 分母用了周转时间 | 分母是服务时间 |
我特别想说第三条。页面置换里“缺页”和“置换”是两个概念:前 n 次访问如果帧没满,直接装入,算缺页但不产生置换。有些人统计时把置换次数当缺页次数,结果整体少算。题目问缺页就数缺页,问置换就数置换。
6.2 实验与代码层面的坑
做模拟实验时,最容易出问题的是时间片的边界处理。RR 模拟里,一个进程在时间片用完的瞬间完成,这时候不能把它再放回队列,否则队列里会多出一个已完成的进程,后续统计全错。我在写模拟器时遇到过一次,输出的平均周转时间比手算大了 0.5,查了半天才发现是这个。
第二个坑是浮点数比较。算平均周转时间用整数累加再除,不要每一步都做除法,否则误差累积,最后和答案差 0.01 就被判错。
第三个坑是信号量的 P/V 顺序。前面说过生产者的例子,读者—写者里也有类似问题:第一个读者判断“是不是第一个”时,那个计数的增减必须在临界区内完成,否则两个读者同时进来都会认为自己是第一个,wrt 被 P 两次,写者永远进不来。
第四个坑是并发模拟的时序。用多线程模拟哲学家进餐时,如果不用随机退避,很容易每次都跑出同一个死锁序列,看起来“必现”,其实是调度太规律。加一点随机 sleep 反而更容易复现真实问题。
6.3 复习节奏与自测方法
我的节奏是三遍。第一遍按章节过,只求看懂规则,不求算对;第二遍拿同一组数据把五个调度算法、四个置换算法、五个磁盘算法全部手算一遍,对照答案找差异;第三遍只看错题,把错的原因写成一句话贴在卡片背面。
自测的方法很简单:合上书,随机抽一个算法,用自己编的数据算一遍,再换个数据算第二遍。两个不同数据都能算对,才算真的会。只看懂不算会,因为这门课的算法都是“规则一句话,执行几十步”,看懂和算对之间隔着一个熟练度。
还有一个小技巧:把所有算法按“是否需要预知未来”分类。OPT 需要预知未来引用串,SJF 需要预知服务时间,LRU 只需要看过去,FIFO 只看进入顺序。这个分类一做,你就能理解为什么 OPT 和 SJF 在现实中都不能直接用,为什么教材把它们放在最前面当基准——它们是用来衡量其他算法“离最优有多远”的尺子。
最后分享一个我自己受益最多的习惯:每次算完一道题,不管对错,都在旁边写一行“这题的陷阱是什么”。写满二十道之后你会发现,陷阱翻来覆去就那么几个——时刻边界、分母、回程、首次装入、Work 累加。把这五个词背下来,比背十个算法定义管用得多。