简介:这份文档是《计算机操作系统教程》左万利、王英第四版课后习题的配套答案资料,面向高校计算机专业学生及考研、期末备考人群,用于核对章节作业、梳理考点与巩固解题思路。压缩包共1个文件,为doc格式,大小约4.21MB,可直接用Word或WPS打开检索,按章节顺序编排,便于对照教材逐题查阅。内容覆盖操作系统基本概念、进程管理、处理机调度、死锁避免、银行家算法与读者写者同步等核心章节:第三章给出EDF与RMS算法可调度性判断及Gantt图绘制,并完整推演FCFS、SJB、HRN三种调度策略下的平均周转时间与带权周转时间;第四章提供读者写者问题的信号量解法及写者优先方案;第五章结合银行家算法与死锁检测,逐步演算安全状态判断、资源请求分配与死锁进程识别。目前该答案已有6331人学习下载,适合作为章节练习后的自检参照与考前重点题型复盘使用。
1. 从一份课后答案说起:操作系统习题的验证式做法
搜「计算机操作系统教程左万利王英第四版课后习题答案.doc」的人,多半手里已经有一份答案文档,真正卡住的不是找不到答案,而是答案对不上自己的手算过程:银行家算法给出的安全序列和自己推的不一致,FIFO 的缺页次数差一次,PV 操作的 P 顺序换个位置结果就死锁。计算机操作系统的习题有个特点——结论对参数极度敏感,缓冲区容量从 4 改成 3、引用串多一个页号、磁头初始方向反过来,整道题都要重算。一份静态的 doc 只能给出某组参数下的一个结果,给不了中间状态。
可行的做法是把课后习题按题型拆成四类:同步与 PV 操作、死锁与银行家算法、存储管理与页面置换、磁盘调度与文件系统。每类写一个几十行的小脚本,把题目参数当输入,把答案结论当断言。这样一旦某道题对不上,能立刻定位到哪一步的中间状态算错了,而不是反复重抄。
下面按这个顺序展开,代码都能直接跑,参数位置留成变量,方便换成自己卷子上的数字。
2. 进程同步习题的推演:PV 操作、管程与协程别混着用
2.1 信号量题为什么「看得懂答案、自己写就死锁」
信号量的语义只有两条:P 操作把信号量减 1,若结果小于 0 则调用者阻塞;V 操作把信号量加 1,若结果小于等于 0 则唤醒一个等待者。题目给的是并发进程的代码骨架,要求你补 P、V 的位置。绝大多数人失分不在语义,而在P 操作的顺序。
以生产者-消费者为例,三个信号量:empty表示空槽数量,full表示满槽数量,mutex保护缓冲区本身。生产者必须先申请空槽、再申请互斥锁;如果反过来,生产者拿着mutex等空槽,而消费者要先拿mutex才能取数据释放空槽,双方互等,直接死锁。这个顺序错误在习题答案里通常只标注「错误」,不会展开到底错在哪,自己跑一遍才看得见。
2.2 生产者-消费者:用 Python 信号量复现并校验缓冲区不变量
import threading, random, time BUF_SIZE = 4 empty = threading.Semaphore(BUF_SIZE) # 空槽计数,初值 = 缓冲区容量 full = threading.Semaphore(0) # 满槽计数,初值 0,必须先 V 才能 P 到 mutex = threading.Semaphore(1) # 互斥量,二值信号量 buf = [] violations = [] def producer(tid): for i in range(5): item = f"P{tid}-{i}" empty.acquire() # P(empty):先确认有空槽,再抢锁 mutex.acquire() # P(mutex) buf.append(item) # 临界区:只放一个元素 if len(buf) > BUF_SIZE: violations.append(("overflow", len(buf))) mutex.release() # V(mutex) full.release() # V(full):满槽 +1,唤醒消费者 def consumer(tid): for _ in range(5): full.acquire() # P(full):先确认有数据,再抢锁 mutex.acquire() # P(mutex) if not buf: violations.append(("underflow", 0)) item = buf.pop(0) mutex.release() # V(mutex) empty.release() # V(empty):空槽 +1,唤醒生产者 ts = [threading.Thread(target=producer, args=(i,)) for i in range(3)] ts += [threading.Thread(target=consumer, args=(i,)) for i in range(3)] [t.start() for t in ts] [t.join() for t in ts] print("violations:", violations, "left:", buf)逻辑说明:empty与full的和恒等于BUF_SIZE,这是这类题最该检查的不变量。代码里每次进入临界区都做一次越界检查,跑完violations为空且buf为空,说明同步逻辑自洽。参数说明:BUF_SIZE改成题面给的容量;生产/消费轮次改成题目要求的值;把empty.acquire()与mutex.acquire()互换,就能亲手复现死锁——程序会在几十秒后卡住不动,这比看答案上那句「会死锁」直观得多。
注意:CPython 有 GIL,这里不会真的并行执行字节码,但信号量的阻塞唤醒顺序与真实并发语义一致,用来验证 PV 顺序足够。想更接近裸机行为可以换成多进程,代价是要把
buf换成multiprocessing.Queue。
2.3 管程、协程和信号量的边界
热搜里常把「管程和协程」放一起,这两者根本不在一个层面。管程是把互斥和同步封装进模块的语言级同步构造,进入管程自动加锁,条件变量上的 wait/signal 对应 P/V;协程是用户态调度的执行体,切换靠显式 yield/await,它解决的是并发任务的切换开销,不是互斥问题。
| 构造 | 谁负责切换 | 解决的问题 | 习题里的典型问法 |
|---|---|---|---|
| 信号量 | 内核调度器阻塞/唤醒 | 互斥与同步 | 写出 P、V 操作序列,判断是否死锁 |
| 管程 | 语言运行时 + 条件变量 | 把同步封装进模块,避免散落的 P/V | 用管程改写生产者-消费者 |
| 协程 | 用户态显式让出 | 高并发任务的轻量调度 | 与同步原语的区别,能否替代互斥量 |
答题时如果题目明确说「用管程实现」,就不要再写semaphore.acquire(),而要写wait(c)/signal(c)形式,条件判断用 while 而不是 if——被唤醒后要重新检查条件,这是管程题最常见的扣分点。
2.4 习题里最容易扣分的四个写法
- 用
if代替while检查条件:虚假唤醒或条件被别的进程抢先改变时直接出错。 - 把
V(mutex)写在V(full)之后:多数情况下能跑,但临界区被拉长,题目若问「能否减少临界区」会失分。 - 读者-写者题里让读者持有
mutex去读:读操作本可并行,会退化成串行。 - 哲学家进餐题不做奇偶区分或一次性申请两把叉子:五个哲学家同时拿起左叉,死锁必然发生。
3. 银行家算法与死锁习题:手算表格怎么变成可执行校验
3.1 安全序列判定:Need 与 Work 的逐分量比较
银行家算法的核心只有一句话:找一个 Need 的每一个分量都不超过当前 Work 的进程,让它执行完并归还资源,重复到所有进程都完成,则状态安全。手算步骤固定为四步:算 Need = Max − Allocation;复制 Available 到 Work;按顺序扫描找满足条件的进程;回收该进程的 Allocation 加到 Work 上。
下面这组数据是这类题的经典结构,三种资源、五个进程:
| 进程 | Allocation | Max | Need(Max−Allocation) |
|---|---|---|---|
| P0 | 0 1 0 | 7 5 3 | 7 4 3 |
| P1 | 2 0 0 | 3 2 2 | 1 2 2 |
| P2 | 3 0 2 | 9 0 2 | 6 0 0 |
| P3 | 2 1 1 | 2 2 2 | 0 1 1 |
| P4 | 0 0 2 | 4 3 3 | 4 3 1 |
Available = (3,3,2)。手算过程:P1 的 Need(1,2,2) ≤ Work(3,3,2),执行完 Work 变 (5,3,2);P3 的 (0,1,1) ≤ (5,3,2),Work 变 (7,4,3);P4 的 (4,3,1) ≤ (7,4,3),Work 变 (7,4,5);P0 的 (7,4,3) ≤ (7,4,5),Work 变 (7,5,5);P2 的 (6,0,0) ≤ (7,5,5),全部完成。安全序列 P1→P3→P4→P0→P2。
3.2 用一段代码复算安全序列并输出一个可行解
def need_matrix(maxm, alloc): return [[maxm[i][j] - alloc[i][j] for j in range(len(maxm[0]))] for i in range(len(maxm))] def is_safe(avail, maxm, alloc, n, m): need = need_matrix(maxm, alloc) work = avail[:] # 复制一份,不能改原 Available finish = [False] * n seq = [] while len(seq) < n: picked = -1 for i in range(n): if not finish[i] and all(need[i][j] <= work[j] for j in range(m)): picked = i # 只要求 Need <= Work,不是 Max <= Work break if picked == -1: return False, [] # 存在进程无法完成,状态不安全 for j in range(m): work[j] += alloc[picked][j] # 进程归还全部已分配资源 finish[picked] = True seq.append(picked) return True, seq avail = [3, 3, 2] maxm = [[7,5,3], [3,2,2], [9,0,2], [2,2,2], [4,3,3]] alloc = [[0,1,0], [2,0,0], [3,0,2], [2,1,1], [0,0,2]] print(is_safe(avail, maxm, alloc, 5, 3))逻辑说明:外层while保证每个进程最多被选中一次;内层for每次从下标 0 开始扫描,所以输出的是「按进程号优先」的那个安全序列,而安全序列通常不唯一,答案里给的是另一种顺序不代表你错。参数说明:avail是当前可用向量,maxm是各进程的最大需求矩阵,alloc是已分配矩阵,n/m分别是进程数和资源类数。把all(...)里的need错写成maxm是手算时最高频的错误,代码里刻意保留了注释提醒。
3.3 Request 请求的三个前置条件与试探分配
题目给的第二个问法通常是「P1 发出 Request(1,0,2),能否分配」。判定顺序不能乱:先查Request ≤ Need,超过说明进程请求超出自己声明的最大需求,是出错;再查Request ≤ Available,超过说明资源暂时不够,阻塞等待;最后做试探分配,把 Allocation、Available 改掉之后重跑一次安全性检查,不安全就撤销试验并让进程等待。
def request(avail, maxm, alloc, pid, req): need = need_matrix(maxm, alloc) m = len(avail) if any(req[j] > need[pid][j] for j in range(m)): return "出错:请求超过最大需求" if any(req[j] > avail[j] for j in range(m)): return "阻塞:当前可用资源不足" trial_alloc = [row[:] for row in alloc] # 深拷贝,避免污染原矩阵 trial_avail = avail[:] for j in range(m): trial_alloc[pid][j] += req[j] trial_avail[j] -= req[j] ok, seq = is_safe(trial_avail, maxm, trial_alloc, len(alloc), m) return f"可以分配,安全序列 {seq}" if ok else "暂不分配:试探后状态不安全"逻辑说明:三个检查必须按「是否合法 → 是否够用 → 是否安全」的次序执行,顺序颠倒会得到语义不同的结论。参数说明:req是长度为 m 的请求向量;trial_alloc用列表推导逐行复制,直接trial_alloc = alloc会改到原数据,导致同一道题第二次调用结果错乱。
3.4 手算常见错误对照表
| 错误写法 | 后果 | 正确做法 |
|---|---|---|
| 用 Max ≤ Work 筛选进程 | 永远找不到可执行进程,误判为不安全 | 用 Need ≤ Work |
| 修改 Available 而不深拷贝 | 多次判定互相污染 | 每次复制 Work / Available |
| 只找到一个安全序列就收工 | 题目要求「所有安全序列」时漏答案 | 用回溯枚举或接受任一序列 |
| 把不安全等同于死锁 | 概念混淆 | 不安全只是「可能」进入死锁 |
| 死锁检测与银行家算法混用 | 检测针对已发生死锁,避免针对分配前 | 看清题干动词 |
4. 页面置换与内存管理计算题:FIFO、LRU、OPT 的表格化复算
4.1 引用串、驻留集与缺页次数:三个必须先固定的假设
页面置换题丢分往往不是算法错,而是假设没对齐。三个必须先在草稿纸角上写清楚的量:引用串是否含首次装入阶段、可用页框数是固定分配还是可变分配、缺页次数统计时首次装入空页框算不算缺页。教材间的约定并不统一——常见的汤小丹版、慕课版讲义与左万利王英这本的记号方式各有差异,同一道题在两本书里答案差 1 到 2 次缺页很正常。做题时把这三个假设写进答案开头,比算出正确数字更重要。
置换了哪一页,取决于算法选择的淘汰对象:FIFO 淘汰内存中驻留最久的页,LRU 淘汰最久未被访问的页,OPT 淘汰未来最长时间不会被访问的页。前两者可实现,OPT 只是理论下界,用来衡量其他算法的差距。
4.2 FIFO、LRU、OPT 的最小实现
from collections import OrderedDict, deque def fifo(ref, frames): q, faults = deque(), 0 for p in ref: if p not in q: faults += 1 if len(q) == frames: q.popleft() # 淘汰队首,即最早进入的页 q.append(p) return faults def lru(ref, frames): od, faults = OrderedDict(), 0 for p in ref: if p in od: od.move_to_end(p) # 命中,刷新为最近使用 else: faults += 1 if len(od) == frames: od.popitem(last=False) # 淘汰最久未使用 od[p] = None return faults def opt(ref, frames): mem, faults = [], 0 for i, p in enumerate(ref): if p in mem: continue faults += 1 if len(mem) < frames: mem.append(p) continue far, victim = -1, None for q in mem: nxt = next((j for j in range(i + 1, len(ref)) if ref[j] == q), float("inf")) if nxt > far: # 下次访问最晚的页被淘汰 far, victim = nxt, q mem[mem.index(victim)] = p return faults ref = [7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1] for name, fn in (("FIFO", fifo), ("LRU", lru), ("OPT", opt)): print(name, fn(ref, 3))逻辑说明:三个函数都只返回缺页次数,不关心具体淘汰过程,出结果后再和答案对照。OrderedDict的move_to_end与popitem(last=False)恰好实现了 LRU 的「命中刷新、淘汰最旧」两步,比手写链表短得多。参数说明:frames是页框数,改成 4 就能观察 FIFO 的异常行为。上面这组 20 个引用的串,三页框下 FIFO 缺页 15 次、LRU 12 次、OPT 9 次;如果你手算得到 14 次,多半是某次命中被误判成缺页。
提示:OPT 计算里的
float("inf")表示该页之后不再被访问,它一定是淘汰首选。手算时对这类页可以直接划掉,不用逐个比较。
4.3 Belady 异常:FIFO 加页框反而更差的一组数据
FIFO 会违反「页框越多缺页越少」的直觉,这就是 Belady 异常。用引用串1 2 3 4 1 2 5 1 2 3 4 5手推一遍:
| 页框数 | 缺页次数 | 现象 |
|---|---|---|
| 3 个页框 | 9 | 第 4 次引用 1 时就已装入,未额外缺页 |
| 4 个页框 | 10 | 淘汰顺序被打乱,后续 1、2 全部缺页 |
三页框时,序列走到1 2 3 4淘汰 1,之后5 1 2 3 4 5中 1 缺页一次、5 命中一次;四页框时 1、2 在早期全部命中,却因此在5 1 2 3 4 5段里被成批淘汰,反而多缺一次。LRU 和 OPT 属于栈算法,不会出现这种现象——考卷上问「哪种算法可能产生 Belady 异常」,答案只能是 FIFO。
4.4 Clock 算法是 LRU 的近似,答题时别写反置换指针
Clock 算法给每页一个访问位,需要淘汰时从指针位置开始扫描:访问位为 1 就清 0 并跳过,为 0 就淘汰。它和 LRU 的差别在于只记录「最近是否被访问」这一个 bit,代价是精度,收益是不用维护完整的访问顺序链。
手算 Clock 题最容易犯的错是扫描方向写反、清 0 后没有回头。正确的循环是「扫描 → 遇 1 清 0 → 指针后移 → 遇 0 淘汰并让指针停在下一页」。改进型 Clock 再加一个修改位,优先淘汰「未访问且未修改」的页,其次「未访问已修改」,这四类优先级顺序是固定答题模板,值得背下来。
5. 磁盘调度与答案核对:把课后习题做成可重跑的自测集
5.1 SSTF 与 SCAN/LOOK 的移臂量计算
磁盘调度题的全部工作量就是累加相邻访问磁道号之差的绝对值。设请求序列98 183 37 122 14 124 65 67,磁头初始在 53 号磁道,磁道范围 0–199:
| 算法 | 访问顺序 | 移臂总量 |
|---|---|---|
| FCFS | 53→98→183→37→122→14→124→65→67 | 640 |
| SSTF | 53→65→67→37→14→98→122→124→183 | 236 |
| SCAN(向 0 方向) | 53→37→14→0→65→67→98→122→124→183 | 236 |
| LOOK(向 0 方向) | 53→37→14→65→67→98→122→124→183 | 208 |
SCAN 与 LOOK 差的 28 道,正是从 14 走到 0 再折返到 65 的那段空跑。教材写法不统一,有的把 LOOK 也叫「电梯调度」,答题时要把「是否走到磁盘端点」写明,否则同一道题会算出两个数。SSTF 的隐患则是饥饿——如果新请求持续落在磁头附近,远处的 183 可能一直排不上队,这也是它不能直接用于生产调度的原因。
5.2 用 pytest 把一组课后题固化成回归用例
import pytest from os_hw.paging import fifo, lru, opt from os_hw.banker import is_safe REF20 = [7,0,1,2,0,3,0,4,2,3,0,3,2,1,2,0,1,7,0,1] @pytest.mark.parametrize("algo,expected", [(fifo,15), (lru,12), (opt,9)]) def test_paging_ref20(algo, expected): assert algo(REF20, 3) == expected # 三页框下的标准结论 def test_belady(): ref = [1,2,3,4,1,2,5,1,2,3,4,5] assert fifo(ref, 3) == 9 assert fifo(ref, 4) == 10 # 页框变多,缺页反而增加 def test_banker_safe(): ok, seq = is_safe([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]], 5, 3) assert ok and len(set(seq)) == 5逻辑说明:parametrize让一个函数同时校验三种算法,改参数比改代码快。test_belady用两个断言把异常现象本身固化成用例——如果哪天重构把 FIFO 写成了栈算法,这条测试会立刻变红。参数说明:把REF20换成你正在做的题目引用串,把期望值填成自己手算的结果,跑一次就知道是算法实现错了还是手算错了。
5.3 跨教材版本的记号差异怎么核对
同一道题在不同教材里答案不一致,通常出在三处:缺页次数是否含初始装入、SCAN 是否走到物理端点、P/V 是否写成 wait/signal。核对时不要直接比数字,而是比中间表——把每一轮的驻留页集合或 Work 向量打出来逐行比对,差异会集中在某一次置换或某一次资源回收上,那一步就是约定不同之处。
我的习惯是给每道题在脚本里写一行假设注释,例如「# 缺页含首次装入」「# SCAN 走到端点 0」,答案对不上时先看注释再怀疑答案。那份.doc里的答案大概率是对的,只是它的假设没写在纸面上;把假设显式化之后,你手里就不再是一份答案,而是一组能重跑的断言,换个数也能立刻验算。
本文还有配套的精品资源,点击获取