简介:面向操作系统课程设计与期末大作业的C++课程设计资源,完整覆盖进程调度FCFS先来先服务与SJF短作业优先两种经典算法的实现。资源包含cpp源码文件、说明文档与可直接运行的exe程序,共3个文件,压缩包仅36KB,轻量便携,便于本地验证与修改。已有2852人学习浏览,适合需要对照实现、理解算法差异的计算机专业学生。资源以具体问题为驱动,可帮助读者掌握进程控制的周转时间、带权周转时间与等待时间计算,并完成两种算法在相同进程到达时间与服务时间下的比较评价,是课程设计或期末复习时可复用的完整方案,适合作为入门理解调度机制的学习样板,也可在其基础上进一步扩展实现其他调度算法或可视化界面演示。资源小巧,部署简单,适合作为课设演示与答辩辅助材料。
1. 课程设计选FCFS和SJF:为什么这个C++大作业选题最稳、也最容易被问倒
C++ 模拟操作系统进程调度,用 FCFS(先来先服务)和 SJF(短作业优先)两种算法写实现源码,是课程设计大作业里被选得最多的组合之一。它代码量不大、算法逻辑直白,熟悉 C++ 基本语法的人一个下午就能跑出第一版;但这也是翻车率很高的选题——平均等待时间口径不对、SJF 执行次序和手算对不上、CPU 空闲间隙没处理,每年都有不少同学在答辩前才会发现这些问题。这篇文章按“原理 → 源码 → 测试用例 → 避坑 → 答辩”的顺序,把一份能直接提交的 FCFS+SJF 调度模拟器完整拆开讲。新手能照着复现,已经写完代码的同学也能拿它自查补漏,顺便把答辩里最容易被问倒的那几个问题提前备好。
2. FCFS与SJF算法原理:先搞懂调度器在算什么,再看两个算法的差异
2.1 FCFS先来先服务:排队逻辑与CPU空闲间隙的处理
进程调度解决的核心问题是:当 CPU 空闲下来,就绪队列里那么多进程,到底让谁先上。FCFS 的决策规则最简单,谁先到达谁先执行,完全模仿现实生活里排队的场景。用模拟器的语言翻译一下:把所有进程按到达时间从小到大排好,然后从头到尾依次执行完。
但这里有一个第一次写就容易漏的细节:CPU 空闲间隙。假设第一个进程 0 时刻到达并运行到 4 时刻,而第二个进程 5 时刻才到达,那么在 4 到 5 之间 CPU 是空闲的。写模拟器的时候,运行完第一个进程后 currentTime 停在 4,如果直接把第二个进程的开始时间设成 4,就会算出“进程还没到达就开始运行”的荒谬结果。正确做法是在执行每个进程之前检查一次:如果 currentTime 小于进程的到达时间,先把 currentTime 跳到到达时间,再安排运行。
这部分在真实操作系统里对应的是“CPU 空闲时不调度”的状态。模拟器的好处是时间由我们自己推进,不需要考虑中断处理,但时间推进逻辑恰恰是最容易写错的地方。下面这张表总结了 FCFS 的几个关键词,写代码前先对一遍:
| 特性 | 说明 |
|---|---|
| 决策规则 | 按到达时间排序,先到先执行 |
| 是否抢占 | 非抢占,进程一旦开始运行就执行到结束 |
| 公平性 | 对所有进程一视同仁,不会饿死任何进程 |
| 平均等待时间 | 一般较差,存在“护航效应” |
| 实现难度 | 最低,一次排序加一次遍历 |
护航效应(convoy effect)值得在实验报告里写一笔:一个需要很长时间的大进程排在前面,后面一堆短进程全都要等它,导致整体平均等待时间被拉高。SJF 的出现,本质上就是为了缓解这个问题。
2.2 SJF短作业优先:非抢占与抢占的差异
SJF 的决策规则是:在所有已到达且尚未执行的进程里,挑服务时间最短的先执行。注意两个限定条件——一是“已到达”,二是“尚未执行”。这两个条件少了任何一个,程序都会出问题。
SJF 有两种变体,课程设计里通常默认写非抢占版本:进程一旦开始执行就不会被换下来,等它跑完,再从剩余的已到达进程中选最短的。另一种是抢占式 SJF,也叫 SRTF(最短剩余时间优先):每当有新的进程到达,就检查它的剩余时间是否比当前正在运行进程的剩余时间短,如果是,立刻抢占 CPU。课程设计标题只写了 SJF,一般交非抢占版本即可,抢占版本可以作为扩展加分项,后面第 6 章我会给出具体的扩展代码。
为什么 SJF 能获得更低的平均等待时间?可以用交换论证来理解:假设有两个已到达的进程 A 和 B,服务时间分别是 5 和 2,先执行 A 再执行 B,B 等了 5 个单位;如果先执行 B 再执行 A,A 只多等 2 个单位。把短作业往前挪,短作业省下的等待时间长,长作业多付出的等待时间短,总体净收益为正。这个直觉在实验报告里写清楚,老师会觉得你真的理解了算法。
SJF 也有代价:长作业可能长时间得不到调度,这就是“饥饿”问题。特别是抢占式 SJF,如果有源源不断的短作业到达,长作业可能永远轮不上。这一点在答辩时几乎必问,提前准备好说法。
2.3 调度评价指标:周转时间、带权周转时间与平均等待时间的计算口径
模拟器光输出执行顺序是不够的,课程设计要求一般都要统计三个指标:周转时间、带权周转时间、平均等待时间。公式先写死:
周转时间 = 完成时间 - 到达时间
带权周转时间 = 周转时间 / 服务时间
等待时间 = 周转时间 - 服务时间(非抢占情况下也等于 开始时间 - 到达时间)
平均周转时间 = 所有进程周转时间之和 / 进程数量
平均等待时间 = 所有进程等待时间之和 / 进程数量
这三个公式里最容易踩坑的是等待时间。有些同学会把“到达之前的时间”也算进去,比如进程 5 时刻到达、5 时刻开始运行,正确等待时间是 0,但有人会算成 5。原因在于用“当前系统时间”直接减去到达时间,而没有先做“当前时间小于到达时间就跳过去”的处理。这个坑我会在第 5 章再展开,这里先记住:进程只有在到达之后才可能排队等待,到达之前 CPU 空不空闲跟它没有关系。
带权周转时间反映的是“公平体验”:一个服务时间 1 的短作业周转了 5,带权周转是 5,体验很差;一个服务时间 10 的作业周转 12,带权周转只有 1.2,用户基本感知不到等待。真实操作系统里评价调度算法,这两个指标比单纯的平均周转时间更细腻。
3. 用C++实现FCFS和SJF调度模拟器:PCB数据结构、调度函数与主流程源码拆解
3.1 PCB进程控制块:结构体字段与设计取舍
模拟操作系统的进程调度,首先要模拟“进程”本身。真实操作系统里每个进程有一张 PCB(进程控制块),记录状态、优先级、资源占用等信息。课程设计不需要那么复杂,只要保留和调度相关的字段即可。我用 C++ 结构体实现,而不是 class,原因很简单:这个模拟器里 PCB 只是一个数据容器,不需要封装私有成员,结构体让代码更短、更好读。
#include <iostream> #include <vector> #include <algorithm> #include <iomanip> #include <fstream> #include <cstdlib> #include <climits> using namespace std; // PCB:进程控制块,记录调度需要的全部信息 struct PCB { int id; // 进程编号,从1开始 int arrivalTime; // 到达时间 int serviceTime; // 服务时间:需要占用CPU的时长 int startTime; // 开始执行时间,由调度函数写入 int finishTime; // 完成时间 int turnaroundTime; // 周转时间 = finishTime - arrivalTime double weightedTurnaroundTime; // 带权周转时间 = turnaroundTime / serviceTime };字段设计上要解释几个取舍。startTime 和 finishTime 必须单独存,因为后面画甘特图、算周转时间都要用;weightedTurnaroundTime 用 double 而不用 int,因为带权周转时间往往是小数,比如 11 除以 2 等于 5.5。serviceTime 在模拟器里就是用户输入的一个整数,它代表“进程需要多少时间单位”,时间单位本身没有严格语义,你可以理解为秒,也可以理解为时间片,不影响算法正确性。
3.2 FCFS调度函数:stable_sort排序与执行循环
FCFS 的实现只有三步:按到达时间排序、推进时间、补算指标。但“排序”这一步有个隐藏问题——C++ 标准库的 sort 是不稳定排序,两个到达时间相同的进程,排序后谁在前是不确定的。课程设计里老师经常用手算结果跟程序输出核对,如果程序在到达时间相同时调换了次序,就会被判定为“实现有误”。所以我用 stable_sort,它保证相同到达时间的进程保持输入顺序。
// FCFS(先来先服务)调度 // 核心思路:按到达时间从小到大排序,依次执行 void fcfsSchedule(vector<PCB>& processes) { // 用 stable_sort 而不是 sort: // 到达时间相同的进程保持输入顺序,结果可复现、可核对 stable_sort(processes.begin(), processes.end(), [](const PCB& a, const PCB& b) { return a.arrivalTime < b.arrivalTime; }); int currentTime = 0; // 系统时钟,代表CPU当前推进到的时刻 for (auto& p : processes) { // 如果CPU空闲到进程到达之后,直接把时间跳到到达时刻 if (currentTime < p.arrivalTime) { currentTime = p.arrivalTime; } p.startTime = currentTime; p.finishTime = currentTime + p.serviceTime; currentTime = p.finishTime; p.turnaroundTime = p.finishTime - p.arrivalTime; p.weightedTurnaroundTime = (double)p.turnaroundTime / p.serviceTime; } }这段代码里,currentTime 是唯一的“状态变量”,它模拟 CPU 时钟。每执行完一个进程,currentTime 推进到该进程的完成时刻;如果下一个进程还没到达,说明 CPU 空闲,currentTime 直接跳到它的到达时刻。为什么要这样处理而不是固定每次加 1?因为 FCFS 是非抢占调度,决策只在“上一个进程完成”这个时刻发生,中间的空闲区间没有进程可以运行,一步跳过去是最省事的模拟方式。
参数上要注意:stable_sort 的第三个参数是 lambda 表达式,只比较 arrivalTime;如果两个进程到达时间相等,lambda 返回 false,stable_sort 会保持它们原有的相对顺序,这正是我们要的行为。
3.3 SJF调度函数:就绪队列扫描与时间推进
SJF 非抢占版本不能像 FCFS 那样一次排序解决,因为“可调度集合”是随时间变化的:一个服务时间很短的进程,如果 10 时刻才到达,那就不能影响 0 时刻的决策。所以我用了一个 while 循环,每次循环做两件事:扫描当前已到达且未执行的进程集合,选出服务时间最短的;执行它,更新时间。
// SJF(短作业优先,非抢占)调度 // 核心思路:每次从"已到达但未执行"的进程里选服务时间最短的 void sjfSchedule(vector<PCB>& processes) { int n = processes.size(); vector<bool> done(n, false); // done[i] 标记第i个进程是否已执行完 int currentTime = 0; int completed = 0; while (completed < n) { int idx = -1; // 本轮选中的进程下标 // 扫描所有进程,找出已到达且未执行的服务时间最短者 for (int i = 0; i < n; i++) { if (!done[i] && processes[i].arrivalTime <= currentTime) { // 优先比较服务时间;相同则比到达时间;再相同比编号 if (idx == -1 || processes[i].serviceTime < processes[idx].serviceTime || (processes[i].serviceTime == processes[idx].serviceTime && (processes[i].arrivalTime < processes[idx].arrivalTime || (processes[i].arrivalTime == processes[idx].arrivalTime && processes[i].id < processes[idx].id)))) { idx = i; } } } if (idx == -1) { // 当前时刻没有已到达的未执行进程:CPU空闲 // 把时钟推进到下一个进程的到达时刻,避免死循环 int nextArrival = INT_MAX; for (int i = 0; i < n; i++) { if (!done[i] && processes[i].arrivalTime < nextArrival) { nextArrival = processes[i].arrivalTime; } } currentTime = nextArrival; continue; } // 执行选中的进程,更新指标 processes[idx].startTime = currentTime; processes[idx].finishTime = currentTime + processes[idx].serviceTime; currentTime = processes[idx].finishTime; processes[idx].turnaroundTime = processes[idx].finishTime - processes[idx].arrivalTime; processes[idx].weightedTurnaroundTime = (double)processes[idx].turnaroundTime / processes[idx].serviceTime; done[idx] = true; completed++; } }这段代码有三个关键点。第一,选择进程时的三级比较:服务时间最短优先,服务时间相同时到达时间早的优先,再相同则编号小的优先。这个“回退到 FCFS”的规则非常重要,没有它,相同服务时间的进程执行次序不确定,结果就无法和手算核对。第二,idx == -1 的分支处理:如果当前没有进程可执行,必须把 currentTime 推进到最近的未来到达时刻,否则 while 循环会在同一个 currentTime 原地转圈,程序表现为“运行后卡死”。第三,done 数组标记执行状态,保证每个进程只被选中一次;进程是否到达的判断用的是 arrivalTime <= currentTime,注意是小于等于,恰好在当前时刻到达的进程可以立即参与选择。
3.4 主函数与打印函数:演示流程和结果输出
课程设计的演示环节讲究“让老师一眼看懂”。我的做法是:程序启动后让用户选择键盘输入还是文件读入,然后依次运行 FCFS 和 SJF,输出两张结果表加一张甘特图。因为两个调度函数都会修改 processes 数组(FCFS 会改变元素顺序),所以运行第二个算法前必须从备份恢复数据,这是一个很容易被忽略的数据完整性问题。
// 从文件读取进程信息 // 文件格式:第一行为进程数量n,之后每行三个整数:编号 到达时间 服务时间 void readFromFile(const string& filename, vector<PCB>& processes) { ifstream fin(filename); if (!fin.is_open()) { cout << "无法打开文件 " << filename << endl; exit(1); } int n; fin >> n; processes.resize(n); for (int i = 0; i < n; i++) { fin >> processes[i].id >> processes[i].arrivalTime >> processes[i].serviceTime; } fin.close(); } // 输出进程统计表 void printResult(const vector<PCB>& processes) { int n = processes.size(); double totalTAT = 0.0, totalWT = 0.0; cout << left << setw(6) << "进程" << setw(8) << "到达" << setw(8) << "服务" << setw(8) << "开始" << setw(8) << "完成" << setw(10) << "周转" << setw(12) << "带权周转" << setw(8) << "等待" << endl; for (const auto& p : processes) { double waitTime = p.turnaroundTime - p.serviceTime; totalTAT += p.turnaroundTime; totalWT += waitTime; cout << left << setw(6) << ("P" + to_string(p.id)) << setw(8) << p.arrivalTime << setw(8) << p.serviceTime << setw(8) << p.startTime << setw(8) << p.finishTime << setw(10) << p.turnaroundTime << setw(12) << fixed << setprecision(2) << p.weightedTurnaroundTime << setw(8) << fixed << setprecision(2) << waitTime << endl; } cout << "平均周转时间:" << fixed << setprecision(2) << totalTAT / n << endl; cout << "平均等待时间:" << fixed << setprecision(2) << totalWT / n << endl; } // 甘特图:按开始时间排序,显示进程实际执行区间 void printGanttChart(vector<PCB> processes) { stable_sort(processes.begin(), processes.end(), [](const PCB& a, const PCB& b) { return a.startTime < b.startTime; }); cout << "调度顺序(甘特图):"; for (const auto& p : processes) { cout << "P" << p.id << "[" << p.startTime << "," << p.finishTime << ") "; } cout << endl; } int main() { vector<PCB> processes; cout << "C++模拟操作系统进程调度:FCFS + SJF" << endl; cout << "1. 键盘输入 2. 文件读入,请选择:"; int choice; cin >> choice; if (choice == 2) { string filename; cout << "请输入文件名:"; cin >> filename; readFromFile(filename, processes); } else { int n; cout << "请输入进程数量:"; cin >> n; processes.resize(n); for (int i = 0; i < n; i++) { processes[i].id = i + 1; cout << "进程P" << i + 1 << "的到达时间和服务时间:"; cin >> processes[i].arrivalTime >> processes[i].serviceTime; } } vector<PCB> backup = processes; // 备份原始数据,SJF运行前要恢复 cout << "\n========== FCFS 调度结果 ==========" << endl; fcfsSchedule(processes); printResult(processes); printGanttChart(processes); processes = backup; // 恢复数据,避免FCFS排序影响SJF cout << "\n========== SJF 调度结果 ==========" << endl; sjfSchedule(processes); printResult(processes); printGanttChart(processes); return 0; }主函数里备份恢复这行代码,是很多人忽略的。FCFS 里 stable_sort 已经把 processes 里的元素重新排过序,如果直接拿这个排过序的数组去跑 SJF,等于改变了进程的初始顺序,SJF 的编号链就会混乱。printGanttChart 的参数故意按值传递,在里面再做一次 stable_sort,这样不会污染外面的数据。输出格式上,等待时间单独算一列,和带权周转时间都保留两位小数,表格对齐用 setw 控制,交作业时截图会很好看。
4. 测试用例与结果输出设计:同一组输入跑出两种算法的对比才有说服力
4.1 测试用例设计:到达时间与服务时间错开,才能看出差异
很多同学交的作业里,测试数据只有三四个进程,而且到达时间全是 0。这种数据跑 FCFS 和 SJF 结果一样(因为 0 时刻全部到达,FCFS 按输入顺序,SJF 按服务时间排序,两者输出不同但看不出“调度时机”的差异),答辩时老师一问就露馅。测试数据的核心要求是:到达时间分散、服务时间长短差异明显、最好出现 CPU 空闲间隙。
我常用的第一组数据是 5 个进程,覆盖了“先到达的长作业”和“后到达的短作业”同时存在的场景,能清楚看出 SJF 比 FCFS 省在哪:
| 进程 | 到达时间 | 服务时间 |
|---|---|---|
| P1 | 0 | 4 |
| P2 | 1 | 3 |
| P3 | 2 | 5 |
| P4 | 3 | 2 |
| P5 | 4 | 1 |
把这组数据存成文本文件,第一行是进程数量,后面每行是“编号 到达时间 服务时间”:
5 1 0 4 2 1 3 3 2 5 4 3 2 5 4 1从文件读入的好处是演示时不用现场敲数据,也显得工程化。程序跑出来的结果要和手算的基准值一致,才算验证通过。
4.2 手算基准结果:FCFS和SJF的完整执行过程对照
先手算一遍 FCFS。所有进程按到达时间排序后就是 P1、P2、P3、P4、P5 的顺序,中间没有空闲间隙:
FCFS 执行过程:P1 在 0 到 4 运行,P2 在 4 到 7,P3 在 7 到 12,P4 在 12 到 14,P5 在 14 到 15。每个进程的指标如下表:
| 进程 | 到达 | 服务 | 开始 | 完成 | 周转 | 带权周转 | 等待 |
|---|---|---|---|---|---|---|---|
| P1 | 0 | 4 | 0 | 4 | 4 | 1.00 | 0 |
| P2 | 1 | 3 | 4 | 7 | 6 | 2.00 | 3 |
| P3 | 2 | 5 | 7 | 12 | 10 | 2.00 | 5 |
| P4 | 3 | 2 | 12 | 14 | 11 | 5.50 | 9 |
| P5 | 4 | 1 | 14 | 15 | 11 | 11.00 | 10 |
平均周转时间 8.40,平均等待时间 5.40。注意 P5 服务时间最短,却在最后一个执行,等待时间高达 10,这就是护航效应的直接体现。
再看 SJF。0 时刻只有 P1 到达,只能选它;P1 在 0 到 4 运行。4 时刻 P2、P3、P4、P5 都已到达,它们服务时间分别是 3、5、2、1,最短的是 P5,所以 P5 先执行,然后是 P4、P2,最后 P3:
| 进程 | 到达 | 服务 | 开始 | 完成 | 周转 | 带权周转 | 等待 |
|---|---|---|---|---|---|---|---|
| P1 | 0 | 4 | 0 | 4 | 4 | 1.00 | 0 |
| P5 | 4 | 1 | 4 | 5 | 1 | 1.00 | 0 |
| P4 | 3 | 2 | 5 | 7 | 4 | 2.00 | 2 |
| P2 | 1 | 3 | 7 | 10 | 9 | 3.00 | 6 |
| P3 | 2 | 5 | 10 | 15 | 13 | 2.60 | 8 |
平均周转时间 6.20,平均等待时间 3.20。对比非常直观:同样的 5 个进程,SJF 把平均等待时间从 5.40 压到了 3.20。这个“同一组数据、两个算法、指标对比”的表格放在实验报告里,比任何文字都说明问题。
4.3 输出格式:进程统计表、甘特图与平均指标的排版
程序运行这份测试数据的输出大致长这样:
========== FCFS 调度结果 ========== 进程 到达 服务 开始 完成 周转 带权周转 等待 P1 0 4 0 4 4 1.00 0.00 P2 1 3 4 7 6 2.00 3.00 P3 2 5 7 12 10 2.00 5.00 P4 3 2 12 14 11 5.50 9.00 P5 4 1 14 15 11 11.00 10.00 平均周转时间:8.40 平均等待时间:5.40 调度顺序(甘特图):P1[0,4) P2[4,7) P3[7,12) P4[12,14) P5[14,15) ========== SJF 调度结果 ========== 进程 到达 服务 开始 完成 周转 带权周转 等待 P1 0 4 0 4 4 1.00 0.00 P2 1 3 7 10 9 3.00 6.00 P3 2 5 10 15 13 2.60 8.00 P4 3 2 5 7 4 2.00 2.00 P5 4 1 4 5 1 1.00 0.00 平均周转时间:6.20 平均等待时间:3.20 调度顺序(甘特图):P1[0,4) P5[4,5) P4[5,7) P2[7,10) P3[10,15)SJF 的结果表按输入顺序打印,甘特图则真实反映了执行顺序。这里有个细节:表里 P5 的等待时间是 0,因为它在 4 时刻到达,SJF 立即选中它;但它在 0 到 4 期间并不在系统中,这段时间不算等待。如果程序输出 P5 等待时间是 4,那就是把到达前的时间算进去了,需要回去检查 currentTime 的推进逻辑。
另外建议准备第二组边界数据,专门测试 CPU 空闲间隙:
3 1 5 2 2 6 1 3 10 3第一个进程 5 时刻才到达,0 到 5 之间 CPU 空闲;P2 在 6 到达,P1 跑到 7 才结束,P2 等了一会儿;P3 在 10 到达,而 P2 在 7 到 8 就结束了,8 到 10 之间又有空闲。这组数据能验证程序在空闲间隙处的时间跳跃是否正确,跑出来的甘特图应该是 P1[5,7) P2[7,8) P3[10,13),平均等待时间 0、1、5 分别为 P1 0、P2 0(7 到达 6?等一下,P2 到达 6,开始 7,等待 1)、P3 0(10 到达,10 开始,等待 0)。手算一遍后和程序输出核对,确认无误。
5. C++课程设计避坑指南:FCFS和SJF实现中最常见的翻车点与排查方法
5.1 到达时间相同时排序不稳定,运行结果和手算对不上
现象:输入两个到达时间相同的进程,比如 P1(0,3)、P2(0,5),程序输出的执行顺序有时是 P1 先,有时是 P2 先,同一份代码前后运行结果不一致。
原因:FCFS 实现里用了 sort 而不是 stable_sort。C++ 标准库的 sort 是不稳定排序,当比较函数对两个元素的判断结果相等(都为 false)时,它们的最终顺序由算法内部决定,不保证和输入顺序一致。课程设计里手算 FCFS 的默认规则是“到达时间相同就按输入顺序”,于是程序结果和手算对不上。
解决:把 sort 换成 stable_sort,或者给进程加一个显式的 id 字段,排序时用arrivalTime < b.arrivalTime作为主比较、id < b.id作为次比较。我建议两种都做,stable_sort 保证基本行为,id 字段作为最后兜底,双保险。
5.2 SJF遇到服务时间相同的进程,没有回退FCFS导致次序错乱
现象:数据里有两个服务时间相同的进程,SJF 输出的执行顺序是“后输入的在前”。比如 P1(0,5)、P2(1,2)、P3(2,2),0 时刻只有 P1,P1 先跑;5 时刻 P2 和 P3 都已到达,服务时间都是 2,程序选了 P3,但手算时默认选先到达的 P2。
原因:SJF 选进程时只比较了 serviceTime,两个进程服务时间相等时,比较结果为 false,于是 idx 保留的是扫描过程中第一次遇到的那个进程。如果扫描顺序刚好是 P3 在前,P3 就被选中了。
解决:像第 3 章代码里那样做三级比较:服务时间 → 到达时间 → 编号。这里有个值得写的细节:交换两个相邻同服务时间进程,平均周转时间不会变(总周转时间不变),但对每个进程的周转时间影响很大,老师核对单进程指标时依然会对不上。所以优先级不仅仅是为了“好看”,是为了结果可复现。
5.3 平均等待时间算错:把到达之前的时间计入了等待
现象:某进程到达时间是 5,开始时间是 5,程序却算出等待时间 5;或者某个进程到达时间很晚,等待时间竟然是负数。
原因:等待时间如果直接用currentTime - arrivalTime计算,而 currentTime 还没做“小于到达时间就跳跃”的处理,就会把 CPU 空闲时间误算成进程等待。负数的情况通常是进程的 startTime 小于 arrivalTime,说明代码在进程未到达时就调度了它,时间推进逻辑有错。
解决:统一用finishTime - arrivalTime - serviceTime计算等待时间,三个字段都来自 PCB,不依赖调度函数外部的 currentTime 变量。再用一个极端用例自检:进程 5 时刻到达、5 时刻开始、7 时刻结束,服务 2,等待必须算 0。如果算出来不是 0,说明调度时把开始时间设早了。
5.4 SJF死循环:所有未执行进程都还没到达时缺少时间推进
现象:程序运行 SJF 时卡住不动,CPU 占用 100%,必须强制关闭终端。
原因:SJF 的 while 循环里,某个时刻所有未执行进程的到达时间都大于 currentTime,扫描后 idx 保持 -1。如果代码直接continue而没有把 currentTime 向前推进,while 循环会无限重复“扫描 → 选不中 → 继续扫描”这个过程,形成死循环。这是 SJF 实现里最典型的运行时错误。
解决:在 idx == -1 时,扫描所有未执行进程,找出最小的 arrivalTime,把 currentTime 跳到那个值,再 continue。这个分支本质上是模拟“CPU 空闲等待进程到达”,和 FCFS 里的时间跳跃是一回事。排查时可以临时加一行cout << currentTime << endl;看时间是否在某处原地不动。
5.5 数据没备份:FCFS跑完直接跑SJF,结果张冠李戴
现象:程序先跑 FCFS 再跑 SJF,SJF 的结果表里进程编号顺序乱了,比如 P5 出现在第一行,且甘特图和指标全对不上。
原因:FCFS 的 stable_sort 已经重新排列了 processes 数组的元素顺序。接着把同一个数组传给 sjfSchedule,SJF 拿到的“输入顺序”其实是 FCFS 排序后的顺序。SJF 本身不要求输入有序,但打印结果表时按数组顺序输出,原本的 P1 到 P5 顺序就乱了;如果还依赖数组下标和编号的对应关系,指标会算到错误的进程上。
解决:在 main 里进入第一个算法前vector<PCB> backup = processes;,跑完 FCFS 后执行processes = backup;再跑 SJF。这里还有一个更隐蔽的版本:备份后 FCFS 和 SJF 用的是同一个备份副本,但第一个算法的输出结果(比如甘特图)依赖排序,而甘特图函数内部又排序一次,双重排序可能导致输出顺序看起来不对——所以甘特图函数要按值传参,别影响外部数据。
提示:调试阶段建议在每次调度后打印数组首元素的 id,确认数据顺序没有被上一个算法污染。
5.6 自查三板斧:手算、边界用例、打印中间变量
写完代码先不要急着截屏交作业。我的自查顺序是三步。第一,挑一组 3 个进程的小数据,到达时间全为 0,服务时间分别是 2、5、3,这时 FCFS 和 SJF 的结果可以完全手算,程序输出逐项比对。第二,准备一组到达时间分散的边界数据,验证时间和空闲间隙。第三,在 SJF 的 while 循环里临时打印 currentTime 和 idx,观察时间推进是否符合预期。这三步做完,程序的基本正确性就有保障了。
6. 从交作业到答辩:扩展抢占式SJF并准备三个必答问题
6.1 加分项:把非抢占SJF改成抢占式SRTF
如果想把课程设计做成更完整的作品,强烈建议加一个抢占式 SJF(SRTF)。改动量不大,核心思路从“每次选一个进程跑完”变成“每个时间单位检查一次,剩余时间最短的进程获得 CPU”。实现上我用逐时间片推进的方式,简单直观:
// SRTF:抢占式短作业优先(最短剩余时间优先) // 每个时间单位结束后重新调度,允许新到达的短作业抢占CPU void srtfSchedule(vector<PCB>& processes) { int n = processes.size(); vector<int> remain(n); // 剩余服务时间 for (int i = 0; i < n; i++) remain[i] = processes[i].serviceTime; int currentTime = 0, completed = 0; while (completed < n) { // 选出当前已到达且剩余时间最短的进程 int idx = -1; for (int i = 0; i < n; i++) { if (remain[i] > 0 && processes[i].arrivalTime <= currentTime) { if (idx == -1 || remain[i] < remain[idx]) idx = i; } } if (idx == -1) { currentTime++; // CPU空闲,推进1个时间单位 continue; } remain[idx]--; // 运行1个时间单位 currentTime++; if (remain[idx] == 0) { processes[idx].finishTime = currentTime; processes[idx].turnaroundTime = processes[idx].finishTime - processes[idx].arrivalTime; processes[idx].weightedTurnaroundTime = (double)processes[idx].turnaroundTime / processes[idx].serviceTime; completed++; } } }这段代码改变了调度时机:非抢占 SJF 只在进程完成时决策,SRTF 每个时间单位都决策,所以每个进程可能在执行中途被换下。把这段代码加进主菜单,用同一组测试数据跑三个算法,实验报告里再补一张“三种算法指标对比”表,加分效果很明显。注意 SRTF 的 startTime 没有直接赋值,因为进程可能多次被换入换出;如果实验报告要求展示开始时间,可以记录“首次获得 CPU 的时间”,这也是一个可以主动跟老师讨论的改进点。
6.2 答辩必问的三个问题与回答思路
第一个问题:“为什么 SJF 能得到更小的平均等待时间?”回答思路是用交换论证说明,短作业提前执行省下的集体等待大于长作业增加的等待,然后指出 SJF 是“理论最优”的非抢占算法。第二个问题:“真实操作系统里为什么不用 SJF?”回答思路是 SJF 需要预先知道每个进程的服务时间,这在真实系统里做不到,进程要跑多久属于未知信息;真实系统用的是多级反馈队列等启发式算法,靠运行历史估计进程性质。第三个问题:“SJF 会不会饿死进程?”回答思路是会,特别是抢占式变体,持续到达的短作业可能让长作业永远等不到 CPU,并说明这涉及到系统的公平性权衡。
回答这三个问题时,如果能顺手举出自己测试数据里的例子,比如 P5 在 FCFS 里等待 10、在 SJF 里等待 0,说服力会比干讲理论强得多。我做这个模拟器时最深的教训是:代码写完只是第一步,把“为什么这么设计、有没有更好的方案”想清楚才是课程设计真正要训练的东西。后来每次写调度相关的代码,我都会先把基准用例手算一遍,再让程序跑——这省下了大量排错时间。希望你也能用这套方法,把自己的课程设计做成拿得出手的作品,希望帮到你。
本文还有配套的精品资源,点击获取