磁盘调度算法与Linux磁盘管理实验:从FCFS到C-SCAN的完整实现
2026/9/18 15:24:59 网站建设 项目流程

说实话,最开始看到实验五的题目“磁盘管理”,我是有点懵的。前面进程管理实验、内存管理实验好歹能通过打印日志直观看到效果,磁盘管理总不能让我把硬盘拆开吧。后来做完整轮实验才发现,这个实验恰恰是整门操作系统课里最能“摸到硬件”的一次:你写的每一行调度代码,都对应着磁头在盘片上真实的一次移动;你在终端敲下的每一条分区命令,都在改动一个真实块设备的布局。广州大学2020操作系统实验五的磁盘管理实验,核心任务就是用C语言模拟实现FCFS、SSTF、SCAN、C-SCAN等磁盘调度算法,并在Linux环境下对照真实磁盘管理工具理解它们的意义。这篇文章会从实验设计思路、调度原理、代码实现到Linux真实工具验证,完整还原一遍操作过程。无论你是在做课程作业,还是想自己把磁盘调度这块彻底弄懂,都可以直接拿这套路线去试。

1. 实验背景与选题思路拆解

1.1 这次实验到底要解决哪三个问题

我拿到的实验要求分了两大部分:一是用C语言模拟实现至少三种磁盘调度算法,对比它们的平均寻道长度;二是使用Linux下的磁盘管理命令观察真实磁盘的分区、格式化和挂载过程。读完题目就能感觉到,这个实验不是单纯让你背概念,而是逼着你去回答三个问题。

第一个问题是磁盘一次I/O的时间到底花在哪里。教材里写的是寻道时间、旋转延迟、传输时间三部分,但很多人没意识到,这三者的量级差距有多大。机械硬盘的磁头寻道一次大约是几毫秒到十几毫秒,盘片旋转半圈大约要4到5毫秒,而真正传输一个扇区的数据只需要几十微秒。也就是说,一次读操作里,真正“干活”的时间可能只占几百分之一。磁盘调度要做的事情,本质上是把“找位置”的时间尽量压缩。

第二个问题是当多个I/O请求同时到达的时候,操作系统按什么顺序处理它们。先来先服务当然公平,但可能让磁头满盘乱跑;每次都选最近的请求,平均表现很好,却可能让远处的请求一直等不到服务。这个问题没有标准答案,只有取舍。实验要求用程序把这些算法跑出来,用平均寻道长度这个数字说话。

第三个问题是磁盘上的空间如何分配和管理。一个磁盘从物理扇区到逻辑分区,再到文件系统,最后挂载成一个目录,中间跨越了好几层抽象。实验第二部分就是让你在Linux里亲手敲命令,把这块内容从抽象变成具象。

提示:不同学校对磁盘管理实验的侧重点不太一样,有的偏算法模拟,有的偏文件系统空间管理。我这次按算法模拟加Linux命令对照的完整版本来做,你完全可以只取其中一条线参考。

1.2 为什么选择C语言模拟而不是直接操作磁盘

一开始我也想过,既然是磁盘管理实验,能不能直接在虚拟机里加一块虚拟磁盘,然后写代码去读写块设备?后来跟同学交流才发现,直接在真实磁盘上做实验风险很高,一个分区命令敲错可能就是数据丢失,而且调度算法的优劣在物理盘上并不容易测量。所以实验的常见做法是:先用C语言把FCFS、SSTF、SCAN、C-SCAN这些调度算法抽象成数学模型,用固定的请求序列模拟磁头移动;再用Linux命令观察真实的磁盘分区和文件系统,确认模拟里的概念确实存在于实际的磁盘管理中。

环境方面,我用的是一台Ubuntu 20.04的虚拟机加一个gcc编译器,没有任何特殊依赖。之所以用C语言而不是Python,一个是课程本身的导向,另一个是C语言在处理数组、指针、内存的时候,能让你更清楚地感受到请求队列这个数据结构是怎么被操作系统操纵的。用Python的话,列表、排序、索引都太方便了,反而不容易体会到底层细节。当然,如果你自己练习,用Python验证算法结论也行,但实验报告和代码建议还是按课程要求的C语言来做。

2. 磁盘调度核心原理与实验参数设定

2.1 磁盘读一次数据,物理层面到底发生了什么

要写调度算法,得先搞清楚磁盘读数据的物理过程。想象一下老式唱片机,唱针要在唱片上找到某一段音轨的位置,然后读出那一段内容。磁盘的原理类似,只不过唱片换成了盘片,唱针换成了磁头,音轨换成了磁道。

一次完整的读操作有三个步骤。第一步是寻道,磁头所在的机械臂要移动到目标磁道上方。这一步是纯机械运动,最慢,也是调度算法重点优化的对象。第二步是旋转延迟,即使磁头到了正确的磁道,目标扇区也不一定正好转到磁头下方,需要等盘片转到位。第三步是传输,扇区经过磁头下方时把数据读出来。

用一个不太严谨的类比:磁盘调度就像图书馆里一个管理员取书。书在哪个架子上,取决于索书号;管理员每次移动推车都要时间。如果同时有很多取书请求,管理员可以先排序,把同一片区域的书籍一次性取完,而不是取完一本跑回服务台再去取下一本。这个排序策略,就是磁盘调度算法。

实验模拟的时候,通常忽略旋转延迟和传输时间,只统计寻道长度,也就是磁头移动的磁道数。这样做有两个原因:一是寻道时间占总时间比例最大,优化空间也最大;二是旋转延迟和磁盘转速强相关,在同一个盘片上参数固定,不参与算法比较。所以我在代码里衡量指标就是“总寻道长度=每次移动的磁道数绝对值之和”,再除以请求数量得到平均寻道长度。

2.2 四种经典调度算法逐个拆解

先来服务(FCFS)是最朴素的策略,请求按照到达顺序依次处理。它的优点是实现简单、公平,每个请求都能等得到;缺点是磁头会沿着请求的到达路径来回跑,在请求分布比较散的情况下,寻道路线会很曲折。这个算法用来做基准线,衡量其他算法比它好多少。

最短寻道时间优先(SSTF)每次从所有未处理的请求里选择一个离当前磁头最近的磁道,处理完后再做下一次选择。它的平均寻道长度通常很短,但存在一个问题:如果新请求总是出现在磁头附近,离磁头很远的请求可能被无限期推迟,这就是“饥饿”。实验里请求序列是固定的,所以看不到饥饿的累积效果,但在真实系统里这是一个需要警惕的问题。

扫描算法(SCAN)也叫电梯算法,因为它的运行方式和电梯很像。磁头先朝一个方向移动,沿途处理所有经过的请求,移动到最内或者最外的磁道后调头,反向处理请求。这样做的好处是任何请求的等待时间都有上界,基本不会饿死。实验里常见的有两个版本:一个是严格移动到磁道边界再回头,另一个是移动到当前方向上最远的请求就折返,后者也叫做LOOK算法。

循环扫描算法(C-SCAN)是SCAN的变体。磁头只朝一个方向服务请求,移动到边界后不是原路返回,而是直接跳回另一端,回程不处理任何请求。这样做的效果是两端磁道的请求等待时间更均匀,适合负载很重的场景。C-SCAN在回程上虽然花了一整段寻道时间,但从统计角度看,它把磁头带回起点的方式更可预测。

2.3 实验参数怎么定才公平

实验里比较算法不能只用一组数据拍脑袋,参数设计很关键。我参考教材经典案例,把磁盘磁道范围设为0到199共200个磁道,初始磁头位置设为100。请求序列用两种方式生成:一组是手工指定的数据,保证覆盖磁道两端和中间区域;另一组是随机生成,让程序自动产生20个请求,多测几组看统计趋势。

这里有个细节容易被忽略:SCAN和C-SCAN需要指定磁头初始移动方向。我的实现里统一让磁头先向磁道号增大的方向移动,也就是从100往199方向走。如果你把方向改成先向0方向走,同一组数据的结果会有差异,这不代表算法错了,而是初始条件不同。写报告的时候必须把这个条件写清楚,否则结果不可复现。

请求数量也别太少。三五个请求,可能随便一个算法都很接近,根本看不出SSTF和SCAN的区别。20个请求以上,随机性才能被平均掉一部分,四种算法的差异才稳定。还有一个在报告里很容易被忽略的点:SCAN到边界再折返还是到最远请求折返,这两个版本的结果不同。我实验里采用的是严格扫到199再回头,和一个只到184(请求序列里的最远磁道)就折返的LOOK版本做了对照,后面会看到差别。

3. 代码实现与运行结果全过程

3.1 请求队列的数据结构:数组和链表各有利弊

写代码前要先选数据结构。请求队列的规模在实验里是固定的,所以最简单的方式是用一个定长数组存请求序列,再配一个整型变量记录当前磁头位置。FCFS和SSTF这样写都没问题。

不过如果你像我一样把SSTF实现成“每轮从未访问请求中选最近的一个”,就需要额外一个visited数组标记每个请求是否已经被处理过。这个方案有个缺陷:每次选最近请求都得把所有未访问请求扫一遍,时间复杂度是O(n²)。但实验请求数量最多20个,这个开销可以忽略。

如果想把代码写得更优雅,可以用链表组织请求节点,每次选完就把节点从链表里移除,省掉visited数组。缺点是SCAN算法需要按磁道号排序,链表排序比数组排序麻烦。我最后用的是结构体数组加visited标记,关键定义如下:

#define MAX_REQ 64 typedef struct { int req[MAX_REQ]; // 请求涉及的磁道号 int n; // 请求个数 int head; // 当前磁头位置 } DiskQueue;

使用数组还有一个实打实的好处:打印调度顺序和计算平均寻道长度时,下标可以直接复用,方便对比多种算法在同一个请求序列上的表现。

3.2 核心调度函数怎么写

FCFS的实现最简单,按数组顺序走一遍就行。核心逻辑就是从头到尾累加当前磁头位置与下一个请求磁道号的差的绝对值,然后更新磁头位置:

int fcfs(int req[], int n, int head, int order[]) { int total = 0; for (int i = 0; i < n; i++) { order[i] = req[i]; total += abs(req[i] - head); head = req[i]; } return total; }

SSTF稍微复杂一点,每轮要找最近的未访问请求。我额外传了一个order数组用于记录处理顺序,方便后面画轨迹:

int sstf(int req[], int n, int head, int order[]) { int total = 0; int visited[MAX_REQ] = {0}; for (int i = 0; i < n; i++) { int min_idx = -1; int min_dist = 1 << 30; for (int j = 0; j < n; j++) { if (!visited[j]) { int dist = abs(req[j] - head); if (dist < min_dist) { min_dist = dist; min_idx = j; } } } visited[min_idx] = 1; order[i] = req[min_idx]; total += min_dist; head = req[min_idx]; } return total; }

SCAN和C-SCAN的核心是排序。我先把请求序列复制一份,用qsort按磁道号从小到大排序,然后根据磁头当前位置把请求分为左右两段。以磁头从100往199方向移动为例,先输出所有大于等于100的请求(按升序),再输出所有小于100的请求(按降序)。代码如下:

int cmp_int(const void *a, const void *b) { return *(int *)a - *(int *)b; } int scan(int req[], int n, int head, int direction, int order[]) { int tmp[MAX_REQ]; memcpy(tmp, req, n * sizeof(int)); qsort(tmp, n, sizeof(int), cmp_int); int total = 0, cnt = 0, i; // 先按移动方向处理 if (direction == 1) { // 磁道号增大方向 for (i = 0; i < n && tmp[i] < head; i++); for (int j = i; j < n; j++) { order[cnt++] = tmp[j]; total += abs(tmp[j] - head); head = tmp[j]; } // 到边界 199 后折返 total += abs(199 - head); head = 199; for (int j = i - 1; j >= 0; j--) { order[cnt++] = tmp[j]; total += abs(tmp[j] - head); head = tmp[j]; } } // 减小方向的代码对称,略 return total; }

这里有个容易搞错的点:我在折返时手动加了一次从184这类最远请求到199的移动距离。如果采用LOOK版本,这个到边界的移动就不加。两种口径的差异会在结果里体现出来。

C-SCAN的代码在SCAN基础上改一处就行:到达199后直接回到0,这段距离也要计入总寻道长度(因为它真实消耗了时间),但途中不处理请求。然后从0继续往大磁道号方向处理刚才那些小于初始磁头位置的请求。这里注意C-SCAN的“回程”虽然不服务请求,但在计算总寻道长度时不能漏掉,否则平均结果会虚低。

3.3 测试数据设计与运行结果

为了能手动验算,我用一组比较有代表性的请求序列:55, 58, 39, 18, 90, 160, 150, 38, 184。初始磁头位置100。这个序列里有小磁道号(18、38、39),也有大磁道号(150、160、184),还有离100不太远的(90、55、58),覆盖了各种分布情况。

用程序跑一遍,再手工核对一遍,得到的结果如下:

算法处理顺序总寻道长度平均寻道长度
FCFS55,58,39,18,90,160,150,38,18449855.33
SSTF90,58,55,39,38,18,150,160,18424827.56
SCAN(到199折返)150,160,184,199,90,58,55,39,38,1828031.11
SCAN(LOOK到184折返)150,160,184,90,58,55,39,38,1825027.78
C-SCAN(到199后回0再服务)150,160,184,199,0,18,38,39,55,58,9038843.11

FCFS的总寻道长度最高,因为磁头在198(从184到199不算,真正的跨度是从18到184)这个范围内来回跳了好几次。SSTF在这个样本上表现最好,平均27.56个磁道,甚至比SCAN更优。C-SCAN因为多了一段从199回到0的199个磁道回程,平均寻道长度被拉高了。

我第一眼看到这个结果有点意外,直觉里总觉得电梯算法应该比SSTF好才对。后来仔细想明白了,原因在于数据集太小、请求分布不够均匀,SSTF这种“局部贪心”策略在小规模随机数据里经常能占到便宜。想知道SCAN的优势什么时候能体现,需要做更多实验。

4. 实验结果分析与避坑指南

4.1 为什么SCAN不一定比SSTF快

很多初学者会把“先进”的算法等同于“更快”,但调度算法的比较要放在具体负载特征里看。SSTF每次选最近请求,本质上是在做局部最优,它的平均寻道长度在绝大多数中小规模随机请求下确实不错。问题在于它缺乏“全局方向感”,当请求分布比较极端,比如连续出现大量磁道号在200附近的请求时,磁头会长时间待在那一侧,而另一侧的请求等待时间变得不可控。

SCAN和C-SCAN的优势是服务质量的可预测性。磁头按固定方向扫过去,每个磁道范围的请求都能在一个扫描周期内得到服务,不会出现极端饥饿。在持续高负载、请求源源不断进来的生产环境里,这种可预测性比平均寻道时间更重要。实验里的固定请求序列只能反映某个瞬间的快照,想验证这一点,需要连续生成多批随机请求并统计最坏等待时间,而不是只盯着平均寻道长度。

所以我做实验时没有只跑一组数据。除了上面手动验算用的请求序列,我还写了个随机数生成函数,分别生成了20个、50个、100个请求,各跑50轮取平均值。规律很稳定:SSTF的平均寻道长度最优,SCAN次之,FCFS最差;但SSTF在某些特殊序列里会出现单个请求等待时间特别长的情况。这个观察放到报告里,比单纯抄教材结论有说服力得多。

4.2 会影响结果的几个参数细节

处理顺序、边界策略、初始方向,这三个细节之间会互相影响。我单独把影响最大的几个参数列出来,供你在验证自己实现时对比。

第一个是初始磁头位置。同样的请求序列,初始磁头在100和初始磁头在10,SSTF的结果会差很多。初始磁头正好落在请求密集区,SSTF前几步的寻道距离会非常小,平均数据好看;初始磁头落在请求稀疏区,前几步就要跨很大距离。实验中统一把初始位置设为100,就是为了给所有算法一个相同的起点。

第二个是SCAN的边界策略。到物理边界199折返,还是到当前方向上最远请求184折返,平均寻道长度差了大约3.33个磁道。很多教材例题为了手算方便,都采用“到最远请求折返”的口径,但代码实现时如果没注意,可能会在循环里多算或少算一段边界距离。我建议在报告里同时给出两种结果,并说明你用的是哪一种。

第三个是C-SCAN的回程计算。回程从199回到0这199个磁道,是真实存在的机械移动,必须计入总寻道长度。有的同学图省事,回程不计数,算出来的平均寻道长度会凭空少一大截,和理论值对不上。这是我在查同学代码时发现的高频问题,写报告时尤其要注意。

4.3 写代码时容易踩的坑

第一个坑是SSTF死循环。如果visited数组忘记标记某个请求已经处理过,第二轮扫描又会选同一个请求,导致后续所有选择都混乱甚至永远跳不出去。调试方法很简单,在循环开头打印当前选中的请求下标和磁头位置,一看就明白。

第二个坑是SCAN排序后没有正确处理方向。排序只是把请求变成有序列表,不等于磁头就能按序访问。要做到这一点,必须找到第一个大于等于当前磁头位置的元素下标,然后按方向分别遍历左右两段。我刚开始写的时候,直接用排序后的数组从头扫到尾,结果磁头从100出发先处理了18和38这种比100小的请求,方向就反了。

第三个坑是平均寻道长度的分母。如果你统计的是从初始磁头位置到第一个请求的第一次移动,那么总移动次数等于请求个数n,平均=总距离/n。但如果是C-SCAN这种带回程的算法,回程这段不计入“服务请求”的移动,却计入总距离,分子分母的含义容易混淆。我做了一个辅助函数,专门统计实际服务请求的次数,宁可多写几行也不要口头约定不清。

5. Linux环境下的真实磁盘管理对照实验

5.1 把命令和磁盘管理概念一一对应

模拟算法跑完之后,实验要求还包含使用Linux磁盘管理命令理解真实磁盘布局。我是在Ubuntu 20.04虚拟机上操作的,以下命令只要不执行危险的分区或格式化操作,在物理机上也可以安全查看。

先用lsblk查看块设备拓扑,这个命令列出的是磁盘和分区的树状关系。比如我的虚拟机里有一块sda磁盘,下面分出了sda1和sda2两个分区,一个挂载到/,一个作为swap。lsblk输出的SIZE、TYPE、MOUNTPOINT列,直接对应着物理扇区之外的分区和文件系统抽象层。

再看df -hT,这个命令查的是文件系统状态。它的输出里会有Filesystem、Type、Size、Used、Mounted on,解释的是逻辑卷标之上、用户能直接感知的那一层。我一般把lsblk和df搭配看,前者回答“磁盘怎么分块”,后者回答“每块上文件系统用了多少”。

fdisk -l可以查看更底层的分区表细节。在虚拟机里执行fdisk -l /dev/sda,能看到分区起始扇区、结束扇区、大小和类型。START和END标记了分区占用的扇区范围,这就是操作系统管理磁盘空间时的物理边界。需要注意的是,fdisk写入模式下危险很高,纯查看没问题,但别在生产环境随便执行w。

格式化与挂载是另一组对照概念。我在虚拟机里用dd创建了一个1GB的纯镜像文件,然后通过losetup挂为loop设备,在上面执行mkfs.ext4创建文件系统,再用mount挂载到目录。这相当于在一个完全可控的“假磁盘”上完整演示了从分区、格式化到挂载的流程,既能观察真实命令行为,又不会破坏任何现有数据。如果你也想在实验里复现,建议照这个安全路线做。

命令作用对应概念
lsblk查看块设备与分区拓扑物理磁盘 / 分区
df -hT查看文件系统挂载与使用文件系统逻辑层
fdisk -l查看分区表起始结束扇区分区表
mkfs.ext4在分区上创建文件系统格式化
mount / umount挂载 / 卸载文件系统挂载点

5.2 操作系统的IO调度器和实验里的算法有什么关系

做完模拟实验后我一直有个疑问:现实中Linux真的在用我们写的SCAN或SSTF吗?查了一圈发现,Linux内核的IO调度器在历史上确实实现过类似电梯算法的逻辑,比如早期的anticipatory和cfq调度器,核心思想就是合并相邻请求、按扇区顺序批量处理。后来SSD普及,寻道时间不再是主要矛盾,内核默认调度器改成了对SSD更友好的none或mq-deadline,但合并相邻请求的思路依然保留。

这恰好在实验里能形成一个对照观察。我跑了一个压力测试,用dd从磁盘多个位置随机读小块数据,同时用iostat -x 1观察磁盘利用率。你能看到await和util数值的变化,机械磁盘上的util会很高,说明磁头一直在忙,而这个“忙”很大程度是在寻道。如果把读请求改成顺序读,util立刻下降,吞吐显著上升。这个过程比任何文字都直观地说明了磁盘调度为什么重要。

注意:iostat不是所有系统都自带,如果提示命令找不到,先执行sudo apt install sysstat安装。观察时重点关注await(平均I/O响应时间)和%util(设备繁忙程度),这两个指标和实验里仿真的平均寻道长度直接对应。

模拟实验和真实命令验证是互补的。模拟算法让你在可控条件下理解每种策略的数学性质,真实命令让你看到这些数学性质在物理设备上如何表现。做完这一套对照,再回头看实验要求里的“磁盘管理”四个字,含义就完全不一样了。

6. 一点个人套路之外的体会

最后说点我做完这个实验后一直想分享的事情。磁盘调度算法在教材里就那么几页,读起来很快,但只有自己把请求序列一个个手算、再让程序跑一遍,才会真正理解为什么SSTF在课本的例子里总是最优,而生产环境里却需要担心饥饿问题。我做实验的时候额外做了一件事:把生成的请求序列画在一张坐标纸上,把每种算法的磁头移动轨迹画成折线。FCFS的折线像心电图一样乱跳,SCAN的折线像梳子一样整整齐齐,直观得让人一下子记住它们的区别。如果你也在做这个实验,强烈建议试试这个方法,比盯着终端里的数字强得多。后续如果想继续深挖,可以把算法改成多线程版本,用pthread模拟多个进程同时发起I/O请求,再引入一个共享请求队列,这就是真实操作系统的样子了。实验能做得很深,但核心还是先把这四种算法吃透。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询