C++实现进程调度模拟器:时间片轮转与短作业优先全解析

发布时间:2026/9/29 22:57:38
C++实现进程调度模拟器:时间片轮转与短作业优先全解析 做操作系统课程设计的时候进程调度模拟器几乎是绕不开的一道坎。时间片轮转RR和短作业优先SJF这两个调度算法理论课上听老师讲觉得挺简单真到动手用C写一个能跑起来的模拟系统才发现里面藏着不少坑。这篇博文就按我实际完成这个项目的思路来写——从算法原理、数据结构和C实现细节到测试数据设计、结果分析和报告整理完整过一遍给正在做类似作业的同学一个可以直接参考的路线。我当时拿到的题目要求是模拟单CPU环境下的进程调度至少实现时间片轮转和SJF两种调度算法支持自定义进程的到达时间和服务时间输出调度顺序甘特图、进程完成时间、周转时间、等待时间等关键指标最后提交设计源文件加一份详细的课程报告并录制讲解。听起来不复杂但真正动手以后发现要做到调度过程严谨、统计结果准确、两种算法对比有说服力需要处理很多边界情况。下面就是我完成这个项目的完整复盘按设计思路 → 数据结构 → 编码实现 → 测试分析 → 排错经验 → 报告整理的顺序展开。1. 整体设计思路与调度策略选择1.1 两种调度算法到底在解决什么问题先把两个算法的本质说清楚。**时间片轮转Round RobinRR**属于抢占式调度核心思想是公平每个就绪进程轮流获得一个固定长度的时间片时间片用完后即使进程还没执行完也必须让出CPU回到就绪队列尾部重新排队。它的优点是响应时间短、不会出现进程饿死交互式系统里大量使用这种策略。缺点是频繁的上下文切换会带来额外开销而且从平均周转时间来看并不占优。**短作业优先Shortest Job FirstSJF**则是非抢占式调度核心思想是让短作业先跑。每次CPU空闲时从就绪队列里挑选服务时间最短的进程执行直到该进程执行完毕才切换。理论早已证明在所有非抢占式调度算法中SJF能给出最小的平均等待时间。但它的隐患也很明显——长作业可能长期得不到执行出现饥饿现象。这两种算法放在同一个项目里做对比本身就是课程设计里很经典的组合一个代表公平优先一个代表效率优先。用同一组测试数据分别跑两种算法从平均周转时间和平均等待时间两个指标上做量化对比就能很直观地看到不同调度策略对系统性能的影响。1.2 为什么把两种算法放进同一个系统有人可能会问直接分开写两个独立的小程序不就行了为什么非要放在一个系统里我的设计是做一个统一的调度模拟框架内部维护一份就绪队列通过一个调度模式参数枚举类型或布尔标志在RR和SJF之间切换。这样做的理由有三个第一代码复用度高。进程创建、状态管理、统计输出、时间推进这些逻辑跟具体算法无关写一遍就行。真正需要切换的只有从就绪队列中选下一个进程这一小段逻辑用一个switch分支就能搞定。第二对比实验更有说服力。课程报告里需要做性能对比如果两个算法用两套不同的代码和数据结构读者很难相信数据是公平的。放在同一个框架里只有选进程的策略不同其他条件完全一致这样得出的对比结论才是站得住脚的。第三便于扩展。如果后续想加优先级调度、多级反馈队列只需要新增一个调度模式方法主循环不用大幅改动。老师如果追问能不能再扩展一个算法这个架构能直接接住。1.3 模拟系统的总体运行流程整个模拟器的工作流程我设计成这样读取进程参数到达时间、服务时间、数量、时间片大小 → 初始化就绪队列 → 推进系统时钟 → 按当前调度算法选择进程执行 → 更新进程状态和统计变量 → 重复直到所有进程完成 → 输出甘特图和统计报表。这里有个关键的思路模拟系统里要自己维护一个递增的系统时钟变量。每执行一个时间单位就检查是否有新进程到达是新进程就把它加入就绪队列。时钟推进的粒度决定了模拟精度我的实现里以时间片为最小推进单位每轮调度结算一次这样逻辑更清晰输出甘特图也方便。2. 核心数据结构与系统架构设计2.1 进程控制块PCB的设计模拟系统的核心是对现实操作系统进程的建模。真实系统里进程控制块PCB包含的信息很多这里只需要保留调度实验关心的字段。我用C定义了一个结构体大概长这样struct PCB { int pid; // 进程编号从1开始 int arriveTime; // 到达时间单位ms模拟 int serveTime; // 服务时间总CPU突发时间 int remainTime; // 剩余服务时间调度过程中实时更新 int finishTime; // 完成时间进程结束时记录 int startTime; // 首次开始执行的时间用于计算响应时间 bool isStarted; // 是否已经至少执行过一次用于计算响应时间 };字段设计有几个细节值得注意remainTime是调度过程中的核心变量。在RR模式下每次执行后要减去消耗的时间片在SJF模式下由于进程是连续执行完的它更多用于理论校验。isStarted和startTime主要用来计算响应时间首次开始执行时间减去到达时间。这个指标能体现RR在交互式场景下的优势报告里可以补充说明。finishTime在进程执行完毕的那个时刻写入之后才能计算周转时间。我还额外加了一个PState 枚举类型表示进程当前状态未到达NOT_ARRIVED、就绪READY、运行RUNNING、完成FINISHED。状态转换是模拟器正确性的关键我在后面编码部分会重点说。2.2 就绪队列与时间推进模型就绪队列选用C标准库的dequePCB*双端队列来模拟。为什么不用vector或者queue因为RR模式下需要频繁在队首取元素、在队尾插入元素deque两端操作都是常数时间复杂度而且支持随机访问SJF模式下需要对队列按剩余时间排序随机访问能力正好用得上。queue容器适配器不支持遍历排序直接排除。时间推进模型上我采用了事件驱动 时间片步进的混合思路。外层while循环检查所有进程是否完成内层每次调度可以做三件事把到达时间小于等于当前时刻的新进程加入就绪队列按调度算法从就绪队列选出下一个执行的进程推进时钟并更新状态。2.3 统计模块的组织方式调度模拟器光能跑还不够关键是要输出准确的统计数据。我单独设计了一个SchedulerStats结构体来累计总周转时间所有进程周转时间之和总等待时间所有进程等待时间之和调度切换次数便于分析上下文切换开销每个进程的完成时刻明细用于输出表格这些统计量在进程完成的时刻实时更新最后统一计算平均值。这里有一个我踩过的坑等待时间不能简单等同为服务时间减执行时间必须用完成时间 - 到达时间 - 服务时间来计算或者用累计等待累计器在每个时间片结算时给非运行进程统一累加。第一种方式更直接我最终采用这种方式。我会计算 [...] 以便在报告里对两种算法的性能进行量化分析。实际上模拟结束后手动验算一遍统计结果是排查统计模块bug最有效的手段。3. C实现关键环节全解析3.1 调度主循环的框架设计主循环是整个模拟器的发动机我把它设计成下面这个结构void runScheduler(RRMode mode, int timeSlice) { int currentTime 0; int finishedCount 0; int n processList.size(); while (finishedCount n) { // 1. 检查是否有新进程到达 for (auto p : processList) { if (p.arriveTime currentTime) { readyQueue.push_back(p); } } // 2. 就绪队列为空时直接推进时钟 if (readyQueue.empty()) { currentTime; continue; } // 3. 按调度算法选择下一个执行进程 PCB* current nullptr; if (mode RR) { current readyQueue.front(); readyQueue.pop_front(); } else if (mode SJF) { current selectShortestJob(); } // 4. 执行与时间推进 int runTime 0; if (mode RR) { runTime min(timeSlice, current-remainTime); current-remainTime - runTime; if (!current-isStarted) { current-startTime currentTime; current-isStarted true; } currentTime runTime; outputGanttSegment(current-pid, currentTime - runTime, currentTime); if (current-remainTime 0) { readyQueue.push_back(current); // 未完成排到队尾 } else { finishProcess(current, currentTime); finishedCount; } } else { runToCompletion(current, currentTime); // SJF 连续执行完 outputGanttSegment(current-pid, ..., ...); finishProcess(current, currentTime); finishedCount; currentTime current-finishTime; current-remainTime 0; } } }这段逻辑里容易忽略的是就绪队列为空的处理。比如第一个进程还没到达或者所有到达的进程都已经执行完、但下一批进程还没到来中间会出现CPU空闲。处理方式是直接把当前时间推进到下一个进程的到达时刻而不是傻傻地让时钟走一步发现队列还是空的再走一步。我在代码里用了一个优化提前扫描进程列表找到下一个最小到达时间直接把currentTime跳过去。这样模拟过程更高效输出甘特图也干净不会出现一个空输出的时间片。另一个容易错的地方是RR模式下的初始响应时间标记。一个进程可能第一次被调度时只执行了一个很短的时间片随后被排到队尾之后又经历多轮才再次轮到。所以首次开始执行时间必须在它第一次真正获得CPU时立即记录不能等进程完成后再回溯。3.2 时间片轮转的切换逻辑RR的实现难点不在选进程而在时间片的精确结算。我用一个runTime变量表示本次实际执行时长取值是时间片大小和剩余服务时间中的较小值如果进程剩余时间不足一个时间片就让它执行完直接结束否则执行满一个时间片后压回队尾。这里有一个重要的边界问题需要想清楚如果进程到达的瞬间就绪队列为空它需要立即运行吗答案是肯定的。比如P1在t0到达当前时间也是0那就应该让P1在0时刻直接开始执行而不是先把时间推进到第一个时间片结束。也就是说主循环里的检查新进程到达必须在选择执行进程之前完成并且要用arriveTime currentTime而不是 currentTime来判断否则就会漏掉跨时间片到达的进程。我在实际编码时还加了一个记录调度切换次数的计数器。每次进程切换、每次时间片用尽重新排队都自增。这个数据在报告里有大用处能说明RR算法虽然响应快但上下文切换开销明显高于SJF。这也呼应了理论课上讲的时间片过小会增大切换开销这一结论。3.3 SJF的选优逻辑与边界处理SJF的选优逻辑相对简单遍历就绪队列选出remainTime最小的进程。注意由于SJF是非抢占式的一旦选中就让它一直运行到结束中途不需要检查是否有更短的新进程到达——这正是它区别于SRTF最短剩余时间优先抢占式版本的地方。很多同学在这里搞混把SJF实现成了抢占式虽然也能跑但和题意的短作业优先就不一致了。我的selectShortestJob()实现如下PCB* selectShortestJob() { auto minIt readyQueue.begin(); for (auto it readyQueue.begin(); it ! readyQueue.end(); it) { if ((*it)-remainTime (*minIt)-remainTime) { minIt it; } } PCB* selected *minIt; readyQueue.erase(minIt); return selected; }SJF还有一个容易踩的坑当有多个进程剩余服务时间相同时怎么选我的做法是选pid最小的也就是后到先比较。这一点看起来不起眼但会直接影响甘特图的输出结果甚至影响后续的手动验算。报告里也应该说明这个选择规则体现设计的一致性。还有一个边界条件如果系统是非抢占式SJF当一个进程正在执行时新到达了一个更短的进程当前进程不会被中断。所以在主循环里SJF分支不需要在每次时间步进时重新选择进程只要当前进程没执行完就一直跑直到它完成或内部时间片推进逻辑自然结束。我在这里加了一个if (current ! nullptr current-remainTime 0)的循环保护避免在SJF分支里重复选进程。3.4 进程状态转换机的实现状态转换是这类模拟器里最容易被忽视、却又最容易出bug的地方。我的状态转换规则很简单未到达 → 就绪当currentTime arriveTime时把进程插入就绪队列。就绪 → 运行调度器选中进程后切换状态为运行中。运行 → 就绪RR模式下时间片用完且remainTime 0进程回到队列尾部。运行 → 完成remainTime 0记录完成时间统计指标。我在实现状态转换时专门定义了一个updateState(PCB*, PState)函数内部用switch检查合法转换非法转换直接打印错误。比如进程还没到达就从就绪队列里被选中了说明到达判断有bug运行中的进程状态被改成未到达也说明时间推进逻辑有问题。这种防御式编程在调试阶段帮我省了不少时间。3.5 甘特图输出模块甘特图是课程报告里最有说服力的可视化内容也是老师打分时的加分项。我用字符串拼接的方式实现了一个简单的控制台甘特图每执行完一段调度记录一个片段格式如下P1 |############| 0 ~ 4 P2 |#### | 4 ~ 8 P3 |#### | 8 ~ 12 P4 |#### | 12 ~ 16 P1 |#### | 16 ~ 20 P3 |#### | 20 ~ 24 P4 |# | 24 ~ 25 P3 |# | 25 ~ 26实现方式是在主循环的每次调度段结束后调用outputGanttSegment(pid, startTime, endTime)它负责把时间段追加到一个vectorstring里最后统一输出。这样即使进程数量多控制台输出也不会乱。如果你想输出更美观的甘特图可以按进程为行、时间为列用二维数组填充但控制台字符宽度有限进程多了容易换行错位反而不如这种分段式输出直观。4. 测试用例设计与调度结果对比分析4.1 测试数据怎么设计才有说服力课程设计报告里的测试数据不是随便填几个数字就完事要能体现出两种算法的性能差异。我建议准备三组数据第一组全部进程同一时刻到达。这样SJF直接按服务时间排序RR按到达顺序轮转对比最干净。第二组进程错峰到达。例如P1在0时刻到达服务时间8P2在1时刻到达服务时间4P3在2时刻到达服务时间9P4在3时刻到达服务时间5。这种错峰场景最能暴露两种算法的本质区别我在报告里用的就是这组数据。第三组极端场景。比如一个超长进程和多个短进程混合用来验证SJF可能让长作业等待时间变长的饥饿问题这是报告里一个很好的讨论点。下面我以第二组数据为例完整展示对比分析过程。这组数据手动算一遍能帮你验证程序的正确性P1到达时间0服务时间8P2到达时间1服务时间4P3到达时间2服务时间9P4到达时间3服务时间5时间片大小设为4。4.2 从输出结果看两种算法的性能差异RR时间片4的调度过程0~4P1执行剩余44~8P2执行剩余0完成8~12P3执行剩余512~16P4执行剩余116~20P1继续执行剩余0完成20~24P3执行剩余124~25P4执行剩余0完成25~26P3执行剩余0完成完成时间P120P28P326P425。平均周转时间 (2082625)/4 19.75。平均等待时间 (1231517)/4 11.75。SJF非抢占式的调度过程0~8P1执行唯一到达8~12P2执行剩余时间最短412~17P4执行此时剩余时间5比P3的9短17~26P3执行最后执行完成时间P18P212P417P326。平均周转时间 (8121726)/4 15.75。平均等待时间 (07915)/4 7.75。结果非常直观这组测试数据下SJF的平均周转时间比RR少了4个单位平均等待时间少了整整4个单位。原因是SJF优先执行P2和P4两个短作业而RR为了维持公平让P1和P3这两个长作业交叉执行整体拖慢了短作业的完成。这就是SJF能最小化平均等待时间这一理论结论在实际数据中的体现。但你也会发现一个有趣的现象P1在RR下的完成时间是20在SJF下是8。长作业在SJF下反而更早完成因为它在SJF里是第一个被执行的这组数据没有体现出饥饿现象。所以我在报告里额外补充了第三组极端测试P1服务时间100P2服务时间2P3服务时间3三者同时到达。此时SJF下P1要等P2和P3全部完成才能开始执行完成时间被拖到105而在RR下P1最多等两个时间片就能开始执行。这就是饥饿问题的量化体现老师看到这个细节就知道你真的理解了这个算法。4.3 时间片大小对RR性能的影响这是报告里另一个有价值的扩展分析。保持测试数据不变把时间片从4改到2、从4改到8重新跑一遍程序会发现时间片越小上下文切换次数越多平均周转时间通常越长但不绝对。时间片越大RR越接近FCFS先来先服务公平性下降但切换开销降低。我实际测试的结果是时间片2时P1和P3的执行被切成了更多段平均周转时间从19.75上升到23.5左右时间片8时实际效果和SJF在这个数据上接近但等待时间和周转时间依然不是最优。这个对比可以放在报告的参数影响分析小节里用一张表格呈现不同时间片下的平均周转时间和平均等待时间说服力拉满。5. 调试过程中的常见坑与排错实录5.1 时间片切换时丢进程的经典bug我在调试RR时遇到过一个非常隐蔽的问题某个进程在被压回队尾后再也没被调度到导致整个模拟器死循环。排查了很久最后发现原因是——进程执行完一个完整时间片后我忘记把它重新压入就绪队列。// 错误写法 if (current-remainTime 0) { // 忘了 push_back(current) 这一行 // 进程直接丢失回不去了 }这种bug在单核模拟器里不会立刻报错只会让就绪队列慢慢变空最后进程数量对不上。排查方法是打印每秒的就绪队列内容如果发现某个进程从队列里消失且remainTime 0基本就是这个原因。我建议大家写代码时把未完成进程重新入队和完成进程释放放在同一个分支的两侧逻辑上形成互斥一眼就能检查到位。5.2 统计指标对不上账的问题输出报告的周转时间和手算结果不一致这也是常见问题。大部分原因出在时间推进精度上。比如SJF分支里我用currentTime runTime推进时钟但如果一个进程执行了5个单位时间我却把这5个单位平均分配到了多个模拟步里那么中间就可能插入其他到达的进程导致顺序错乱。解决思路是SJF分支里一旦选中进程就直接推进到它的完成时刻期间不检查新到达进程而RR分支严格按时间片粒度推进。这两种推进策略的粒度不同但都要保证在推进之后把当前时间内所有到达的进程都加入就绪队列。我在每次推进时钟后统一调用一次addArrivingProcess(currentTime)函数保证不漏不重。还有一个统计口径的问题进程完成时间是它结束的那一刻而不是它被调度器检查到的那一刻。比如P1在16时刻就已经运行完了但因为主循环是16时刻之后才检查完成状态很容易把finishTime记成20。解决方法是进程执行结束时立即记录完成时间而不是等到主循环的下一轮再统一结算。这个立即结算的原则贯穿了我整个统计模块。5.3 新增算法扩展时对既有逻辑的影响老师可能会要求扩展优先级调度我实际操作中有个体会给系统加新算法时最安全的方式是新写一个调度分支方法而不是改原有的RR和SJF逻辑。因为原有逻辑已经在调试中稳定下来动它容易引入新的回归bug。比如我后来加了一个简单的优先级调度只需要新增一个selectByPriority()方法把所有进程按优先级排序返回最高者完全不影响已有代码。从架构角度看这其实就体现了一个好的调度模拟器设计原则调度策略与调度框架解耦。算法是插拔式的框架是稳定的这样的代码无论是调试还是写报告思路都会清晰很多。5.4 报告与讲解环节的经验提醒课程设计除了代码还要交报告和做讲解这两样同样影响最终分数。我的报告是按需求分析 → 概要设计 → 详细设计 → 系统实现 → 测试与分析 → 总结的经典结构写的其中测试与分析部分占了最大篇幅。为了说清楚调用的过程我还截了几张程序运行的控制台输出图截图前先把终端窗口宽度调大保证甘特图每一段都在一行内显示。甘特图在页面上按比例缩放老师看得清楚讲解时也能指着图说流程。讲解环节我被老师问到过为什么SJF在平均等待时间上优于RR但实际操作系统里却很少用纯SJF这个问题。我的回答思路是SJF需要预知进程的服务时间这在真实系统里很难准确获得而且它会导致长作业饥饿交互式场景下用户体验差。所以实际系统更多用多级反馈队列这类折中的算法。这个回答能让老师知道你不仅会写代码还能把理论结合实际。6. 项目源码结构与配套资料的组织方式6.1 源代码模块划分一个规范的课程设计项目源码结构应该清晰。我项目的目录结构是这样的SchedulerSim/ ├── main.cpp // 程序入口参数读取与整体流程控制 ├── scheduler.h // 调度器类声明 ├── scheduler.cpp // 调度器实现 ├── pcb.h // 进程控制块定义 ├── algorithm/ │ ├── rr.cpp // 时间片轮转实现 │ └── sjf.cpp // 短作业优先实现 └── utils/ ├── gantt.cpp // 甘特图输出 └── stats.cpp // 统计指标计算与输出这样模块划分的好处是每个文件职责单一调试时能快速定位问题。如果你完全把所有逻辑塞在main.cpp里虽然也能跑但到写报告阶段你会发现很难截图展示优秀的模块化设计。而模块化设计通常是课程报告里要求明确写出的内容。6.2 参数输入与UI交互设计为了让测试灵活我的程序支持两种输入方式交互式录入和文件批处理。交互式录入适合演示每输入一个进程就询问是否继续文件批处理适合反复调试数据格式一行一个进程0 8 1 4 2 9 3 5 4前4行是到达时间 服务时间最后一行是时间片大小。程序启动时读取文件没有文件就进入交互模式。每个算法跑完输出一次统计数据最后统一打印两张对比表周转时间对比表平均等待时间对比表。6.3 讲解视频里我最想讲清楚的两个点配套的讲解视频或现场讲解我最想讲清楚的是两个问题一是进程状态机是怎么转的二是两种算法的甘特图为什么长这样。前者体现你对系统建模的理解后者体现你对算法的理解。我录讲解时会把程序跑一遍一边跑一边指着甘特图说当前队列里有哪些进程、为什么不选它、为什么让它提前结束。这样即使没有华丽的PPT老师也能直观感受到你确实亲手实现了整个系统。我个人实际做完这个项目最大的体会是课程设计最有价值的不是最终那份能跑的代码而是调试过程中逼着自己把每个细节想清楚的过程。比如RR在时间片用完和进程执行完这两种情况下进程的去向完全不同SJF在多个短作业并存时的选择规则需要一致。只有亲手踩过这些坑才能真正建立起对操作系统核心机制的直觉。以后面试遇到讲讲进程调度这类问题你脑子里浮现的是自己写过的甘特图和那几行调试到半夜的代码而不是课本上的干巴巴定义。这份项目值得认真做一遍。