ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

操作系统课程设计:用C++实现进程调度算法模拟器(FCFS/SJF/RR)

操作系统课程设计:用C++实现进程调度算法模拟器(FCFS/SJF/RR)

简介:一份面向操作系统课程设计与大作业场景的C++进程调度模拟实现源码包,覆盖动态优先级、先来先服务、时间片轮转三种经典调度算法,适合计算机相关专业学生理解进程调度原理、完成课程报告或进行算法对比实验。压缩包共25个文件,以源程序文件、工程配置文件、解决方案文件、可执行程序为主,另含调试符号、构建日志、中间文件及说明文档,整体仅有1.88MB,结构紧凑。源码基于Visual Studio开发,下载后用VS直接打开即可运行,便于查看三种算法各自如何管理就绪队列、分配处理器并输出调度时刻表。已有4793人学习使用,对正在准备操作系统课设或需要参考调度算法实现的读者具有较高参考价值,也可作为复习进程管理章节的辅助素材。

1. 操作系统课程设计里的进程调度模拟:这道大作业到底在考什么

如果你正在为操作系统课程设计的大作业选题发愁,进程调度算法的模拟实现大概是被学长学姐推荐最多的一个方向。它不依赖真实硬件,不要求你把Linux内核调起来,只需要用C++把FCFS、SJF、RR、优先级调度这些算法跑通,最后输出一份可对比的调度结果。听起来不难,但每年都有大量作业挂在同一批问题上:代码写完了不知道结果对不对,调度顺序讲不清楚,进程状态管理一复杂就崩,答辩时被老师追问一个“时间片到了谁先切换”就卡壳。

这篇文章会从调度算法的选型逻辑讲起,带你把PCB设计、核心调度循环、指标计算和文本甘特图输出完整过一遍。整份方案不依赖任何第三方库,C++11以上就能编译,在Visual Studio或者Linux下用g++都能跑。你既能拿来做课程设计源码提交,也能在报告里把每个设计决策讲明白。文章结尾还会给出四组必测的边界输入,用来验证你的模拟器在不同调度策略下是否真的正确——这一步才是课设拿高分的关键。

2. 进程调度算法的选型:哪些算法组合能在课设里讲出深度

2.1 五种经典调度算法的对比表与选型逻辑

进程调度算法是这道大作业的心脏。常见的做法是至少实现三种以上算法,并保证它们共用同一套进程池和模拟框架。下面的表基本覆盖了课设最常见的算法组合:

算法核心决策依据抢占性主要指标优势明显缺陷
FCFS按到达时间排队非抢占实现最简单、公平平均等待时间可能很高
SJF(非抢占)到达后选服务时间最短非抢占平均周转时间理论最优长作业可能饥饿
SRTF(抢占式SJF)每次新进程到达时比较剩余时间抢占平均周转时间更优切换开销大、长作业饥饿更严重
RR就绪队列轮转抢占(时间片到期)响应时间可控时间片大小严重影响性能
多级反馈队列按优先级分队列,高队列时间片短抢占兼顾响应时间和吞吐参数调整复杂

如果你是第一次做这个题,我的建议是选FCFS、SJF、RR加上优先级调度这四种作为基础课设组合。四个算法能覆盖非抢占和抢占两类场景,报告里的对比实验也能做出明确差异——比如FCFS的平均等待时间明显高于非抢占SJF,RR的响应时间比FCFS好但上下文切换次数多。如果只做两个算法,答辩时深度不够,容易被问“你为什么不考虑多级队列”。如果贪多把六七个算法全塞进去,光是调试抢占式调度的时间就够你通宵几次。优先级调度可以做得简单一点:静态优先级,数字越小优先级越高,老化机制作为扩展点在报告里提一笔即可。

2.2 统一调度器框架:怎么让多种算法共用一套代码

这四个算法看起来差异很大,但从实现角度其实可以统一在一个调度器框架下。关键在于想清楚一个核心问题:**谁来决定进程切换的时机?**FCFS和SJF是“当前进程运行完才切换”,RR和SRTF是“额外的事件触发切换”。我在课设里采用的做法,是给每种算法实现两个回调函数:shouldPreempt负责判断“是否有必要打断当前进程”,pickNext负责从就绪队列里选出下一个要运行的进程。

这样一个调度器核心循环就能写得很干净:用当前时间推进,进程到达时插入就绪队列,运行态进程执行到“完成或时间片耗尽”,然后判断是否切换。RR算法里shouldPreempt判断当前运行时长是否达到时间片;SRTF里它判断就绪队列里是否出现了剩余时间更短的进程。这样四种算法不需要各自写一套主循环,后面加新算法也只是新增两个函数。更重要的是,答辩时你能直接指出这种设计的好处:调度策略与模拟时钟解耦,代码复用于是有了实打实的证据。

3. 用C++搭出模拟内核:PCB结构与时间轴推进

3.1 PCB的字段设计:进程控制块应该装哪些东西

进程控制块是整个模拟器的数据结构核心。课程设计里不需要仿照真实内核做得很复杂,但关键字段一个不能少。我设计PCB时会区分两类字段:进程的“静态属性”和“动态运行状态”。静态属性包括进程ID、到达时间、服务时间、初始优先级,这些在读取输入时就固定下来;动态状态包括剩余服务时间、当前优先级、进入就绪队列的时间、开始运行时间、完成时间、累计运行时间。

struct PCB { int pid; // 进程ID double arriveTime; // 到达时间, 课设数据建议用整数或一位小数 double serviceTime; // 服务时间(总CPU时间) double remainTime; // 剩余服务时间, 初始等于serviceTime int priority; // 静态优先级, 数字越小优先级越高 double startTime; // 第一次获得CPU的时间, -1表示还没运行过 double finishTime; // 完成时间 double runTime; // 累计已运行时间 ProcessState state; // NEW / READY / RUNNING / TERMINATED int queueID; // 预留: 多级队列扩展用 };

这个结构里容易踩坑的是时间字段的精度。如果你用double存时间,就绪队列排序时比较到达时间要带上epsilon容错,否则两个到达时间相同的进程先后顺序会因为浮点误差出现乱序。我一般用double存储,但在比较函数里写fabs(a.arriveTime - b.arriveTime) < 1e-9来判定相等。

另外一个容易被忽略的是startTime的初始化。它必须与“进程是否已经开始运行”关联起来,不是简单初始化为0。课设输出平均等待时间时要计算等待时间,等待时间 = 第一次开始运行时间 - 到达时间,所以这个字段没记准,后面所有统计指标都是错的。

3.2 模拟时钟的推进方式:逐片扫描还是事件驱动

时间推进是模拟器的第二个核心问题。新手最常见的写法是for循环里时间从0开始,每个单位时间扫描一次所有进程。这种写法的缺点是当单位时间比较小时,比如0.1秒一个时钟片,而进程的服务时间又很大,运行到几百个循环才能完成一道题,数据一多就非常慢。

我采用事件驱动的推进方式,每轮循环里时间直接跳到下一个“有意义”的时刻。所谓有意义时刻,就是有进程到达、或正在运行的进程完成、或RR时间片到期的时刻。

// 调度器主循环伪代码 while (!allTerminated) { // 1. 把当前时刻之前到达的进程放入就绪队列 while (nextArrivalIndex < processes.size() && processes[nextArrivalIndex].arriveTime <= currentTime + EPS) { readyQueue.push(processes[nextArrivalIndex]); nextArrivalIndex++; } // 2. 如果CPU空闲且就绪队列非空, 直接选一个进程开始运行 if (currentProcess == nullptr && !readyQueue.empty()) { currentProcess = pickNext(readyQueue); if (currentProcess->startTime < 0) currentProcess->startTime = currentTime; } // 3. 如果没有进程可运行, 把时间跳到下一个到达事件 if (currentProcess == nullptr) { if (nextArrivalIndex < processes.size()) { currentTime = processes[nextArrivalIndex].arriveTime; } else { break; // 所有进程都已到达, 理论不会走到这里 } continue; } // 4. 计算下一个触发时刻: 完成、时间片到期、新进程到达(抢占算法需要考虑) double nextArrival = (nextArrivalIndex < processes.size()) ? processes[nextArrivalIndex].arriveTime : INF; double nextFinish = currentTime + currentProcess->remainTime; double nextPreempt = currentTime + timeSlice - currentProcess->runTime; // 5. 按算法类型决定时间推进到哪个时刻, 更新状态 advanceToNextEvent(/* ... */); }

不要小看第3步那个“直接跳到下个到达时间”。如果不做这一步,CPU空闲但当前时间还在逐个时钟片推进,输出结果时你会看到一段冗长的“CPU空闲段”,计算结果没问题但运行效率很差。事件驱动的好处是模拟器可以在毫秒级完成几百个进程的调度运算,你在报告里可以自信地写“采用事件驱动而非逐周期扫描,时间复杂度与事件数相关而非与总时长相关”。

3.3 状态切换的四个关键动作

调度器里所有算法最终都落在一组固定操作上:当前进程被移出CPU、新进程被加载、进程状态更新、统计字段记录。这四个动作无论如何组合,都不能绕开。我建议不要在每个算法里分别实现状态切换逻辑,而是封装成switchProcess()函数,这样能避免一个隐蔽问题:进程完成和进程被抢占的收尾逻辑不一样。

bool switchProcess(PCB* oldProc, PCB* newProc, double currentTime) { if (oldProc != nullptr) { oldProc->runTime += currentTime - oldProc->lastSwitchTime; if (fabs(oldProc->runTime - oldProc->serviceTime) < EPS) { // 进程完成 oldProc->finishTime = currentTime; oldProc->state = TERMINATED; } else { // 进程被换出, 回到就绪 oldProc->state = READY; readyQueue.push(oldProc); } } if (newProc != nullptr) { newProc->lastSwitchTime = currentTime; if (newProc->startTime < 0) newProc->startTime = currentTime; newProc->state = RUNNING; } return true; }

这个函数里有几个字段需要和3.1的PCB设计对应:lastSwitchTime记录进程最近一次获得CPU的时刻。需注意进程被换出那一刻,runTime需要累加上本次运行区间,但remainTime也要同步更新,我习惯用remainTime = serviceTime - runTime而不是单独维护,避免两个字段不一致。如果两个字段只用一个,剩余时间判断出错会直接导致SRTF调度结果混乱。

4. 算法实现的关键代码:从FCFS到RR的调度顺序怎么算对

4.1 FCFS与SJF的pickNext实现:就绪队列的比较规则

做过课设的应该都有经验:FCFS和SJF的实现差异只在就绪队列的排序规则。FCFS按到达时间排,到达时间相同的按pid排;SJF按服务时间排,服务时间相同的按到达时间排。有了3.2节统一调度器,实现两个算法只是写两个比较函数的问题。

// FCFS: 先到先得, 队首就是最早到达的 struct FCFSCompare { bool operator()(const PCB* a, const PCB* b) const { if (fabs(a->arriveTime - b->arriveTime) > EPS) return a->arriveTime > b->arriveTime; // 小顶堆: 到达时间小的在前 return a->pid > b->pid; } }; // SJF非抢占: 在已到达进程中选服务时间最短的 struct SJFCompare { bool operator()(const PCB* a, const PCB* b) const { if (fabs(a->serviceTime - b->serviceTime) > EPS) return a->serviceTime > b->serviceTime; if (fabs(a->arriveTime - b->arriveTime) > EPS) return a->arriveTime > b->arriveTime; return a->pid > b->pid; } };

这几个比较函数就是整个模拟器的“调度策略插件”。把比较函数作为模板参数或者函数对象传进就绪队列,main里声明不同的优先队列容器,就能跑出不同算法。请注意这里的细节:优先队列默认是“最大元素在顶端”,所以比较函数的返回值含义需要仔细推理。我在代码里写的是“如果a应排在b后面则返回true”,而operator>的写法会让到达时间最小的进程待在队首——建议每个算法写完后,先用一个小数据用例打印调度结果,确定顺序符合预期再去跑大用例。

非抢占SJF里的一个隐藏问题是:就绪队列里的进程是“当前时刻已经到达”的,而这个队列是动态变化的。也就是说,CPU空闲时pickNext选出的进程,与CPU运行时新到达的进程,两者不会触发重新选择——这正是非抢占的定义。很多课设实现把SJF写成了伪抢占式,每当新进程到达就重新比较,这是错的。你的报告里需要明确区分这一点,答辩时老师特别喜欢追问。

4.2 RR轮转调度的实现:时间片推进与队列尾部插入

RR的代码量也不大,但坑在细节。时间片到了之后,当前进程如果没完成,要被放到就绪队列尾部,而不是重新参与比较。另外RR的模拟时钟推进到“时间片到期”这个事件点时,要处理一个边界:新进程在当前时间片结束前到达,应等本轮时间片用完再切换,还是立即切换?

理论上标准RR是等当前时间片用完再切换。但实际模拟中这个规则在课程设计报告里应当写明。我的实现里RR的版本采用了标准答案的写法:每次调度时间为min(remainTime, timeSlice),到期时换人。新进程到达不抢占。

// 在调度主循环的advanceToNextEvent中, RR分支如下: double duration = std::min(currentProcess->remainTime, timeSlice); double nextEventTime = currentTime + duration; bool canRunFullSlice = true; // 如果时间片未用完前有进程到达, 当前进程仍然继续运行, 但下一个事件时刻不变 // 推进时钟并更新运行状态 currentProcess->remainTime -= duration; currentProcess->runTime += duration; currentTime = nextEventTime; // 更新完状态后, 如果运行中进程已完成 if (currentProcess->remainTime <= EPS) { markTerminated(currentProcess, currentTime); currentProcess = nullptr; } else { // 时间片耗尽但未完成, 放入就绪队列尾部 readyQueue.push(currentProcess); currentProcess = nullptr; }

这段代码逻辑并不复杂,真正容易踩坑的地方是第4行的nextEventTime——如果currentTime本身已经因为新进程到达而变过,这里的计算不能直接用全局currentTime,而应该用currentProcess->lastSwitchTime作为时间片起始点。另一个容易翻车的地方是运行时间的浮点累计:如果在多次迭代里反复对remainTime做减法,浮点误差会累积到让剩余时间变成0.000001这种诡异值。解决方式是当remainTime < timeSlice * 1e-6时直接按进程完成处理,而不是死等它减到0。

4.3 指标计算:周转时间和带权周转时间怎么统计

做完调度模拟后,输出指标是课设报告的硬通货。操作系统教材里定义的平均周转时间、平均带权周转时间,是反映调度算法性能的两个最核心指标。计算它们需要一个前置条件:每个进程的完成时间被正确记录。FinishTime在PCB里已经定义,一轮调度跑完,遍历所有进程统计即可。

double calcAverageTurnaroundTime(const std::vector<PCB>& procs) { double sum = 0.0; for (const auto& p : procs) { sum += p.finishTime - p.arriveTime; } return sum / procs.size(); } double calcAverageWeightedTurnaroundTime(const std::vector<PCB>& procs) { double sum = 0.0; for (const auto& p : procs) { sum += (p.finishTime - p.arriveTime) / p.serviceTime; } return sum / procs.size(); }

这两个函数本身不难,但要注意输出格式。工程上习惯用"%.2f"保留两位小数,因为课设报告的三线表里精确到0.01就足够说明问题。另一个隐藏点是——带权周转时间的定义分母是服务时间,如果服务时间可以为0,除零会让你程序崩溃。课设输入数据里建议限定服务时间大于0,代码里加一行防御性校验,在读取阶段就将非法数据报错。

5. 进程调度模拟的避坑记录:四条耗时一整晚的典型问题

5.1 坑一:SRTF抢占条件写反,长进程永远抢占不到CPU

现象:用SRTF跑一组数据,短进程一直运行,长进程的完成时间特别大,甚至输出结果和其他参考实现完全对不上。

原因:抢占式SJF的切换条件不是“新到达的进程服务时间比当前进程剩余时间短”,而是“比当前进程的剩余服务时间短”。我调试时发现有些实现直接比较新进程的serviceTime和当前进程的serviceTime,而不是比较剩余时间。这会让运行到一半的长进程因为“原始服务时间长”被反复换出,产生饥饿。

解决:确保比较用的字段是remainTime,而不是serviceTime。代码里在shouldPreempt函数中使用newProc->serviceTime < currentProc->remainTime - EPS判断是否抢占。同时注意边界:如果新进程的serviceTime与当前进程remainTime相等,标准SRTF会维持当前进程继续运行。

5.2 坑二:时间片小到极端时,RR的上下文切换次数异常高

现象:给RR设置时间片为0.1,进程服务时间平均5,跑完后输出结果里的切换次数达到几千次,报告里画出的甘特图密密麻麻无法阅读。

原因:时间片远小于服务时间时,任何进程都不可能在一个时间片内完成,每次时间片到期都会触发切换。这在模拟器逻辑上没错,但课程设计报告里说服力很差——甘特图根本没法展示。

解决:课设数据范围设置时间片为服务时间的四分之一到二分之一比较合理。比如服务时间5,时间片设为1或2。同时建议在实验结果中额外输出总切换次数,用数字论证“时间片过大响应变差,过小开销增加”。这个维度是答辩加分项。

5.3 坑三:就绪队列用了全局优先级比较,导致到达时间改变后排序不更新

现象:FCFS先跑结果正常,但切换成SJF后,进程完成的顺序频繁出现“后到达的短作业排到了先到达的长作业前面,但输出顺序却是按pid排列”。

原因:就绪队列内部用优先队列做堆排序,但模拟过程中如果修改了进程的某个比较字段(比如在静态数组里更新了状态),而堆里的元素是PCB指针,排序依据不会随之更新。更常见的错误是——把进程插入队列和提取队列的动作写对了,但比较函数里引用的字段在后续被修改,导致堆结构混乱。

解决:调度器的设计原则上是“进程一旦进入就绪队列,其参与排序的字段在本次等待期间不再改变”。SRTF的实现之所以要特殊处理,是因为它需要在进程被抢占后重新进入队列,这时remainTime已经变了,必须重新push一次而不是原位修改。我的做法是:从CPU上换下的进程,无论什么原因,都执行一次readyQueue.pop再push,确保堆内部顺序被重建。

5.4 坑四:输出甘特图时进程ID对齐混乱

现象:多行输出的甘特图在pid为两位数时错位,横轴的刻度对不上,看起来杂乱无章。想在报告里截图,但输出一眼看去就是“没用心做”的既视感。

原因:格式化输出时没有统一字段宽度,用空格拼接而不是格式化控制符。

解决:用C++的iomanip库统一宽度,比如每格宽度为8个字符,内容是[PID:%2d]这种固定格式。甘特图输出其实是课设报告里最有视觉冲击力的部分,值得花点时间把格式调好。我自己习惯是输出一行时间刻度,一行布局条,一行进程ID,三行一组,时间用setw(5)固定占位,这样即使模拟到50个进程也能轻松看清调度顺序。

6. 边界测试与答辩验证:四组输入数据验证调度器的正确性

模拟器写完只是第一步,第二步是做验证。课程设计答辩时,老师几乎必然会问“你怎么确认你的模拟结果是对的”。单纯跑通默认数据并不够,你需要准备几组能暴露问题的边界输入。

第一组:全部进程同时到达

输入5个进程,到达时间全为0,服务时间分别为2, 4, 6, 8, 10。这时FCFS和SJF的调度顺序应该完全一致:因为就绪队列是空的,先到达的进程立即运行,SJF没有机会重排。如果这组数据下你的SJF结果与FCFS不同,说明算法里存在bug(很可能是抢占逻辑写错了或者就绪队列初始状态处理错误)。RR结果则应该呈现严格的轮转顺序,每个进程各跑一个时间片后循环。

第二组:进程按服务时间降序到达

到达序列为(进程A: 到达0, 服务10)、(进程B: 到达1, 服务2)、(进程C: 到达2, 服务1)。这组数据专门测试SRTF的抢占行为。正确答案是:A运行到1,B到达后抢占(2<9),B运行到2完成,C到达后抢占(1<7),C运行到3完成,最后A继续运行到时间10。如果SRTF结果中A一口气跑到结束,说明抢占条件没触发。

第三组:CPU空闲窗口

设计一个时间段内没有进程到达的输入。比如进程1到达时间0服务时间3,进程2到达时间5服务时间2。正确的输出中,时间区间[3,5]应显示CPU空闲标记。这组数据会暴露模拟器是否在CPU空闲时浪费循环逐片推进的缺陷——如果你的程序运行时间随总模拟时长线性增长,说明事件驱动没做对。

第四组:时间片恰好等于服务时间

给RR设置时间片为4,某个进程服务时间也是4。进程应在一个时间片内完整结束,并且不能在结束时被重复放入就绪队列。这个边界条件测试的是“remaining time减到0”与“时间片到期”两个事件同时发生时事件处理的优先级。我的实现里先检查完成再检查切片,这样进程不会出现“已经结束了还在就绪队列里等待下一轮”这种荒谬输出。

做完验证,把四组数据的运行结果截图放进报告附录,答辩时直接说“我用了这四组边界输入做测试,分别覆盖了同时到达、抢占边界、CPU空闲窗口和零剩余时间场景”,这个表述本身就是学术严谨性的体现。

我的习惯是每改动一次调度器代码,就把这四组数据重新跑一遍,确保没有引入回归。这类模拟器最怕的状态黑匣子在进程状态切换时悄悄吞掉了一次调度事件,而边界测试往往是暴露它的唯一手段。希望这篇笔记能帮你把这份大作业从“能跑”推进到“能讲”——不只是在报告里写出算法名字,而是能在答辩现场用数据和设计逻辑说服老师。祝你的操作系统课设顺利收场。

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

RELATED READING

延伸阅读

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