ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

操作系统习题答案精讲:进程状态转换、并发交错与银行家算法推演

操作系统习题答案精讲:进程状态转换、并发交错与银行家算法推演

简介:这份资源是《操作系统精髓与设计原理》第八版课后习题的参考答案文档,面向正在学习操作系统课程的高校学生、考研复习者以及需要巩固理论基础的开发者,帮助解决课后练习无从下手、答案难以验证的问题。压缩包内共1个docx文件,约904KB,内容按章节组织,覆盖进程管理、进程调度、资源管理与并发编程等核心模块。其中进程管理部分梳理了就绪、运行、阻塞与非常驻态之间的转换事件;调度部分给出兼顾优先级与性能的中间策略;资源管理结合具体时刻表分析进程状态变化;并发编程则通过共享变量与并行程序案例,推导输出顺序及tally变量的上下界范围。目前已有7515人学习下载,适合作为课后对照、考前复盘与概念查漏补缺的参考材料。

1. 从一份习题答案说起:进程状态机到底怎么跑

很多人复习操作系统时,卡在进程状态转换图这一步。教材上画得清清楚楚,就绪、运行、阻塞、挂起四个圈,箭头来回指,可一到做题就懵——某个时刻到底该在哪个状态?这份《操作系统第八版习题答案》把这类问题拆得很细,从 3.1 的状态转换事件到 3.2 的多进程时刻状态推演,再到 5.x 的并发程序交错执行分析,基本覆盖了进程管理、调度、互斥、死锁这几块硬骨头。它适合正在啃操作系统教材、需要对照习题验证思路的人,也适合想通过具体题目理解 PV 操作、银行家算法、compare&swap 这些机制的开发者。下面我按“先看懂状态模型,再动手推时刻表,最后避开并发陷阱”的顺序,把这份答案里最值得反复琢磨的几个点拆开讲。

2. 进程状态转换:从 3.1 的六条事件到 3.2 的时刻推演

2.1 状态转换的六条事件不是背下来的,是推出来的

习题 3.1 要求列出就绪态、运行态、阻塞态、非常驻态之间每个转换的引发事件。答案给了六条,但很多人只记结论,不理解为什么。我一般会按“谁主动、谁被动”来分:就绪到运行是分派器主动选人,运行到就绪是时间片到了或被更高优先级抢占,运行到阻塞是进程自己申请资源或等 I/O,阻塞到就绪是等待的事件完成了。剩下两条涉及非常驻态——就绪到非常驻、阻塞到非常驻,本质都是内存装不下,系统把进程换出到磁盘挂起。

这里有个容易翻车的点:非常驻态不是“死掉”,而是被换出后还在竞争资源,只是暂时不在内存。理解这一点,3.2 的时刻表才不会推错。

2.2 3.2 时刻推演:把事件表转成进程状态矩阵

3.2 给了一串时刻和事件,要求写出 P1 到 P8 在时刻 22、37、47 的状态。答案用了一个紧凑的表格,但直接看容易晕。我建议自己画一张“时刻-进程-状态”的三维表,按事件顺序逐行填。下面用 Python 模拟这个推演过程,把每个时刻的状态变化落成代码,方便你改参数验证。

# 模拟 3.2 的进程状态推演 # 状态简写:R=就绪, B=阻塞, D=运行, s=挂起就绪, E=结束 # 初始时刻 5 之前,假设 P1~P8 都在就绪态 processes = {f'P{i}': 'R' for i in range(1, 9)} # 记录每个进程等待的事件 wait_events = {} def set_state(pid, state, event=None): processes[pid] = state if event: wait_events[pid] = event else: wait_events.pop(pid, None) # 按题目事件顺序推演 set_state('P1', 'B', '读磁盘单元3') # 时刻5 set_state('P5', 'R') # 时刻15 时间片结束 set_state('P7', 'B', '写磁盘单元3') # 时刻18 set_state('P3', 'B', '读磁盘单元2') # 时刻20 set_state('P5', 'B', '读磁盘单元3') # 时刻24 set_state('P5', 's', '换出') # 时刻28 set_state('P3', 'R') # 时刻33 读完成中断 set_state('P2', 'R') # 时刻36 读完成中断 set_state('P8', 'E') # 时刻38 结束 set_state('P5', 'R') # 时刻40 写完成中断(注意:P5之前被换出,这里答案标为R->D的过渡) set_state('P5', 'R') # 时刻44 调入P5 set_state('P7', 'R') # 时刻48 写完成中断 # 输出三个时刻的快照 for t in [22, 37, 47]: print(f'--- 时刻 {t} ---') for pid in sorted(processes.keys()): state = processes[pid] event = wait_events.get(pid, '') print(f'{pid}: {state} {event}')

这段代码的关键在于:每个事件只改一个进程的状态,但中断事件可能让多个进程同时变化。比如时刻 33 的读完成中断让 P3 从阻塞变就绪,时刻 36 的读完成中断让 P2 从阻塞变就绪。参数wait_events记录阻塞原因,方便回答“若阻塞,写出等待的事件”。实际做题时,时刻 22 的快照是 P1 阻塞、P5 阻塞、P7 阻塞、P3 阻塞,其余就绪;时刻 37 时 P8 已结束,P5 被换出成挂起就绪;时刻 47 时 P5 已调入,P7 还在等写完成。这些细节在答案表格里用符号表示,代码跑一遍就清楚了。

提示:3.2 的答案表格里用了->表示状态迁移,自己推的时候最好用不同颜色标出每个时刻的“新变化”,否则容易把之前的状态覆盖掉。

2.3 3.5 的中间策略:优先级和交换开销怎么折中

3.5 问的是:就绪/挂起态进程优先级高于所有就绪态进程时,两种极端策略——总选就绪态(减少交换)或总选最高优先级(可能不必要交换)——都不好,给个中间策略。答案的思路是给就绪/挂起态进程降一两个优先级,只有它比就绪态最高优先级还高出几个级别时才选它。这个策略在真实调度器里很常见,本质是给“换入”操作加一个惩罚因子,避免为了高优先级频繁换页。参数怎么定?一般看换入换出的开销,如果换入一次相当于 2 个时间片的开销,那就降 2 级。这个思路在数据库连接池、线程池的调度里也能看到影子。

3. 并发程序交错执行:5.3 和 5.4 的推演方法

3.1 5.3 的 x is 10 和 x is 8:指令级交错怎么排

5.3 给两个进程 P1 和 P2,各自循环做x = x-1; x = x+1; if(x != 10) printf。单处理器上伪并行,要求给出打印 “x is 10” 和 “x is 8” 的顺序。答案把每条 C 语句拆成机器指令:载入、减一、存储、载入、加一、存储。要打印 10,说明在判断时 x 恰好是 10,但中间被另一个进程改了又改回来;要打印 8,说明两个进程的减操作叠加了。

我一般用状态表来推:把 x 的当前值、两个进程的寄存器值、下一条指令列成表,逐步走。下面用 Python 模拟一个可能的交错序列,打印出每一步的 x 和寄存器。

# 模拟 5.3 两个进程交错执行,寻找打印 x is 10 和 x is 8 的序列 # 每条高级语句拆成:LD(载入), DECR/INCR(寄存器操作), STO(存储) # 简化模型:x 是共享内存,R1/R2 是两个进程的寄存器 def simulate(sequence): x = 10 R = {'P1': 0, 'P2': 0} log = [] for step in sequence: pid, op = step if op == 'LD': R[pid] = x log.append(f'{pid} LD: R={R[pid]}, x={x}') elif op == 'DECR': R[pid] -= 1 log.append(f'{pid} DECR: R={R[pid]}, x={x}') elif op == 'INCR': R[pid] += 1 log.append(f'{pid} INCR: R={R[pid]}, x={x}') elif op == 'STO': x = R[pid] log.append(f'{pid} STO: R={R[pid]}, x={x}') elif op == 'CHECK': if x != 10: log.append(f'{pid} CHECK: x={x} -> 打印 x is {x}') else: log.append(f'{pid} CHECK: x={x} -> 不打印') return log # 构造一个打印 x is 10 的序列:P1 减完还没存,P2 完整跑一轮,P1 再存 seq_10 = [ ('P1','LD'), ('P1','DECR'), # P1 R1=9, x=10 ('P2','LD'), ('P2','DECR'), ('P2','STO'), # P2 R2=9, x=9 ('P2','LD'), ('P2','INCR'), ('P2','STO'), # P2 R2=10, x=10 ('P1','STO'), # P1 存回 9,x=9 ('P1','LD'), ('P1','INCR'), ('P1','STO'), # P1 加回 10,x=10 ('P1','CHECK'), # x=10,不打印 ('P2','CHECK'), # x=10,不打印 ] print('--- 打印 x is 10 的序列 ---') for line in simulate(seq_10): print(line) # 构造打印 x is 8 的序列:两个进程都减了但没加回来就检查 seq_8 = [ ('P1','LD'), ('P1','DECR'), # P1 R1=9 ('P2','LD'), ('P2','DECR'), # P2 R2=9 ('P1','STO'), # x=9 ('P2','STO'), # x=9 ('P1','LD'), ('P1','DECR'), # P1 R1=8 ('P2','LD'), ('P2','DECR'), # P2 R2=8 ('P1','STO'), # x=8 ('P2','STO'), # x=8 ('P1','CHECK'), # x=8,打印 x is 8 ] print('--- 打印 x is 8 的序列 ---') for line in simulate(seq_8): print(line)

代码里CHECK对应if(x != 10)的判断。打印 10 的序列关键在于:一个进程的减操作被另一个进程的完整加减覆盖,最终 x 回到 10 再检查。打印 8 的序列则是两个进程的减操作叠加,各自存回后 x 变成 8。实际考试时不需要写代码,但用这种逐步推演的方法,比盯着答案背要可靠得多。

3.2 5.4 的 tally 上下界:为什么是 [2, 100]

5.4 问并行程序最终 tally 的合适下界和上界。答案给的是 [2, 100],n=50 两个进程。下界 2 的推导很经典:一个进程载入 0 后失去处理器,另一个进程跑完 49 次增操作存了 49,第一个进程恢复后存 1 覆盖 49,然后第二个进程再载入 1 加一次存 2,最终就是 2。上界 100 是理想情况,两个进程完全串行,各加 50 次。

如果允许任意数量进程,下界还是 2,上界变成 N*50。这个结论在讲“竞态条件”时特别有用:共享变量的最终值取决于交错顺序,范围可能很大。我一般会让学生自己用代码模拟随机交错,跑一万次看 tally 的分布,比看答案印象深。

import random def race_simulation(n_processes=2, n_increments=50, trials=10000): results = [] for _ in range(trials): tally = 0 # 每个进程维护自己的寄存器 regs = [0] * n_processes # 生成所有操作:每个进程有 n_increments 次 (LD, INCR, STO) ops = [] for pid in range(n_processes): for _ in range(n_increments): ops.append((pid, 'LD')) ops.append((pid, 'INCR')) ops.append((pid, 'STO')) random.shuffle(ops) for pid, op in ops: if op == 'LD': regs[pid] = tally elif op == 'INCR': regs[pid] += 1 elif op == 'STO': tally = regs[pid] results.append(tally) return min(results), max(results), sum(results)/len(results) print(race_simulation(2, 50, 5000))

跑出来最小值通常是 2,最大值接近 100,均值在 50 左右。这个模拟能直观看到“交错顺序无严格要求”带来的不确定性。

4. 互斥与信号量:5.6、5.11、5.16 的坑在哪

4.1 5.6 的软件互斥反例:turn 和 blocked 的配合漏洞

5.6 给了一段用blocked[2]和turn实现互斥的代码,要求举反例证明不正确。答案给了一个交错序列:P0 设blocked[0]=true,进入 while 循环,发现turn != 0,在内层 while 等blocked[1];P1 设blocked[1]=true,发现turn != 1,在内层 while 等blocked[0]。两个进程互相等对方释放,但谁都没法推进,死锁。这个反例说明:软件方法里,标志位和 turn 的读写顺序稍有差池就会翻车。实际开发中,这种“看起来对”的互斥代码最容易在压力测试下暴露问题。

4.2 5.11 的 compare&swap 有界等待算法

5.11 要求用 compare&swap 设计一个保证 n-1 个 turn 内进入临界区的算法。答案用了一个waiting[]数组和lock变量。核心逻辑是:每个进程先设waiting[i]=true,然后循环用 CAS 抢锁;抢到后清waiting[i],进临界区;出来时找下一个等待的进程,把锁直接交给它。这样每个进程最多等 n-1 个 turn。参数n是进程数,waiting数组大小也是 n。这个算法在真实锁实现里很常见,比如 Java 的ReentrantLock公平模式就有类似队列机制。

// 5.11 的 compare&swap 有界等待算法(C 伪代码) int const n = N; // 进程数 int lock = 0; // 0 表示未锁,1 表示已锁 int waiting[N]; // 等待队列,初始全 0 void P(int i) { while (1) { waiting[i] = 1; while (waiting[i] && compare_and_swap(&lock, 0, 1)) { // 自旋等待 } waiting[i] = 0; // 临界区 // ... // 退出区:找下一个等待者 int j = (i + 1) % n; while (j != i && !waiting[j]) { j = (j + 1) % n; } if (j == i) { lock = 0; // 没人等,释放锁 } else { waiting[j] = 0; // 直接把锁交给 j } // 其余部分 } }

关键参数:lock是全局锁,waiting是每个进程的等待标志。CAS 操作compare_and_swap(&lock, 0, 1)原子地把 0 换成 1,成功返回真。退出时不是简单置lock=0,而是找下一个等待者直接放行,这样保证有界等待。

4.3 5.16 的二元信号量实现一般信号量:delay 溢出怎么修

5.16 给了一段用二元信号量实现一般信号量的代码,指出缺点并给解决方案。原代码在semWait里先semWaitB(mutex),然后s--,如果s<0就semSignalB(delay)再semWaitB(delay)。问题出在:两个semWait同时执行时,第一个进程在semWaitB(delay)前被切换,第二个进程执行semSignal会让delay变成 1,然后第一个进程恢复再semSignalB(delay)会让delay超过二元信号量的范围。修复方法是在semSignal里加判断,只有s<=0时才semSignalB(delay),否则只释放mutex。这个坑在真实信号量实现里很典型:二元信号量的值只能是 0 或 1,多次释放会溢出。

5. 银行家算法与死锁避免:6.5 的安全序列怎么算

5.1 可用资源向量和需求矩阵的验证

6.5 给了 6 个进程、4 种资源的分配表和最大需求表,要求验证可用资源向量、计算需求矩阵、证明安全序列。可用资源向量 = 总资源 - 已分配之和。总资源 A=15, B=6, C=9, D=10(从答案反推),已分配 A 列求和:2+1+2+1+1+1=8,所以可用 A=15-8=7?但答案给的是 6,3,5,4。这里要注意:题目里 C 和 D 的单位数没写全,答案的可用向量是 6,3,5,4,说明总资源可能是 15,6,9,10 减去已分配 9,3,4,6 得到 6,3,5,4。需求矩阵 = 最大需求 - 已分配,逐项相减。

import numpy as np # 6.5 的数据 allocated = np.array([ [2,0,2,1], # P0 [0,1,1,1], # P1 [2,4,1,0], # P2 [1,0,0,1], # P3 [1,1,0,0], # P4 [1,0,1,1], # P5 ]) max_need = np.array([ [9,5,5,5], # P0 [2,2,3,3], # P1 [7,5,4,4], # P2 [3,3,3,2], # P3 [5,2,2,1], # P4 [4,4,4,4], # P5 ]) available = np.array([6,3,5,4]) need = max_need - allocated print('需求矩阵:') print(need) # 安全性检查 work = available.copy() finish = [False] * 6 safe_seq = [] while len(safe_seq) < 6: found = False for i in range(6): if not finish[i] and all(need[i] <= work): work += allocated[i] finish[i] = True safe_seq.append(f'P{i}') found = True print(f'P{i} 完成,工作向量变为 {work}') break if not found: print('不安全状态') break print('安全序列:', safe_seq)

跑出来安全序列是 P4, P5, P3, P2, P1, P0,和答案一致。工作向量在每个进程完成后的变化也打印出来了,方便对照。

5.2 P5 请求 (3,2,3,3) 该不该允许

P5 的请求向量是 (3,2,3,3),先检查是否小于等于需求(P5 需求是 3,3,3,3,满足)和可用(6,3,5,4,满足)。然后试探性分配:可用变成 (3,1,2,1),P5 已分配变成 (4,2,4,4),需求变成 (0,2,0,0)。再跑安全性检查,如果还能找到安全序列就允许。实际跑一下会发现,分配后可用资源太少,可能找不到安全序列,所以请求应被拒绝。这个判断过程在考试里要一步步写,不能跳步。

注意:银行家算法的安全性检查里,need[i] <= work是逐元素比较,不是向量长度比较。很多人在这里写成sum(need[i]) <= sum(work),结果算错。

6. 把答案用成复习工具:我的三个习惯

这份答案最值钱的地方不是最终结果,而是推导过程。我自己的习惯是:第一,每道题先不看答案,自己推一遍,推完再对照,差异处用红笔标;第二,把 3.2 那种时刻推演做成表格,用不同颜色标每个时刻的变化,避免状态覆盖;第三,对 5.3、5.4 这种并发题,写个小脚本模拟随机交错,跑几千次看结果分布,比背答案管用。比如 tally 的上下界,跑一次模拟就能看到最小值 2 出现的频率,最大值 100 几乎不出现,均值在 50 附近,这个直觉对理解竞态条件很有帮助。

还有一个技巧:把 5.11 的 compare&swap 算法和 5.16 的信号量实现放在一起看,会发现它们都在解决“有界等待”和“原子性”两个问题。compare&swap 用硬件原子指令保证锁的获取,信号量用二元信号量组合出计数信号量。理解了这个对应关系,再去看真实操作系统里的 futex、自旋锁、读写锁,就不会觉得突兀。

从那以后我每次复习操作系统,都强制自己把每道题的推演过程写成可运行的代码或表格,哪怕只是伪代码。因为看答案只能骗过眼睛,跑一遍才能骗过脑子。希望帮到你。

本文还有配套的精品资源,点击获取

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进