操作系统学习笔记 · 第 21 课 · 缓存与磁盘调度——为什么系统越用越快
你可能注意到一个现象:同一个文件,第一次打开慢,第二次打开就飞快。为什么?因为系统"记性很好"——读过的数据,它偷偷缓存起来了。这一课,我们讲缓存与磁盘调度,看清"为什么系统越用越快",以及"快"背后的代价。这是第五阶段(存储与 I/O)的收官课。
从"第二次读文件为什么快"说起
你第一次用 vim 打开一个大文件,可能要等一会儿;关掉,马上再打开——几乎秒开。
同样的文件、同样的磁盘,为什么第二次快这么多?答案:页缓存(page cache)。文件第一次读,数据从磁盘读进内存,顺手留在内存里缓存;第二次读,直接从内存拿,根本不用碰磁盘。
磁盘慢、内存快,这个缓存就是"用空间换时间"的典型。我们拆开看。
21.1 页缓存(page cache)
一句话理解:Linux 把读过的文件页缓存在内存里,下次再读直接从内存拿,不用碰磁盘——所以"第二次读同一文件特别快"。
还记得第 16 课的"分页"吗?文件的数据在内存里也是按页管理的。页缓存干的事:
- 读文件时,内核把对应页从磁盘读进内存的页缓存里;
- 这页数据留在缓存里,不急着丢弃;
- 下次再读同一页,缓存命中,直接返回,不碰磁盘。
一个容易误会的现象:free -h里buff/cache占用很高,内存"看着快满了"。其实很多是缓存——它随时可以被释放掉给真正需要内存的程序用,不是"内存泄漏"。所以 Linux 内存"用满"是好事,说明缓存充分。
21.2 缓冲区(buffer)
一句话理解:缓冲区是"暂存待处理数据的区域",把零散的小读写攒成一大块再落盘,减少磁盘访问次数。
页缓存管"读",缓冲区管"攒"。举例子:
- 你写日志,每写一行就要落盘一次?那磁盘会被频繁唤醒,很浪费;
- 缓冲区把零散的小写操作攒起来,攒到一定量(比如 4KB 一个块),一次性落盘。
一句话:缓冲区 = 攒零为整,减少磁盘访问次数。 就像快递攒够一车再发,而不是来一个包裹跑一趟。
21.3 写回 vs 写穿(write-back vs write-through)
缓冲区攒着数据,那到底什么时候真正写进磁盘?两种策略:
| 策略 | 做法 | 优点 | 风险 |
|---|---|---|---|
| 写穿(write-through) | 每次写都立即落盘 | 安全 | 慢 |
| 写回(write-back) | 先写缓存,稍后再落盘 | 快 | 断电可能丢数据 |
一句话理解:写穿"稳但慢",写回"快但有风险"。
- 写穿:每次写操作立刻同步写到磁盘。数据最安全(断电也不丢),但每次都要等磁盘,慢;
- 写回:先写到缓存,标记为"脏页",稍后再批量落盘。速度快,但突然断电,还没落盘的脏页就丢了。
Linux 页缓存默认写回:靠定时器和阈值触发刷盘。脏页占比超过阈值(vm.dirty_ratio/vm.dirty_background_ratio),内核就强制刷盘,避免脏页堆积太多。这就是"快"和"安全"之间的权衡。
21.4 磁盘调度(机械硬盘)
数据真正落到机械硬盘上时,还有一个优化点:磁头怎么移动。
一句话理解:机械硬盘有磁头和旋转盘片,寻道(磁头移动)最耗时。磁盘调度把请求重新排序,让磁头"少跑冤枉路",一次扫过去尽量多服务。
机械硬盘的结构:一个旋转的盘片 + 一个会移动的磁头。读数据时,磁头要先移动到正确的位置(寻道)——这个"移动"是最耗时的。
如果来一个请求就服务一个,磁头会东一下西一下地乱跑,浪费大量时间在寻道上。所以有了磁盘调度:把一堆请求重新排序,让磁头顺着一个方向扫,沿途把该方向上的请求都服务掉。
电梯算法(SCAN)最形象:磁头就像电梯——朝一个方向扫,服务沿途所有请求,扫到头再折返。而不是"来一个跑一趟"。
21.5 SSD 为什么不需要磁盘调度
一句话理解:SSD 没有磁头和旋转,随机访问和顺序访问几乎一样快,所以不需要调度;它的优化方向变成"磨损均衡"和"垃圾回收"。
机械硬盘要调度,是因为"寻道慢"。但 SSD(固态硬盘) 没有磁头、没有旋转盘片——它是纯电子芯片,随机访问和顺序访问几乎一样快。
所以:
- SSD 不需要"让磁头少跑"的调度算法;
- SSD 的优化方向变了:磨损均衡(让每个存储单元磨损均匀,延长寿命)和垃圾回收(回收不再用的块)。
所以现代系统对 SSD 用更简单的 I/O 调度器(如none、mq-deadline)。"优化"永远对着"瓶颈"来——瓶颈变了,优化手段就变。
动手实验:C 语言写电梯调度(SCAN)模拟
// lesson21.c —— 电梯调度(SCAN)算法模拟
#include <stdio.h>
#define REQS 8
int main(void) {
// 磁盘请求磁道号(乱序)
int reqs[REQS] = {98, 183, 37, 122, 14, 124, 65, 67};
int head = 53; // 当前磁头位置
int direction = 1; // 1=向磁道增大方向,-1=减小方向
int served[REQS] = {0};
int total_seek = 0;
printf("SCAN 电梯调度(起点 %d)\n", head);
for (int round = 0; round < REQS; round++) {
int best = -1;
for (int i = 0; i < REQS; i++) {
if (served[i]) continue;
if (direction == 1 && reqs[i] < head) continue; // 只找前进方向的
if (direction == -1 && reqs[i] > head) continue;
if (best == -1 ||
(direction == 1 && reqs[i] < reqs[best]) ||
(direction == -1 && reqs[i] > reqs[best]))
best = i; // 找前进方向上最近的请求
}
if (best == -1) { direction = -direction; round--; continue; } // 到头折返
total_seek += reqs[best] > head ? reqs[best] - head : head - reqs[best];
printf("服务 %d(移动 %d)\n", reqs[best], reqs[best] > head ? reqs[best]-head : head-reqs[best]);
head = reqs[best];
served[best] = 1;
}
printf("总寻道距离:%d\n", total_seek);
return 0;
}编译运行:
gcc lesson21.c -o lesson21 && ./lesson21观察输出:磁头像电梯一样朝一个方向扫,沿途服务该方向上的请求,扫到头再折返。总寻道距离,远小于"来一个服务一个"(那样磁头会来回乱跑)。
你可以试着改成"先来先服务(FCFS)",对比总寻道距离,直观感受"调度"带来的收益。
深入点:两个进阶话题
① 脏页刷盘时机
写回策略里,"什么时候刷盘"由两个参数控制:
vm.dirty_ratio:脏页占内存比例达到这个值,写操作会阻塞,强制刷盘;vm.dirty_background_ratio:脏页比例达到这个值,后台开始刷盘(不阻塞)。
这是调优 Linux 写性能的关键参数:设太大,断电丢的数据多;设太小,刷盘太频繁、性能差。要平衡。
② 预读(readahead)
顺序读文件时,内核会"猜"你接下来还要读后面的页,于是提前把后面的页读进缓存。
这样顺序读(比如看视频、顺序扫描日志)几乎无等待——你读第 1 页时,第 2、3、4 页已经在内存里候着了。这也是"局部性原理"的又一次应用(第 17 课见过)。
小结与思考题
这一课,我们回答了"为什么系统越用越快":
- 页缓存:读过的文件页缓存在内存,第二次读不碰磁盘;
- 缓冲区:零散小写攒成一大块再落盘,减少磁盘访问;
- 写回 vs 写穿:快但有风险 vs 稳但慢,Linux 默认写回靠阈值刷盘;
- 磁盘调度:机械硬盘靠 SCAN 让磁头少跑冤枉路;SSD 无磁头,不需要调度。
留三个问题:
- 页缓存为什么让"第二次读文件"变快?
- 写回和写穿的区别?各适合什么场景?
- 为什么机械硬盘要调度、SSD 不需要?
到这里,第五阶段(存储与 I/O,19~21 课)收官。前面我们都是"单机"视角:进程、内存、磁盘。但从下一课起,视野要扩展到网络——程序不再只跟本机的文件对话,而是跟另一台机器上的程序对话。它们之间怎么建立连接?"三次握手"到底是什么?我们进入第六阶段:网络编程。