你可能注意到一个现象:同一个文件,第一次打开慢,第二次打开就飞快。为什么?因为系统"记性很好"——读过的数据,它偷偷缓存起来了。这一课,我们讲缓存与磁盘调度,看清"为什么系统越用越快",以及"快"背后的代价。这是第五阶段(存储与 I/O)的收官课。

从"第二次读文件为什么快"说起

你第一次用 vim 打开一个大文件,可能要等一会儿;关掉,马上再打开——几乎秒开

同样的文件、同样的磁盘,为什么第二次快这么多?答案:页缓存(page cache)。文件第一次读,数据从磁盘读进内存,顺手留在内存里缓存;第二次读,直接从内存拿,根本不用碰磁盘

磁盘慢、内存快,这个缓存就是"用空间换时间"的典型。我们拆开看。


21.1 页缓存(page cache)

一句话理解:Linux 把读过的文件页缓存在内存里,下次再读直接从内存拿,不用碰磁盘——所以"第二次读同一文件特别快"。

还记得第 16 课的"分页"吗?文件的数据在内存里也是按管理的。页缓存干的事:

  1. 读文件时,内核把对应页从磁盘读进内存的页缓存里;
  2. 这页数据留在缓存里,不急着丢弃;
  3. 下次再读同一页,缓存命中,直接返回,不碰磁盘。
一个容易误会的现象:free -hbuff/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 调度器(如 nonemq-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 无磁头,不需要调度。

留三个问题:

  1. 页缓存为什么让"第二次读文件"变快?
  2. 写回和写穿的区别?各适合什么场景?
  3. 为什么机械硬盘要调度、SSD 不需要?
到这里,第五阶段(存储与 I/O,19~21 课)收官。前面我们都是"单机"视角:进程、内存、磁盘。但从下一课起,视野要扩展到网络——程序不再只跟本机的文件对话,而是跟另一台机器上的程序对话。它们之间怎么建立连接?"三次握手"到底是什么?我们进入第六阶段:网络编程

标签: 计算机基础, 操作系统, Linux

添加新评论