简介:这份资源是面向计算机、网络工程等专业学生的操作系统课程设计实验报告,聚焦进程管理系统的设计与实现,适合正在完成操作系统课程设计或准备相关实验答辩的本科学习者参考。报告围绕进程调度、存储管理、文件管理、多道程序转换调度及操作系统整体设计五个模块展开,梳理了FCFS、SJF、优先级、时间片轮转、多级队列与多级反馈队列等典型调度算法的设计目的、数据结构与实施要点,并给出存储分配算法、磁盘寻道先来先服务/最短寻道优先/电梯算法、页面置换FIFO/OPT/LRU,以及作业与进程调度结合的周转时间计算与输出格式要求,可作为确定设计方案、编写调试程序与撰写报告书的思路参考。压缩包共1个PDF文件,约159KB,体积轻便易于查阅。目前已有286人学习下载,适合据此对照任务书完成预设计、实验编码与设计总结。
1. 拿到「操作系统进程管理系统设计实验报告」这个题目,先别急着写文档
课程设计布置下来的那天,多数人的动作是打开 Word,把「实验目的、实验原理、实验步骤」的标题先敲好,然后卡在「实验数据」那一栏——因为没有数据。真正能交差的顺序是反过来的:先把进程控制块、调度器和同步机制跑起来,让程序吐出一张带时间戳的调度 trace,报告的表格和结论才有东西可填。
这个题目拆开看是三件事:进程管理的数据结构怎么设计,调度算法怎么选和怎么实现,以及同步与死锁怎么验证。三者不是并列关系,调度器依赖 PCB 里的字段,同步机制又决定阻塞队列什么时候被唤醒,改任何一处,另外两处的输出都会跟着变。
适合的人群很明确:正在做操作系统课程设计的学生,期末复习想用代码验证周转时间公式的,以及从零开始手搓操作系统、卡在调度模块的开发者。接下来的路径是数据结构、调度算法、同步与死锁、实验验证,每一段都给能直接跑的 Python 代码和参数取值依据。
2. 进程管理系统设计:PCB 字段、状态机与队列的最小实现
进程管理的系统设计如果落不了地,通常是 PCB 定义得不够用:写了 pid 和优先级,却忘了剩余运行时间和首次获得 CPU 的时刻,结果时间片轮转算不出正确的等待时间。这一章把 PCB 的字段、五状态迁移和三个队列的职责对齐,让后面所有调度算法共用同一套数据结构。
2.1 PCB 里到底该放哪些字段
字段分两组看。第一组是调度用的元数据:到达时间 arrive、需要的 CPU 总时间 burst、剩余时间 remain、优先级 priority、已等待时间 wait。第二组是上下文切换要保存的运行现场:程序计数器、通用寄存器、栈指针、状态字。模拟实验里第二组通常简化为一个 dict,但字段名要留好,报告里解释上下文切换开销时才有依据。
| 字段 | 类型 | 用途 | 是否参与调度决策 |
|---|---|---|---|
| pid | int | 唯一标识 | 否 |
| arrive | int | 到达就绪队列的时刻 | 是 |
| burst | int | 需要的 CPU 总时间 | 是 |
| remain | int | 剩余时间,RR 抢占用 | 是 |
| priority | int | 数值越小优先级越高 | 是 |
| wait | int | 累计等待时间 | 否(用于统计) |
| start | int | 首次获得 CPU 的时刻 | 否(用于算响应时间) |
| ctx | dict | 寄存器快照 | 否 |
提示:priority 的方向必须在代码注释里写死,越小越高还是越大越高。混用方向是优先级调度结果对不上理论值的第一大原因。
2.2 五状态模型与三个队列的对应关系
新建、就绪、运行、阻塞、终止这五个状态,落到实现层面就是三个容器加一个当前运行指针。就绪队列用 deque,因为它要支持 O(1) 的队尾追加和队首弹出;阻塞队列用 list 或 dict,按事件号索引,事件完成时整体扫描唤醒;终止集合只用于统计。
| 当前状态 | 触发事件 | 目标状态 | 队列动作 |
|---|---|---|---|
| 新建 | 资源分配完成 | 就绪 | 入就绪队列尾部 |
| 就绪 | 调度器选中 | 运行 | 出就绪队列 |
| 运行 | 时间片耗尽 | 就绪 | 回到就绪队列尾部 |
| 运行 | 请求 I/O 或 P 操作阻塞 | 阻塞 | 入阻塞队列,记录事件号 |
| 阻塞 | I/O 完成或 V 操作唤醒 | 就绪 | 出阻塞队列,入就绪队列 |
| 运行 | 正常结束 | 终止 | 移入终止集合,回收资源 |
2.3 用 Python 定义 PCB 与队列
from dataclasses import dataclass, field from collections import deque from enum import Enum class State(Enum): NEW = "new" READY = "ready" RUNNING = "running" BLOCKED = "blocked" TERMINATED = "terminated" @dataclass class PCB: pid: int arrive: int # 到达时间,假定为整数时钟 burst: int # 需要 CPU 的总时间 priority: int = 0 # 数值越小优先级越高 state: State = State.NEW remain: int = 0 # 剩余时间,RR 抢占判断用 start: int = -1 # 首次获得 CPU 的时刻 finish: int = -1 # 完成时刻 wait: int = 0 # 等待时间,不含运行时间 ctx: dict = field(default_factory=dict) # 寄存器 / PC 快照 def __post_init__(self): self.remain = self.burst # 初始化剩余时间 class Queues: def __init__(self): self.ready = deque() # 就绪队列,支持两端操作 self.blocked = {} # {事件号: [PCB, ...]} self.done = [] # 已终止进程 def block(self, pcb, event_id): pcb.state = State.BLOCKED self.blocked.setdefault(event_id, []).append(pcb) def wake(self, event_id): # 事件完成,唤醒该事件上所有阻塞进程 for pcb in self.blocked.pop(event_id, []): pcb.state = State.READY self.ready.append(pcb)逻辑上要盯住一点:wake 之后进程进的是就绪队列而不是直接进运行态,抢占式调度里这一点直接决定唤醒延迟。参数方面,arrive 和 burst 用整数时钟即可,实验报告里时钟单位写成「时间单位」比写「毫秒」稳妥,避免和真实系统的调度延迟混淆。
2.4 上下文切换模拟到什么粒度
课程实验一般只做计数,不真正切栈。做法是每次调度前把 ctx 里的 pc 加一、累加寄存器快照开销,这样上下文切换时间可以当成一个可调参数 C,用来观察时间片过小时吞吐量的下降。如果想真切栈,Python 侧可以用生成器把进程函数拆成 yield 点,Java 侧用线程加锁;但真线程的切换由内核决定,反而不好控制变量。
注意:协程在用户态完成切换,不经过内核调度器,和这里的进程上下文切换不是一回事。管程是把 PV 操作封装进对象的高级同步原语,两者都可以在报告的对比分析里提,但不建议拿来当调度器的实现基础。
3. 进程调度算法实现:FCFS、SJF、时间片轮转与优先级的落地
调度是这份实验报告里数据量最大的一章,也是最容易「理论分高、代码跑出来对不上」的地方。选型时先明确评价指标,再按指标挑算法,最后才是写代码。同一组进程用四种算法各跑一遍,把周转时间、带权周转时间和等待时间并列成表,结论自己就浮出来了。
3.1 四个算法的适用边界与评价指标
指标一共五个:周转时间 finish - arrive、带权周转时间 (finish - arrive) / burst、等待时间、响应时间(首次获得 CPU 减到达)、CPU 利用率与吞吐量。带权周转时间比绝对周转时间更能说明问题,因为它把长作业和短作业拉到了同一尺度上。
| 算法 | 抢占 | 优点 | 明显短板 | 适合的负载特征 |
|---|---|---|---|---|
| FCFS | 否 | 实现最简单,无饥饿 | 短作业排在长作业后等待时间被放大 | 批处理、CPU 密集且长度接近 |
| SJF | 否 | 平均周转时间理论最优 | 长作业可能饥饿,需预知 burst | burst 可估计的批处理 |
| RR | 是 | 响应时间可预期 | 时间片过小则切换开销占比高 | 交互式、响应优先 |
| 优先级 | 可配置 | 能表达业务重要性 | 低优先级饥饿 | 有明确分级要求的场景 |
3.2 FCFS 与 SJF 的最小可运行实现
def fcfs(procs): """先来先服务:按到达时间排序,CPU 空闲时时钟直接跳到下一个到达时刻""" clock = 0 for p in sorted(procs, key=lambda x: x.arrive): clock = max(clock, p.arrive) # 关键:处理 CPU 空闲期 p.start = clock p.wait = clock - p.arrive clock += p.burst p.finish = clock return procs def sjf(procs): """非抢占短作业优先:每次从已到达的进程里挑 burst 最小的""" pending = sorted(procs, key=lambda x: x.arrive) ready, done, i, clock = [], [], 0, 0 while len(done) < len(procs): while i < len(pending) and pending[i].arrive <= clock: ready.append(pending[i]); i += 1 # 新到达的进程入就绪队列 if not ready: clock = pending[i].arrive # 就绪队列空,时钟跳空 continue p = min(ready, key=lambda x: (x.burst, x.arrive)) # 同长度按到达时间兜底 ready.remove(p) p.start = clock p.wait = clock - p.arrive clock += p.burst p.finish = clock done.append(p) return done两段代码里clock = max(clock, p.arrive)和clock = pending[i].arrive是同一件事的两种写法,都是在处理 CPU 空闲期。这一步漏掉,FCFS 的周转时间会集体偏小,而 SJF 会陷入死循环。SJF 的排序键写成元组(burst, arrive)是为了让同长度作业有确定顺序,否则min在并列时结果不稳定,同一份数据两次运行结果不一样,报告就没法复现。
3.3 时间片轮转 RR 的 quantum 怎么定
RR 的调度器多了一个抢占点:每跑完一个时间片,无论进程是否完成,都要检查有没有新进程到达,然后把未完成的进程放回就绪队列尾部。
def rr(procs, quantum): """时间片轮转:quantum 是每个进程一次最多占用 CPU 的时间""" pending = sorted(procs, key=lambda x: x.arrive) ready, done, i, clock = deque(), [], 0, 0 running = None while len(done) < len(procs): while i < len(pending) and pending[i].arrive <= clock: ready.append(pending[i]); i += 1 if running is None: if not ready: clock = pending[i].arrive continue running = ready.popleft() if running.start == -1: running.start = clock # 只记首次,用于算响应时间 slice_len = min(quantum, running.remain) running.remain -= slice_len clock += slice_len # 这一片跑完后才把新到达的进程放进队列,保证队列顺序正确 while i < len(pending) and pending[i].arrive <= clock: ready.append(pending[i]); i += 1 if running.remain == 0: running.finish = clock done.append(running) running = None else: ready.append(running) # 被抢占,回队尾 running = None return donequantum 的取值有一个可量化的判断标准:让上下文切换开销 C 占比不超过 5% 到 10%,即 quantum 至少是 C 的十倍到二十倍。若 C 取 1、quantum 取 2,切换开销占到 33%,吞吐量会明显塌下去;quantum 取 20 以上,RR 的行为又向 FCFS 退化,响应时间变长。实验里稳妥的做法是固定一组进程,让 quantum 从 1 扫到 20,把带权周转时间画成折线,拐点就是这组负载的合适区间。
3.4 优先级反转与老化参数怎么设
纯优先级调度在混合负载下会暴露两个问题。一是饥饿:低优先级进程可能永远排不上;二是优先级反转:高优先级进程等一个被低优先级进程持有的锁,而低优先级又被中优先级抢占。饥饿用老化解决,每等待 k 个时间单位就把优先级数值减一,k 取多少要看优先级范围,通常取「优先级跨度 / 预期等待上限」。
提示:老化要写成可关闭的开关,跑对照组数据时关掉,跑改进组时打开,两份 trace 放在同一张表里,报告里的「优化效果」才有说服力。
4. 进程同步与死锁:信号量、PV 操作与银行家算法验证
调度器管的是谁先上 CPU,同步机制管的是谁能进临界区。这两块在实验里必须连起来跑,因为阻塞队列的唤醒时机直接改变就绪队列的到达序列,只跑调度不跑同步,得到的 trace 是理想化的,报告里讨论并发行为时会站不住。
4.1 信号量与 PV 操作的 Python 实现
信号量的语义核心在于:P 操作在 value 小于等于 0 时阻塞并释放锁,V 操作在 value 递增后唤醒一个等待者。用条件变量实现比用忙等更接近真实内核行为。
import threading from collections import deque class Semaphore: def __init__(self, value): self.value = value self.waiters = deque() # 等待队列,对应 PCB 阻塞队列 self.lock = threading.Lock() def P(self): # 申请资源,可能阻塞 with self.lock: self.value -= 1 if self.value < 0: ev = threading.Event() self.waiters.append(ev) if self.value < 0: ev.wait() # 阻塞在此,直到被 V 唤醒 def V(self): # 释放资源,唤醒一个等待者 with self.lock: self.value += 1 if self.waiters: self.waiters.popleft().set()参数上只有 value 一个:互斥信号量取 1,计数信号量取缓冲区容量。要注意 value 变成负数时,绝对值等于正在等待的进程数,这一点在报告的阻塞队列分析里可以直接引用。
4.2 生产者-消费者跑通并观察缓冲区变化
import time, random, threading buffer, CAP = [], 5 mutex = Semaphore(1) # 互斥访问缓冲区 empty = Semaphore(CAP) # 空槽位数 full = Semaphore(0) # 已填充槽位数 def producer(tid): for item in range(4): empty.P() # 先申请空槽,满了就阻塞 mutex.P() buffer.append(f"p{tid}-{item}") print(f"生产者{tid} 放入 {item}, 缓冲区={buffer}") mutex.V() full.V() # 通知消费者有数据 time.sleep(random.uniform(0.01, 0.03)) def consumer(cid): for _ in range(4): full.P() # 先申请数据,空则阻塞 mutex.P() item = buffer.pop(0) print(f"消费者{cid} 取出 {item}, 缓冲区={buffer}") mutex.V() empty.V() # 释放空槽 time.sleep(random.uniform(0.01, 0.03)) threads = [threading.Thread(target=producer, args=(i,)) for i in range(2)] threads += [threading.Thread(target=consumer, args=(i,)) for i in range(2)] [t.start() for t in threads] [t.join() for t in threads]P 操作的顺序不能颠倒:先 empty.P() 再 mutex.P()。反过来的话,缓冲区满时生产者会拿着互斥锁阻塞,消费者进不了临界区,程序直接死锁。这是实验里最常见的死锁复现方式,值得单独跑一次然后把卡住的调用栈贴进报告作为反例。
4.3 银行家算法做死锁避免
死锁避免和死锁检测是两条路。银行家算法属于避免,它在每次分配前判断是否存在安全序列。以 5 个进程、3 类资源为例:
| 进程 | Allocation (A,B,C) | Max (A,B,C) | Need (A,B,C) |
|---|---|---|---|
| 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) 时,Need 小于等于 Work 的进程依次被回收,得到安全序列 P1、P3、P4、P2、P0。
def is_safe(available, allocation, need): """返回 (是否安全, 安全序列)。每次只推进一个进程,找不到就判定不安全""" n = len(allocation) work = available[:] finish = [False] * n seq = [] while True: picked = False for i in range(n): if not finish[i] and all(need[i][j] <= work[j] for j in range(len(work))): work = [work[j] + allocation[i][j] for j in range(len(work))] finish[i] = True seq.append(f"P{i}") picked = True break if not picked: break return all(finish), seq available = [3, 3, 2] allocation = [[0,1,0], [2,0,0], [3,0,2], [2,1,1], [0,0,2]] need = [[7,4,3], [1,2,2], [6,0,0], [0,1,1], [4,3,1]] print(is_safe(available, allocation, need)) # (True, ['P1', 'P3', 'P4', 'P2', 'P0'])work = [work[j] + allocation[i][j] ...]这一行是算法的关键:进程结束后归还全部已分配资源,而不是只归还本次请求的部分。漏掉这点,后续进程会因为可用资源偏小而判定成不安全。检测死锁则用资源分配图化简,只要存在不可完全化简的图就说明有环,这条路径适合在报告里和银行家算法做对比。
5. 实验数据的验证与调参:从甘特图到 Linux 真实进程对照
代码跑出来的数字对不对,不能只靠肉眼看几行打印。把每次调度事件落成结构化 trace,再和真实系统的行为做一次对照,报告里的数据才经得起追问。
5.1 把调度 trace 落盘并画成甘特图
调度过程中记录(pid, start, end)三元组,直接导出 CSV,顺便渲染成甘特图。图上相同 pid 出现多个色块,就说明发生了抢占;色块之间有空隙,就是 CPU 空闲期没被正确填充。
import csv import matplotlib.pyplot as plt def save_trace(trace, path="trace.csv"): # trace 形如 [(pid, start, end), ...] with open(path, "w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow(["pid", "start", "end", "duration"]) for pid, s, e in trace: writer.writerow([pid, s, e, e - s]) def draw_gantt(trace, path="gantt.png"): fig, ax = plt.subplots(figsize=(8, 2.6)) pids = sorted({t[0] for t in trace}) row = {pid: i for i, pid in enumerate(pids)} for pid, s, e in trace: ax.broken_barh([(s, e - s)], (row[pid] * 10, 8), facecolors="#4c72b0") ax.text(s + (e - s) / 2, row[pid] * 10 + 4, str(pid), ha="center", va="center", color="white", fontsize=8) ax.set_xlabel("clock") ax.set_yticks([r * 10 + 4 for r in row.values()]) ax.set_yticklabels([f"P{p}" for p in pids]) plt.tight_layout() plt.savefig(path, dpi=150)画完之后做一次交叉验证:把 CSV 里每行的 end 时刻排序,相邻两行的 end 与下一行 start 应完全衔接,除非该处发生了 I/O 阻塞。这个检查能抓出大多数时钟推进错误。
5.2 用 ps、vmstat 和 /proc 对照真实进程状态
模拟器里的阻塞、就绪,在 Linux 上有对应的真实状态位。跑一个 CPU 密集进程加一个 I/O 密集进程,用下面的命令观察:
# 查看进程状态、优先级和已运行时间 ps -eo pid,ppid,stat,pri,ni,etime,pcpu,comm --sort=-pcpu | head -20 # 每秒采样一次,连采 5 次;重点看 r 列(就绪队列长度)和 cs 列(上下文切换次数) vmstat 1 5 # 查看单个进程的详细状态与自愿/非自愿切换次数 grep -E "State|voluntary_ctxt_switches|nonvoluntary" /proc/<pid>/statusstat列的含义要记清:R 是运行或就绪,S 是可中断睡眠即阻塞,D 是不可中断睡眠,T 是停止,Z 是僵尸。如果模拟器里算出的阻塞比例很高,而 vmstat 的 cs 列却很低,说明 workload 的 I/O 事件建模太密,实际内核没那么频繁地切换。把两条数据并排写进报告,比只贴一组模拟数字有说服力得多。
调参收尾的动作很具体:固定 20 个进程、burst 在 1 到 15 之间随机、到达时间在 0 到 30 之间随机,生成三份不同随机种子的 workload,分别用 quantum 取 2、4、8 各跑一遍,比较带权周转时间的均值和方差。方差明显变宽的那一档,就是这组负载下时间片切得过碎的位置。
本文还有配套的精品资源,点击获取