ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

任务调度器课程设计:基于C语言实现FCFS、SJF与RR调度算法

任务调度器课程设计:基于C语言实现FCFS、SJF与RR调度算法

简介:面向大二学生数据结构课程设计,基于C/C++实现的任务调度器系统,覆盖任务队列、调度模块与交互主程序,适合需要完成同类选题、理解队列结构与任务管理流程的学生参考。压缩包共38个文件,大小约40.97MB,包含VS2019解决方案、C++源码、任务队列与调度管理相关头文件、测试主程序、可直接运行的exe,以及pdb、obj、tlog等编译调试产生的中间文件;工程目录清晰,解压后用Visual Studio 2019打开即可复现运行效果。已有207名学生学习下载,说明该选题在课程设计场景中具备一定代表性。通过该项目可以完整走一遍任务调度器的设计实现过程,掌握任务队列构建、任务状态维护、调度策略设计等核心思路;也能借鉴其模块划分、工程组织方式和类图设计,便于快速改写成自己的课程设计代码、报告与答辩材料。

1. 任务调度器这件课设,为什么值得认真做

很多人拿到"任务调度器"这个题目的第一反应,是去翻操作系统里的进程调度算法,结果发现课设要求的是用数据结构去模拟调度过程,而不是真的实现一个操作系统调度内核。先来先服务要排队,短作业优先要快速找出最小服务时间,时间片轮转要环形取任务,这背后对应的是队列、优先队列和循环队列的设计与编码能力。正因为如此,它才成为数据结构课程设计里最常被选中的题目之一——题目不大,但能同时考到结构体、指针、堆、队列和排序稳定性。这份方案面向正在准备数据结构课程设计的学生,也面向想快速理清任务调度器完整实现思路的开发者,给出的是从需求分析、数据结构选型、模块划分到C语言核心实现的一整套可落地参考。适合拿来做C/C++课设的蓝本,也适合在答辩时应对"你为什么要这样设计"的追问。

2. 需求分析与技术选型:先确定调度规则,再决定用堆还是队列

2.1 课设任务书里最容易被忽略的四个需求点

先别急着写代码,我会把任务书拆成四个必答项。第一,必须支持的调度算法。常见组合是FCFS(先来先服务)、SJF(短作业优先)、RR(时间片轮转)三种,部分题目还会要求"动态优先级调度"。第二,任务输入方式。多数要求从文件或控制台读入任务列表,每条任务包含任务ID、到达时间和服务时间。第三,结果输出。通常要求打印调度顺序、每个任务的开始时间、完成时间、等待时间、周转时间,以及平均周转时间。第四,边界行为。比如两个任务同时到达、服务时间相同、任务到达时间错峰,这些场景决定了算法能否正确处理"排序稳定性"。

以最常见的输入文件为例:

5 T1 0 8 T2 1 4 T3 2 9 T4 3 5 T5 4 2

第一行是任务总数,之后每行依次是任务ID、到达时间、服务时间。表面上简单,但隐含了一个容易漏掉的需求:任务不是一开始全部到达,调度器必须在current_time小于下一个任务到达时间时等待。我一般会在需求分析里专门写一条"支持任务错峰到达",这样设计就完整了。

需求分析还会直接影响编码边界。比如,如果任务书要求"输出每个任务的开始时间",那Task里还需要记录start_time;如果要求"支持任务随机生成",那么输入层就要加一个生成器函数。很多同学拿到题就开写,写着发现字段不够用,又回头改结构体,来回浪费一个下午。我通常的做法是先在纸上画一个输入的样例、一个输出的样例,把字段对齐了再动键盘。

2.2 数据结构选型:优先队列、普通队列和数组各司其职

任务调度器的核心数据结构是就绪队列,但不同算法对队列的要求彼此冲突。FCFS只需要先进先出,用循环队列即可;SJF每次要选出服务时间最短的任务,用最小堆最合适;RR要按顺序循环取任务,本质上又是循环队列。如果只用数组加排序硬扛,每来一个新任务就重新全排序,代码难写且复杂度高,更重要的是答辩时说不清楚"为什么用这个结构"。

因此在完整方案里,我会同时实现两个就绪容器:一个二叉堆用于SJF和优先级调度,一个循环队列用于FCFS和RR。二者都以Task指针为元素,通过相同的队列接口操作。下面这个表是我放在课设报告里的选型对比:

数据结构适用算法核心操作时间复杂度
循环队列FCFS / RR入队、出队、判空O(1)
二叉堆(最小堆)SJF / 优先级调度插入、取最小、堆化O(log n)
数组全部算法存储原始任务、按id索引O(n)

有一点需要特别说明:SJF里的"最短"是指服务时间最小,当服务时间相等时,按到达时间排序。这就要求堆的比较函数不是只看service_time,而是先比service_time,再比arrive_time,否则两个服务时间相同的任务顺序可能与手算结果不一致。这是后面的避坑章节里最容易翻车的一个点。

在这个选型阶段,还有一个容易被当成"玄学"的点:为什么时间片轮转也要用队列而不是链表。链表插入删除虽然方便,但循环队列在固定大小任务数组的课设场景下更简单,不需要频繁malloc/new。如果任务数上限已知,预分配一个MAX_TASKS容量的数组,用head和tail两个下标维护队列,几乎没有内存管理负担。链表方案可以作为加分项写在扩展思考里,但核心代码用循环队列更容易讲。

2.3 用C还是C++:封装与调试成本的权衡

如果允许自由选择语言,我会优先用C而不是C++。原因有三:第一,C的结构体和指针能直观展示任务节点、堆、队列之间的关系,答辩老师很容易看出你掌握的程度;第二,C++的STL会把priority_queue、queue这些容器都包装好,代码量少了,但老师一句"你讲讲最小堆的堆化过程"就可能答不上来;第三,C程序用gdb调试堆数组和队列更透明,每次push/pop后都能直接查看data数组的顺序。

如果你更习惯C++,也可以用class封装Scheduler和Task,但必须自己管理析构和拷贝,避免浅拷贝导致指针重复释放。这个方案里给出的代码以C语言为基准,同时在注释里标注了C++改进点。我这里建议课设代码控制在三个源文件:main.c负责流程控制,scheduler.c负责算法实现,task_queue.c负责堆与队列的操作。模块清晰,答辩时分头讲解很从容。

选型确定后,下一步需要把数据结构封装成统一接口,让调度算法不感知底层是堆还是队列。比如我定义一组入队、出队、判空函数,堆和循环队列都实现同一套函数名,内部各自维护自己的存储方式。这样的抽象能让后续切换算法时只改动一行设置代码,也为第3章的总体架构设计做了铺垫。

3. 总体架构设计:三层结构与五类模块的职责划分

3.1 顶层分层:输入层、调度核心层、输出层

任务调度器整体采用三层结构。输入层负责解析任务文件、校验数据,比如任务数不能为0、服务时间不能为负数、时间片必须大于0等。调度核心层维护任务状态机和三个调度算法,这一层不关心数据是从文件还是控制台来的。输出层负责打印调度顺序和统计指标。这样的好处是算法切换不会影响输入输出,新增算法时只需要在核心层添加一个函数。

在课设报告里,我会用一个模块划分表来说明:

模块文件职责关键结构/函数
main.c解析命令行参数、组织流程Scheduler结构体
task_queue.c循环队列与最小堆的操作enqueue、dequeue、heap_push、heap_pop
scheduler.c三种调度算法的状态推进scheduler_fcfs / sjf / rr
output.c打印调度顺序、计算统计量print_schedule、calc_avg

main函数中先用load_tasks()读取任务数组,再根据命令行参数创建Scheduler,调用scheduler_run(),最后调用print_result()。我会特别在main函数里保留一个打印中间状态的开关,调试时开启,答辩演示时关闭。

在三层结构的边界上,我还会留一个"hook点":调度核心层只暴露一个scheduler_run()接口和一个scheduler_set_algorithm()设置函数。这样main.c无需关心算法内部是用了堆还是队列,如果日后想支持动态优先级抢占,只需在Scheduler里增加一个priority字段,并在比较函数中增加一条判断即可。这种设计看似多写几行代码,但在答辩中被问到"可扩展性"时,就能拿出实际的函数接口来证明,而不是只说思路。

3.2 任务状态机:新建、就绪、运行、完成

任务不能共享同一块"队列"就完事,每个任务都有自己的生命周期。我给Task设计一个state字段,枚举值分别代表NEW(新建)、READY(就绪)、RUNNING(运行)、FINISHED(完成)。调度流程是这样的:所有任务初始都是NEW;当current_time推进到某个任务的到达时间时,把它插入就绪队列并置为READY;调度器从就绪队列取一个任务,置为RUNNING并运行;任务运行完成后置为FINISHED。

以RR举例,任务T1第一次被调度时state由1变为2,运行一个时间片未完成,state回到1并插入队尾;等第二次出队再次变为2,运行完毕才变为3。如果没有这中间一次"READY回缩",任务就可能在第二次被取出时还被当作RUNNING,导致调度器认定它还在运行而不重新分配。状态机把这个问题转化成了简单的枚举判断,代码里一眼能看清。

这个状态机最大的价值是避免重复入队。RR算法里,一个任务执行完一个时间片但没结束,会被放回队尾,此时它的状态应该是READY而不是RUNNING;而一个已经FINISHED的任务如果因为下标判断失误再次被扫描到,就会重复入队。有了state,每次入队前检查一下"不是READY和RUNNING",就能提前拦住错误。

3.3 调度核心模块:统一接口与三个算法的策略选择

调度核心层我不会写成三个相互独立的大函数,而是定义一个Scheduler结构体来统一管理:

typedef struct { int algorithm; /* 0-FCFS 1-SJF 2-RR */ int time_slice; /* RR使用 */ int current_time; Task *tasks; int task_count; int finished_count; } Scheduler;

然后提供一个scheduler_run()入口,内部通过switch调用不同的调度函数。每个调度函数只负责把task的finish_time写好,不直接打印结果。这样统计模块永远从finish_time字段取数,不会因为全局变量被改而算错。

使用统一接口还有一个微观好处:统计模块在调度完成后遍历任务数组,不需要知道刚才跑的是哪个算法。算法的选择只影响每个任务的finish_time,不影响后续计算。这也让main.c的代码量大幅减少,从开始到读入参数再到输出结果,不超过60行。

三种算法的推进逻辑分别是:FCFS按到达顺序入队,任务执行完才让出CPU;SJF每步从最小堆堆顶取任务,同样不可抢占;RR每个任务最多运行一个时间片,没做完就重新入队尾部。对比来看,SJF和FCFS都是"运行到结束",只是选择顺序不同;RR才会在运行中途打断。答辩时我会主动说出这个区别,表明算法并不是简单换排序。

3.4 统计指标模块:周转时间、等待时间与平均数的计算

课程设计通常要求输出平均周转时间,所以统计模块要独立于调度模块。每个任务调度完成后只记录finish_time,统计时再统一计算:周转时间 = finish_time - arrive_time;等待时间 = 周转时间 - service_time;带权周转时间 = 周转时间 / service_time。平均周转时间则是所有任务周转时间之和除以任务数。

这里有一个容易踩坑的细节:打印带权周转时间时,如果用整数除法,9/8会变成1而不是1.12。我会用浮点数保存结果,printf保留两位小数。另外,当任务服务时间为0时,带权周转时间会做除零运算,我在输入校验里直接禁止服务时间小于等于0,从根上规避掉这个黑匣子问题。

输出格式我建议用表格对齐方式,比如每行打印:任务ID、到达时间、服务时间、完成时间、周转时间、带权周转时间。最后单独一行打印平均周转时间和平均带权周转时间。这样的打印结果可以直接贴进课程设计报告,不需要再手工整理。

4. 关键实现:堆、队列与三种调度算法的落地代码

4.1 任务结构体与最小堆的实现

代码从Task定义开始:

typedef struct Task { int id; /* 任务ID,从1开始 */ int arrive_time; /* 到达时间 */ int service_time; /* 服务时间 */ int done_time; /* 已经运行的时间,RR用 */ int finish_time; /* 完成时间 */ int state; /* 0新建 1就绪 2运行 3完成 */ } Task;

就绪容器我采用"两个独立结构体"的方案,先看最小堆:

typedef struct PQueue { Task *data[MAX_TASKS]; /* 存储任务指针 */ int size; /* 堆中元素个数 */ } PQueue; void heap_push(PQueue *h, Task *t) { int i = h->size++; h->data[i] = t; while (i > 0) { int p = (i - 1) / 2; if (compare(h->data[i], h->data[p]) < 0) { Task *tmp = h->data[i]; h->data[i] = h->data[p]; h->data[p] = tmp; i = p; } else { break; } } } Task *heap_pop(PQueue *h) { Task *top = h->data[0]; h->data[0] = h->data[--h->size]; int i = 0; while (1) { int l = 2 * i + 1, r = 2 * i + 2, smallest = i; if (l < h->size && compare(h->data[l], h->data[smallest]) < 0) smallest = l; if (r < h->size && compare(h->data[r], h->data[smallest]) < 0) smallest = r; if (smallest == i) break; Task *tmp = h->data[i]; h->data[i] = h->data[smallest]; h->data[smallest] = tmp; i = smallest; } return top; }

这段代码的逻辑是:heap_push从数组末尾上浮,heap_pop先将堆顶与末尾元素交换并下沉,正好对应堆的两个核心操作。MAX_TASKS采用宏定义,如#define MAX_TASKS 100,避免动态扩容引出的指针失效问题。

compare函数是优先级判定的核心:

int compare(Task *a, Task *b) { if (a->service_time != b->service_time) { return a->service_time - b->service_time; } return a->arrive_time - b->arrive_time; }

返回值小于0表示a应排在b前面。这与普通排序的cmp函数保持一致。很多同学在这里会犯方向性错误,写成b->service_time - a->service_time,导致最小堆变成最大堆,这是最典型的翻车点。

4.2 FCFS与SJF调度实现

FCFS采用循环队列,这里给出调度主体的最简版:

void scheduler_fcfs(Scheduler *sched) { CQueue q; init_queue(&q); int next = 0; while (sched->finished_count < sched->task_count) { while (next < sched->task_count && sched->tasks[next].arrive_time <= sched->current_time) { sched->tasks[next].state = 1; enqueue(&q, &sched->tasks[next]); next++; } if (is_empty(&q)) { sched->current_time++; continue; } Task *t = dequeue(&q); t->state = 2; t->finish_time = sched->current_time + t->service_time; sched->current_time = t->finish_time; t->state = 3; sched->finished_count++; } }

注意两个细节:第一个while负责把到点的新任务放入队列,第二个if用于处理队列为空且任务尚未全部到达的情况,这时current_time逐秒推进会稍慢,但逻辑最简单。如果追求效率,可以直接跳到下一个任务的到达时间,但仍要处理下一个任务也没到齐的边界。

SJF与FCFS的结构非常像,只是把循环队列换成最小堆,取任务时从堆顶取:

void scheduler_sjf(Scheduler *sched) { PQueue h; h.size = 0; int next = 0; while (sched->finished_count < sched->task_count) { while (next < sched->task_count && sched->tasks[next].arrive_time <= sched->current_time) { sched->tasks[next].state = 1; heap_push(&h, &sched->tasks[next]); next++; } if (h.size == 0) { sched->current_time = sched->tasks[next].arrive_time; continue; } Task *t = heap_pop(&h); t->state = 2; t->finish_time = sched->current_time + t->service_time; sched->current_time = t->finish_time; t->state = 3; sched->finished_count++; } }

这段代码与FCFS最大的区别在于两处:一是就绪容器由队列变成堆,二是队列为空时不再用current_time++,而是直接跳跃到下一个任务的到达时间。跳跃写法在SJF里更合理,因为堆为空代表此刻没有可执行任务,继续逐秒加一不会改变堆中的内容。

4.3 时间片轮转(RR)的实现与队尾回插

RR需要维护每个任务已经运行的时间done_time,时间片time_slice由命令行传入。核心代码:

void scheduler_rr(Scheduler *sched, int time_slice) { CQueue q; init_queue(&q); int next = 0; while (sched->finished_count < sched->task_count) { while (next < sched->task_count && sched->tasks[next].arrive_time <= sched->current_time) { sched->tasks[next].state = 1; enqueue(&q, &sched->tasks[next]); next++; } if (is_empty(&q)) { sched->current_time++; continue; } Task *t = dequeue(&q); if (t->state == 3) continue; /* 防御:已完成任务不入队 */ t->state = 2; int need = t->service_time - t->done_time; int run_time = need > time_slice ? time_slice : need; t->done_time += run_time; sched->current_time += run_time; if (t->done_time < t->service_time) { t->state = 1; enqueue(&q, t); /* 没做完,放回队尾 */ } else { t->finish_time = sched->current_time; t->state = 3; sched->finished_count++; } } }

这段代码里最关键的是"回插"动作:任务每轮只能运行一个时间片,如果还没完成,done_time加满当前时间片后重新入队。需要特别注意的是,回插的任务不能被重复计算到达时间,所以next指针只负责"新到达"任务,回插直接走enqueue队列尾部。防御性的state==3判断是血泪经验,曾经有同学在任务完成瞬间又被调度器取出,就是因为状态没有置为完成。

4.4 命令行参数与运行配置

为了让课设更完整,我会让程序支持命令行参数:

./scheduler -a sjf -t input.txt -o result.txt -q 2

各参数含义如下表:

参数说明默认值
-a调度算法,fcfs/sjf/rrfcfs
-t任务文件路径input.txt
-o结果输出文件stdout
-q时间片大小(RR)2

main函数中用getopt解析参数,并用switch判断算法。文件不存在或参数非法时,打印"输入文件不存在",不直接崩溃。这样设计的好处是,演示时可以在答辩现场用同一份任务文件分别跑三种算法,对比输出,效果比只看代码好得多。参数表可以放在设计文档中,代码里只需要一个结构体SchedConfig来保存。

5. 常见问题与避坑指南:五个最容易翻车的地方

任务调度器这个课设,代码量不大,但翻车点很集中。我见过的模拟项目里,十个同学有七八个都卡在下面这几类问题上。这里按"现象→原因→解决"的顺序整理,方便你在答辩前快速自查。

5.1 比较函数方向写反,最短任务反而最后执行

现象:SJF调度输出里,服务时间最短的任务出现在序列最后,运行时间反而像最长作业优先。 原因:最小堆的compare返回值语义与预期相反。我见过很多初学者写compare时参考qsort的写法,结果在堆的siftDown里又套了一层"balance > 0"的符号翻转,负负得正。 解决:统一约定:compare(a,b) < 0 表示 a 优先级高于 b,堆顶永远存放compare值最小的元素。写完compare后,先把三个任务(服务时间6、2、4)按顺序插入堆,再全部弹出,检查是否得到2、4、6。这个测试只需10秒,能避免后面所有算法结果错乱。我一般会在单元测试代码里直接写上这个用例,作为回归检查。

5.2 时间片轮转里任务重复入队,程序死循环

现象:RR运行到某个任务后,发现它被调度了两次,或者printf刷屏不断执行同一个任务。 原因:任务已经完成,但状态没有置为FINISHED;或者调度器在"新到达任务"扫描时重复处理了已经入队的任务。 解决:强制使用任务状态机,入队前检查state是否为NEW或FINISHED。更直接的防御是在调度器入口加一个断言:

if (t->state == 3) continue;

同时维护一个next下标,只扫描尚未入队的新任务,禁止每轮都从头遍历整个任务数组。这两种手段叠加后,重复入队基本被根除。

5.3 任务错峰到达时,程序卡在空队列等待

现象:用全是0时刻到达的测试数据一切正常,换成错峰到达后程序要么长时间无输出,要么直接跳过任务。 原因:调度器在队列为空时没有处理"下一个任务还没到达"的情况。FCFS里用current_time++能侥幸跑通,但SJF里如果也这么做,遇到下一个任务到达时间跨度很大,程序会做大量无效循环,观感上像死机。 解决:队列为空时,直接把current_time跳到下一个待入队任务的到达时间。SJF代码里的sched->current_time = sched->tasks[next].arrive_time;就是这个用途。需要注意的是,跳跃前要确保next下标有效,也就是next < task_count。否则说明所有任务都已经处理完,这本来也是退出循环的条件。

5.4 动态分配产生的野指针和内存泄漏

现象:程序测小数据正常,加到50个任务后偶发崩溃;或者使用valgrind检查时发现一堆"invalid read"。 原因:Task数组用malloc动态分配,而队列data数组里保存的是Task指针;当任务数组被realloc扩容时,旧指针全部失效。这是最典型的野指针来源。 解决:课程设计场景下,直接在栈上定义固定大小数组:

Task tasks[MAX_TASKS];

并先读入任务数再检查是否超过MAX_TASKS。如果一定要动态分配,就一次性malloc最大任务数,不要做realloc。对于队列中的指针,只要任务数组地址不变,指针始终有效。检查泄漏时在gdb或valgrind下跑一遍完整测试,会大大降低翻车概率。

5.5 输出结果与手算不一致,平均周转时间算错

现象:调度顺序看起来对,但平均周转时间比手算大很多,或者带权周转时间出现1.00这种明显不合理的数值。 原因:统计时用"全局current_time"而不是任务自身的finish_time。由于RR会反复切换任务,某个任务完成时current_time可能已经被后续任务推进过,直接用当前时间减到达时间必然出错。 解决:在任务完成时就把t->finish_time记录好,统计模块只从finish_time字段取值。打印前先逐个输出每个任务的arrive_time、finish_time、周转时间,与手算表逐项比对。这里我踩过一个坑:带权周转时间用整数除法,9/8算成1,后来全部改成double,输出加上%.2f,结果才正常。

6. 验收与答辩:把课设做到能演示、能讲透

6.1 功能验收清单

在提交前,我会用一个包含5个任务、3种算法的测试文件跑完整流程,并对照手算结果。下表是最低验收标准:

检查项通过标准
输入解析能正确读取文件,非法参数给出提示不崩溃
FCFS调度顺序与手算完全一致
SJF最短任务最先执行,服务时间相同时按到达顺序
RR每个任务每个时间片只运行一次,回插正确
统计平均周转时间、平均带权周转时间保留两位小数
鲁棒性任务数0、时间片0、文件不存在均能友好退出

6.2 答辩演示流程

答辩时只讲三条主线:需求、选型、验证。先花半分钟说"这个课设解决的问题是让一组任务在三种调度策略下得到不同的执行序列并量化对比",再用同一组数据分别跑FCFS、SJF、RR,指着输出说"SJF的平均周转时间比FCFS低,因为短任务优先完成"。最后结合代码解释为什么要用堆——"SJF每次取任务需要O(log n),如果改用顺序查找整体复杂度就到O(n²)"。老师如果要你展示代码,优先展示compare函数和RR的回插逻辑,这两个点最能体现出对数据结构的理解。

演示前准备一页打印纸,左侧输入、右侧三个算法的输出,老师提问时不会因为现场敲命令而冷场。如果时间充裕,可以点亮一个"debug模式"开关,打印每个时间片的就绪队列内容,这会让演示显得很扎实。

这套完整的课程设计资源,包含可编译运行的C源码、任务生成与测试用例、设计文档框架和答辩PPT提纲,拿到后可以直接对照修改。建议在动笔写报告前先跑通功能,再回头整理文档,效率会高很多。

我最初做模拟项目X时,也曾在RR里把任务重复入队,调试到半夜翻车,后来养成的习惯是每改一次算法,先拿三个任务做手算,再到程序里打印current_time和队列内容比对。从那以后,我每次做调度类的课程设计,都会强制自己走一遍"手算结果 → 程序输出 → 差异分析"的流程。希望这份方案能帮到你,少踩几个坑。

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

RELATED READING

延伸阅读

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