1. 项目概述与核心价值
又到了期末,操作系统课的大作业如期而至。这次的任务是“用C语言实现几种经典的进程调度算法”,相信不少同学拿到这个题目时,既兴奋又有点无从下手。兴奋的是,这终于不再是纸上谈兵,可以亲手用代码模拟CPU如何“翻牌子”选进程运行了;无从下手的是,课本上的算法描述看似清晰,但真要用代码把FCFS、SJF、HRRN、RR这些调度器“造”出来,中间隔着数据结构设计、状态机转换、时间片轮转等一系列具体问题。
我当年也在这个作业上花了整整一周,调试到深夜是常事。但做完之后,对进程调度、就绪队列、上下文切换这些核心概念的理解,比背十遍书都来得深刻。这个项目本质上是一个离散事件模拟器,它不涉及真正的硬件中断或多线程并发,而是通过一个虚拟的“系统时钟”和精心设计的数据结构,来模拟CPU调度行为并统计各项性能指标。对于计算机专业的学生而言,这是一个绝佳的、将抽象理论转化为具体代码能力的训练场。无论你是正在为作业发愁的同学,还是想巩固操作系统底层知识的开发者,跟着这篇笔记,我们一起来把这个调度模拟器从零搭建起来。
2. 整体设计与核心思路拆解
在动手写代码之前,我们必须把整个模拟器的运行逻辑想清楚。一个常见的误区是直接对着算法伪代码开始写main函数,这样很容易陷入细节,导致程序结构混乱,难以扩展新的调度算法。
2.1 模拟器的心脏:事件驱动模型
我们的程序世界是一个简化版的计算机系统。这里没有真正的硬件时钟中断,那么如何推动时间前进,并触发“进程到达”、“进程结束”、“时间片用完”这些事件呢?答案是事件驱动。
我们可以维护一个全局时钟变量current_time,它代表了模拟器中的当前时间。同时,我们维护一个未来事件列表。最开始,所有进程的“到达时间”就是这个列表里的事件。模拟器的主循环就是不断地从事件列表中取出下一个即将发生的事件,将current_time快进到该事件的发生时间,然后处理这个事件(比如让一个进程开始运行、结束运行或让出CPU)。处理事件的过程可能会产生新的事件(比如一个进程开始运行后,就预定了一个“结束运行”事件),再将其加入事件列表。如此循环,直到所有事件处理完毕。
这种设计的好处是逻辑清晰,且与真实操作系统内核中“中断驱动”的调度思想有异曲同工之妙。我们不需要用sleep或忙等待,模拟效率极高。
2.2 进程的“身份证”:PCB结构体设计
在操作系统中,每个进程都有一个进程控制块(PCB)来记录它的所有信息。在我们的模拟器里,也需要一个类似的结构体。这是整个程序的数据核心。
typedef struct Process { int pid; // 进程ID int arrival_time; // 到达时间 int burst_time; // 需要的总CPU执行时间(服务时间) int remaining_time; // 剩余执行时间(用于RR和SJF) int start_time; // 首次开始运行的时间 int finish_time; // 完成时间 int waiting_time; // 等待时间 int turnaround_time; // 周转时间 float response_ratio; // 响应比(专用于HRRN) struct Process *next; // 指向下一个进程的指针(用于链表) } Process;关键字段解析:
remaining_time: 这是实现可剥夺调度(如RR)和动态计算(如SJF中判断最短剩余时间)的关键。进程每运行一个单位时间,此值减1,减到0即完成。start_time&finish_time: 用于计算周转时间(finish_time - arrival_time)和等待时间(start_time - arrival_time)。注意,一个进程可能被多次调度(如RR),start_time记录的是它第一次获得CPU的时间。response_ratio: 这是高响应比优先(HRRN)算法的核心。响应比 = (等待时间 + 要求服务时间)/ 要求服务时间。它需要在每次调度点动态计算。
2.3 调度算法的统一接口
为了让程序结构更优雅,便于扩展和维护(想象一下老师突然要求加一个优先级调度),我们应该为所有调度算法定义一个统一的函数接口。
typedef Process* (*Scheduler)(Process* ready_queue); // 这是一个函数指针类型,它指向一个函数,该函数接收当前就绪队列头指针, // 返回一个指针,指向被选中的那个要运行的进程。这样,在模拟器主循环中,我们只需要调用current_scheduler(ready_queue)就能得到下一个该运行的进程,而current_scheduler可以在FCFS、SJF、HRRN、RR之间切换。这是一种简单的策略模式应用。
2.4 性能评估指标:我们关注什么?
模拟的最终目的是为了比较不同算法的优劣。我们主要关注以下几个核心指标:
- 平均周转时间:进程从提交到完成所经历的时间平均值。越小越好,意味着进程完成得快。
- 平均等待时间:进程在就绪队列中等待时间的平均值。越小越好,意味着CPU利用率高,进程响应快。
- 平均响应时间(对于RR等交互式系统):进程从第一次提交到第一次获得CPU的时间平均值。
在代码中,我们会在每个进程结束时(finish_time确定后)立即计算它的周转时间和等待时间,并累加。最后,输出平均值。
3. 核心数据结构与工具函数实现
有了清晰的思路,我们就可以开始搭建基础设施了。这部分代码是所有调度算法共享的基石。
3.1 进程链表管理
我们选择单链表来管理就绪队列和所有进程。因为它简单、直观,且足够满足我们的需求(不需要随机访问,主要是头部插入、尾部插入和遍历)。
// 工具函数:创建一个新进程 Process* create_process(int pid, int arrival, int burst) { Process* p = (Process*)malloc(sizeof(Process)); p->pid = pid; p->arrival_time = arrival; p->burst_time = burst; p->remaining_time = burst; // 初始时剩余时间等于总时间 p->start_time = -1; // -1 表示尚未开始 p->finish_time = -1; p->waiting_time = 0; p->turnaround_time = 0; p->response_ratio = 0.0; p->next = NULL; return p; } // 工具函数:将进程插入就绪队列尾部(用于FCFS等维护顺序的队列) void enqueue(Process** ready_queue, Process* proc) { if (*ready_queue == NULL) { *ready_queue = proc; proc->next = NULL; } else { Process* temp = *ready_queue; while (temp->next != NULL) { temp = temp->next; } temp->next = proc; proc->next = NULL; } } // 工具函数:从就绪队列头部移除一个进程(用于出队调度) Process* dequeue(Process** ready_queue) { if (*ready_queue == NULL) return NULL; Process* proc = *ready_queue; *ready_queue = (*ready_queue)->next; proc->next = NULL; // 隔离出来 return proc; }注意:这里的
enqueue是简单的尾插法。但在SJF或HRRN中,我们可能需要根据某种优先级(剩余时间、响应比)将进程插入到队列的合适位置,而不是简单地插到尾部。届时我们会实现专门的插入函数。
3.2 事件管理与模拟主循环框架
我们简化事件管理,不显式地维护一个事件队列,而是通过“进程列表”和“当前时间”来推导事件。主循环的逻辑如下:
- 初始化:读取或生成所有进程,按到达时间排序,放入一个“未来进程列表”。
- 当还有进程未完成时,循环: a.检查到达事件:将
current_time时刻及之前到达的所有进程,从“未来进程列表”移入“就绪队列”。 b.检查就绪队列是否为空: - 空:说明CPU空闲,将current_time快进到下个进程的到达时间。 - 非空:调用调度函数,选出下一个要运行的进程。 c.处理运行事件:根据调度算法决定此进程运行多久(可能是整个burst_time,也可能是一个时间片time_quantum)。 d.更新系统时间:current_time增加此次运行的时间。 e.更新进程状态:减少该进程的remaining_time。如果减为0,则标记完成,计算其各项指标;否则,根据算法将其重新放回就绪队列或等待下次调度。 - 循环结束,输出所有进程的详细信息和平均指标。
这个框架像是一个总导演,而具体的调度算法是演员,它们只负责回答“现在该谁上场?”这个问题。
4. 四大调度算法的具体实现与难点剖析
现在进入最核心的部分。我们将逐一实现四种算法,并重点讲解其中的易错点和技巧。
4.1 先来先服务(FCFS)
这是最简单的算法,其核心是维护一个先进先出(FIFO)的就绪队列。调度时,永远选择队首的进程。
Process* fcfs_scheduler(Process* ready_queue) { // FCFS就是选择当前就绪队列的第一个进程 return ready_queue; // 直接返回队首指针 } // 在模拟主循环中,调用fcfs_scheduler后,我们会用dequeue将其从队首取出。实现要点与坑点:
- 非抢占:一旦进程开始运行,就会一直运行到完成。在模拟中,这意味着我们选中一个进程后,
current_time直接增加该进程的remaining_time,中间不会被打断。 - ** convoy效应(护航效应)**:这是FCFS的著名缺点。如果队列前面是一个长进程,后面跟着很多短进程,那么短进程的等待时间会变得非常长,导致平均等待时间飙升。你的模拟结果应该能清晰地反映出这一点。
- 计算等待时间:一个进程的等待时间 =
start_time - arrival_time。注意,只有在进程第一次被调度时(start_time == -1)才设置start_time。
4.2 非抢占式短作业优先(SJF)
非抢占式SJF的关键在于,调度只发生在有进程完成时。当CPU空闲或一个进程运行结束时,系统会从当前就绪队列中,选择预估运行时间(burst_time)最短的那个进程来运行。
Process* sjf_scheduler(Process* ready_queue) { if (ready_queue == NULL) return NULL; Process *shortest = ready_queue; Process *current = ready_queue->next; Process *prev_shortest = NULL; Process *prev = ready_queue; // 遍历链表,找到burst_time最小的进程 while (current != NULL) { if (current->burst_time < shortest->burst_time) { shortest = current; prev_shortest = prev; } prev = current; current = current->next; } // 将选中的进程从链表中移除 if (prev_shortest == NULL) { // 最短进程是队首 ready_queue = shortest->next; } else { prev_shortest->next = shortest->next; } shortest->next = NULL; return shortest; }实现要点与坑点:
- “非抢占”的含义:在模拟中,即使一个更短的进程在某个长进程运行期间到达,长进程也不会被中断。我们必须等到当前进程自然结束后,才会重新调用调度器,此时新到达的短进程才会被考虑。这是与抢占式SJF(最短剩余时间优先)的根本区别。
- 如何选择“短作业”:我们需要遍历整个就绪队列来寻找
burst_time最小的进程。这里使用链表,时间复杂度是O(n)。对于作业数不多的大作业来说完全足够。 - 饥饿问题:理论上,如果一直有短作业到达,长作业可能永远得不到执行。但在非抢占式且所有作业同时到达的假设下,这个问题不明显。你的报告里可以讨论这一点。
4.3 高响应比优先(HRRN)
HRRN是对SJF的一种改进,旨在缓解长作业的饥饿问题。响应比R = (W + S) / S,其中W是等待时间,S是要求服务时间。每次调度时,选择响应比最高的进程。因为等待时间W在分子上,一个作业等得越久,其响应比越高,被选中的机会就越大。
float calculate_response_ratio(Process* proc, int current_time) { int waiting_time = current_time - proc->arrival_time; return (waiting_time + proc->burst_time) / (float)(proc->burst_time); } Process* hrrn_scheduler(Process* ready_queue, int current_time) { if (ready_queue == NULL) return NULL; Process *highest = ready_queue; Process *current = ready_queue->next; Process *prev_highest = NULL; Process *prev = ready_queue; // 先计算第一个进程的响应比 highest->response_ratio = calculate_response_ratio(highest, current_time); float max_ratio = highest->response_ratio; // 遍历,计算并比较响应比 while (current != NULL) { current->response_ratio = calculate_response_ratio(current, current_time); if (current->response_ratio > max_ratio) { highest = current; prev_highest = prev; max_ratio = current->response_ratio; } prev = current; current = current->next; } // 将选中的进程从链表中移除(代码类似SJF,略) // ... (移除highest进程的链表操作) return highest; }实现要点与坑点:
- 动态计算:响应比必须在每次调度点重新计算,因为每个进程的等待时间
W随着current_time的推移在不断增长。 - 数据类型:
(W+S)/S的结果可能是小数,所以response_ratio字段和计算函数返回值要用float。比较时使用>。 - 何时调度:和SJF一样,HRRN通常也是非抢占的,调度发生在进程完成或CPU空闲时。
4.4 时间片轮转(RR)
RR算法给每个进程分配一个固定的CPU时间片。进程运行一个时间片后,如果还没结束,就会被剥夺CPU,重新排到就绪队列的末尾,然后调度队首的下一个进程。
Process* rr_scheduler(Process** ready_queue, int time_quantum, int* remaining_quantum) { // remaining_quantum 是一个指针,用于跟踪当前运行进程在本轮中剩余的时间片 // 如果它为0或负数,说明需要调度一个新进程 if (*remaining_quantum <= 0) { // 需要调度新进程 if (*ready_queue == NULL) return NULL; // 就绪队列空 Process* scheduled = dequeue(ready_queue); // 从队首取出 *remaining_quantum = time_quantum; // 重置时间片 return scheduled; } else { // 继续运行当前进程(在模拟主循环中,我们通常不会在这里返回新进程) // 这个分支更多是用于状态维护。实际调度决策在主循环中通过判断remaining_quantum来做。 return NULL; // 表示继续运行当前进程 } } // 在模拟主循环中,RR的逻辑与其他算法有显著不同: // 1. 每次只增加 current_time 一个最小单位(如1)或一个时间片。 // 2. 当前运行进程的 remaining_time 减1,同时 remaining_quantum 也减1。 // 3. 检查 remaining_time 是否为0(进程结束),或者 remaining_quantum 是否为0(时间片用完)。 // 4. 如果时间片用完但进程未结束,则将此进程 enqueue 回就绪队列尾部。 // 5. 然后,调用 rr_scheduler 获取下一个进程(此时 remaining_quantum 为0,会触发重新调度)。实现要点与坑点:
- 时间片大小的选择:这是RR算法的灵魂。时间片太大,退化成FCFS;时间太小,进程切换开销(上下文切换)占比过高,系统吞吐量下降。在你的模拟程序中,时间片应作为一个可配置的常量(如
#define TIME_QUANTUM 4)。 - 就绪队列的管理:RR的就绪队列必须是标准的FIFO队列。新到达的进程插入队尾,被时间片剥夺的进程也插入队尾。
- “当前运行进程”的状态:你需要一个变量(如
Process* running_proc)来记录当前正在占用CPU的进程。还需要一个变量(如int remaining_quantum)来记录该进程在当前轮次中剩余的时间片。 - 性能指标的计算:对于RR,
start_time仍然是进程第一次获得CPU的时间。waiting_time的计算需要小心:每次进程在就绪队列中等待,其等待时间都在增加。一种简单的实现方式是,在每个时间单位,为就绪队列中的所有进程的waiting_time加1。
5. 模拟主循环的整合与代码框架
将以上所有部分整合起来,是项目成功的关键。下面给出一个高度简化的主循环伪代码框架,以RR为例,展示如何将事件处理、调度、状态更新串联起来。
int main() { // 初始化:读取进程数据,按到达时间排序存入 all_processes 列表 Process* all_processes = load_processes(); Process* ready_queue = NULL; Process* running_proc = NULL; int current_time = 0; int time_quantum = 4; int remaining_quantum = 0; // 当前运行进程剩余时间片 int completed = 0; int total_processes = count_of(all_processes); // 主循环 while (completed < total_processes) { // 步骤1:处理到达事件 while (all_processes != NULL && all_processes->arrival_time <= current_time) { Process* arrived = dequeue(&all_processes); // 从未来队列取出 enqueue(&ready_queue, arrived); // 加入就绪队列 printf("Time %d: Process P%d arrived.\n", current_time, arrived->pid); } // 步骤2:检查是否需要调度(CPU空闲或时间片用完) if (running_proc == NULL || remaining_quantum == 0) { // 如果当前有进程在运行但时间片用完,且未结束,则放回就绪队列 if (running_proc != NULL && running_proc->remaining_time > 0) { enqueue(&ready_queue, running_proc); printf("Time %d: Process P%d time slice expired, re-queued.\n", current_time, running_proc->pid); } // 调用调度器获取下一个进程 running_proc = rr_scheduler(&ready_queue, time_quantum, &remaining_quantum); if (running_proc != NULL) { if (running_proc->start_time == -1) { running_proc->start_time = current_time; // 记录首次开始时间 } printf("Time %d: Process P%d starts running.\n", current_time, running_proc->pid); } else { // 就绪队列为空,且没有进程在运行,CPU空闲 // 可以快进时间到下一个进程到达时间 if (all_processes != NULL) { current_time = all_processes->arrival_time; continue; // 跳回循环开始处理到达事件 } } } // 步骤3:如果没有进程运行,则时间无法推进(理论上上面已处理快进) if (running_proc == NULL) { // 这种情况应该不会发生,除非所有进程都已完成 break; } // 步骤4:模拟运行一个单位时间 current_time++; running_proc->remaining_time--; remaining_quantum--; // 步骤5:更新就绪队列中所有进程的等待时间(每个时间单位加1) Process* p = ready_queue; while (p != NULL) { p->waiting_time++; p = p->next; } // 步骤6:检查当前运行进程是否结束 if (running_proc->remaining_time == 0) { running_proc->finish_time = current_time; running_proc->turnaround_time = running_proc->finish_time - running_proc->arrival_time; // waiting_time 已经在步骤5中累计了 printf("Time %d: Process P%d finished. TT=%d, WT=%d\n", current_time, running_proc->pid, running_proc->turnaround_time, running_proc->waiting_time); completed++; running_proc = NULL; // CPU变空闲 remaining_quantum = 0; // 重置时间片 } } // 输出统计结果 print_statistics(all_processes); return 0; }注意:这是一个概念性框架,省略了内存释放、错误处理等细节。对于FCFS、SJF、HRRN这些非抢占算法,循环逻辑会更简单:一旦开始运行一个进程,就直接将
current_time推进到该进程结束,中间不检查到达事件(因为非抢占)。
6. 输入输出设计与测试用例
一个友好的程序需要有清晰的输入输出。输入可以来自文件,也可以直接在代码中初始化。
6.1 输入格式设计
建议使用简单的文本文件格式,例如processes.txt:
进程ID 到达时间 服务时间 1 0 5 2 2 3 3 4 2 4 6 4每行代表一个进程。在main函数开始时读取这个文件,并创建进程链表。
6.2 输出信息
模拟过程中可以输出时间线,便于调试:
Time 0: P1 arrived. Time 0: P1 starts running. (FCFS Selected) Time 5: P1 finished. TT=5, WT=0 Time 5: P2 starts running. ...最终输出一个汇总表格和平均指标:
调度算法: FCFS 进程ID | 到达时间 | 服务时间 | 开始时间 | 完成时间 | 周转时间 | 等待时间 -------------------------------------------------------------------- P1 | 0 | 5 | 0 | 5 | 5 | 0 P2 | 2 | 3 | 5 | 8 | 6 | 3 ... 平均周转时间: 7.25 平均等待时间: 4.506.3 关键测试用例
设计几组有代表性的测试数据,能凸显不同算法的特点:
- 默认用例:进程交错到达,服务时间长短不一。用于基本功能验证。
- 护航效应用例:一个超长进程(如服务时间100)在0时刻到达,紧接着在1时刻到达多个短进程(服务时间1)。观察FCFS下短进程极长的等待时间,以及SJF/HRRN的改善。
- SJF饥饿用例(理论测试):连续有短作业到达,观察长作业是否会被无限期推迟(在非抢占SJF中,如果长作业很晚才开始,可能不会)。
- RR时间片测试:使用同一组进程,分别用很小(如1)和很大(如大于所有进程服务时间)的时间片测试,观察平均周转时间和等待时间的变化趋势。
7. 常见问题与调试心得
这是我当年调试时踩过的坑和总结的技巧,希望能帮你节省时间。
7.1 指针操作与内存管理
链表操作是出错重灾区。
- 野指针和内存泄漏:每次
malloc创建进程,在程序结束前一定要free。链表删除节点时,注意正确更新next指针,避免访问已释放的内存。 - 链表头指针的传递:像
enqueue,dequeue,sjf_scheduler这些函数需要修改链表头,记得传递Process**(指针的指针),否则修改可能无法生效。 - 调试技巧:写一个
print_queue(Process* head)函数,随时打印就绪队列里的进程ID和剩余时间,这是最直观的调试手段。
7.2 时间推进逻辑错误
这是模拟逻辑的核心,容易混乱。
- FCFS/SJF/HRRN的非抢占:在进程运行期间,
current_time是直接跳到完成时刻的。在这段“跳跃”的时间里,可能有新进程到达。你必须在“跳跃”之前,就处理好所有在这段时间内到达的进程,将它们加入就绪队列。我的做法是:在决定运行进程P(从时间T1到T2)之前,先扫描未来进程列表,把所有到达时间在(T1, T2]这个区间内的进程,都提前加入到就绪队列中。 - RR的时间片与时间单位:在RR模拟中,
current_time每次递增1(一个最小时间单位)。这更贴近真实系统的“时钟滴答”。要确保时间片time_quantum、剩余时间remaining_time和current_time的推进是同步的。
7.3 状态变量初始化与更新
start_time初始化为-1,在进程第一次被调度时设置为current_time。判断条件是if (proc->start_time == -1)。waiting_time的更新时机:- 对于FCFS/SJF/HRRN:可以在进程结束时计算,
waiting_time = start_time - arrival_time。前提是start_time记录准确。 - 对于RR:必须在每个时间单位手动为就绪队列中的所有进程增加等待时间。因为一个进程可能多次进出就绪队列。
- 对于FCFS/SJF/HRRN:可以在进程结束时计算,
remaining_time:在进程被创建时等于burst_time,每运行一个单位时间减1。
7.4 算法切换与代码组织
为了让程序能方便地切换算法,建议使用函数指针数组。
typedef Process* (*SchedulerFunc)(Process**, int, int*); // 适配RR // 或者 typedef Process* (*SchedulerFunc)(Process*); // 适配FCFS/SJF SchedulerFunc schedulers[] = {fcfs_scheduler, sjf_scheduler, hrrn_scheduler, rr_scheduler}; char* scheduler_names[] = {"FCFS", "Non-preemptive SJF", "HRRN", "Round Robin"};在main函数里,可以通过一个循环,依次用不同的调度器运行同一组测试数据,并输出对比结果,这样写实验报告时数据获取非常方便。
最后,这个项目的魅力在于,当你看到自己编写的程序输出不同算法下差异显著的性能指标时,课本上那些枯燥的定义瞬间就变得生动起来。调试过程虽然痛苦,但每一次解决bug,你对进程、队列、状态和时间的理解就会加深一层。不妨在实现基本功能后,尝试增加一些扩展,比如实现抢占式的SJF(最短剩余时间优先),或者给每个进程增加一个优先级,实现多级反馈队列(MLFQ),这会让你的大作业在众多项目中脱颖而出。