有个现象几乎人人都遇到过,但很少有人说清为什么会这样。

同一个文件,第一次读要几百毫秒,第二次读只要几十毫秒。time 命令给出的数字摆在那里,于是结论就写成了「第二次命中了页缓存」。

这个结论对不对?对。但它不是一个被证明的结论,只是一个被观察到的相关性。耗时短的原因有很多:磁盘刚好不忙、CPU 频率刚好上去了、别的进程刚好没抢内存。耗时是结果的投影,不是原因本身。

要真的证明「数据来自页缓存」,得拿出缓存层自己的计数。这篇文章就是把这件事做完整:先拆数据路径,再给出四类互相独立的证据,然后回答一个更实际的问题,这些证据能推出什么,推不出什么。

所有数据都来自一台真实的 KVM 虚拟机,本机实测,可复现。实验环境、程序源码和原始输出都在文末。

目录


第一章 一次 read() 的完整数据路径

从一行代码说起

1
ssize_t n = read(fd, buf, 4096);

这行代码做了两件事:把进程的意图告诉内核,等内核把数据放到 buf 里。真正的工作全在内核侧。

图一:read() 的完整数据路径

从下往上看每一层在干什么:

设备与驱动层。数据的终点。这台机器上的「物理磁盘」是 QEMU 给虚拟机的一块虚拟盘,驱动是 virtio-blk。这一层负责把块号翻译成真正的读命令发出去。

块层。它接收「读第 N 到第 M 个扇区」这样的请求,做合并和排序,再交给驱动。相邻的小请求会在这里被合并成大请求,这一点后面会影响到实测数字。

文件系统层。它的职责是翻译:把「文件的第 8192 字节」这种逻辑地址,翻译成「磁盘的某个块」。不同文件系统的翻译方式不同(ext4 用 extent 树,XFS 用 B+ 树),但对上层来说是透明的。

页缓存。这是整条路径的关键。它是一棵按「文件 + 页号」索引的树,缓存着文件内容对应的物理页。如果请求的页已经在这棵树里,内核就跳过下面所有层,直接把数据拷回用户缓冲区。

VFS 层。统一的文件操作接口,负责权限检查、文件位置维护、以及决定走哪条具体实现。

回到用户态。数据通过 copy_to_user 拷进 buf,read() 返回拷贝的字节数。

分岔点在哪

这张图里最值得记住的是分岔:路径在页缓存处一分为二。

  • 命中:数据已经在页缓存里 → 直接拷贝返回。下面三层完全不参与。
  • 未命中:先把数据从磁盘读上来,填进页缓存,然后走命中那条路。

注意这里有个容易被忽略的设计:未命中时,读上来的数据会留在页缓存里。这就是为什么第二次读会快:第一次顺便把缓存铺好了。

还要注意,无论哪条路,最终数据都是拷贝进用户缓冲区的。这个拷贝不是免费的:1 GiB 的数据要在内核态和用户态之间搬 1 GiB。后面讲 mmap 时会看到,这条拷贝正是 mmap 想省掉的东西。

关键观察:系统调用这一层是”瞎”的

把上面那段话反过来读一遍。

read() 这个系统调用做的事情,无论命中还是未命中,都完全一样:检查参数、走 VFS、要数据、拷回来、返回字节数。

它不知道、也不关心数据是从哪来的。 接下来的实验会用实测数据证明这一点:冷读和热读的系统调用序列,一字不差。

这件事的直接后果是:光看系统调用,你无法判断缓存有没有起作用。 证据必须从别的地方找。

第二章 用什么证明数据来自页缓存

为什么耗时不算证据

先把这个思路排除掉,因为它看起来最直观。

耗时 = 你的代码 + 内核代码 + 内存拷贝 + 磁盘等待 + 调度延迟 + 中断处理 + ……。磁盘等待只是其中一项。当它在总耗时里占比很低时,耗时对「有没有走磁盘」这件事就几乎没有分辨力。

实测数字很能说明问题。同一个 1 GiB 文件,同一段读取代码:

1
2
冷读(缓存被驱逐后)   0.2664 s
热读(紧接着再读一遍) 0.1009 s

只差 2.6 倍。而两者的实际磁盘 I/O 差了多少?1 GiB 对 0。

耗时的比值是 2.6,I/O 的比值是无穷大。 拿 2.6 倍去论证「数据来自缓存」,等于用一把刻度很粗的尺子去量一个精度要求很高的东西。更糟的是,如果换成一个 4 KiB 块的小文件读,冷热耗时只差 1.5 倍(0.3836 s 对 0.2508 s),这个信号几乎淹没在噪声里。

耗时能告诉你「可能有缓存参与」,但给不出「一定」和「有多少」。

四类能直接回答问题的证据

这台机器上没有装 strace、perf、blktrace,而且 sudo 需要密码,所以这些现成工具一个都用不了。但这不影响取证,内核在 /proc 里暴露了足够多的计数器,非特权进程就能读。

证据一:/proc/self/io 的两个计数器

这是最有力的一条。这个文件里有两行关键内容:

1
2
rchar: 2884          <- 进程通过系统调用读写了多少字节(含缓存命中)
read_bytes: 0 <- 进程真正从块设备取了多少字节

两者的语义差别正是问题的答案:

  • rchar 统计走过系统调用的字节数。看着 read() 把数据给你了,它就算数,不关心数据从哪来。
  • read_bytes 统计真正落到块设备上的字节数。只有页缓存没命中、必须去磁盘拿的部分才算。

于是:

这个差值是内核自己记的账,不是推理出来的。缓存有没有替我们干活、替了多少,两个数字一减就出来了。

证据二:mincore() 直接问每一页在不在

/proc/self/io 给的是总量。如果要精确到每一页,可以用 mincore() 系统调用:给它一段映射的地址,它返回一个位图,每一位表示对应页当前是否驻留在内存里。

1
2
unsigned char *vec = calloc(npages, 1);
mincore(mapped_addr, size, vec); // vec[i] 的 bit0 为 1 表示第 i 页驻留

要让 mincore 检查文件的内容页,需要先把文件 mmap 进来。它返回的是「页是否在内存」,这正是缓存命中的定义。

证据三:缺页异常计数

getrusage(RUSAGE_SELF) 里的 ru_minflt / ru_majflt,或者 /proc/self/stat 里的对应字段:

  • 次缺页(minor fault):页已经在内存里,只需要建立页表映射。缓存命中走这个。
  • 主缺页(major fault):页不在内存里,必须去磁盘读。只有这种情况才涉及物理 I/O。

两个计数器分开,正好把「从缓存拿」和「从磁盘拿」区分开了。用两个独立来源交叉验证(getrusage 和 /proc/self/stat)还能排除单一接口统计口径的问题。

证据四:/proc/diskstats 的设备级计数

上面三条都是进程视角。/proc/diskstats 是设备视角,统计整块盘被读了多少个扇区。

读取前后各取一次,差值就是这次操作期间这块盘真实承受的读负载。这条证据的价值在于它不受进程边界限制:就算读操作发生在别的进程、别的线程,甚至内核自己的预读线程里,设备计数照样会涨。

小结:四类证据各管一段

证据 回答的问题 视角
rchar 进程以为自己读了多少 进程 · 系统调用层
read_bytes 进程真的从设备取了多少 进程 · 块设备层
mincore() 具体哪些页现在在内存里 页 · 缓存层
diskstats 整台机器的盘被读了多少 设备 · 硬件层

四者互相独立,交叉起来就能把「数据到底从哪来」钉死,不必依赖任何时间测量。

第三章 冷读与热读的完整对照

实验设置

为了能可靠地制造「冷」,需要能把指定文件踢出页缓存。通常的做法是往 /proc/sys/vm/drop_caches 写 1,但那需要 root,而且会把整台机器的缓存清空,副作用太大。

非 root 条件下有个精确得多的工具:posix_fadvise()。

1
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);

这个调用的意思是「这个文件的这部分,我暂时不需要了,可以丢掉」。它只影响指定的文件区间,不碰别的进程、别的文件。

实测它在 1 GiB 文件上的效果:

1
2
fadvise 前驻留: 262144 / 262144 页
fadvise 后驻留: 0 / 262144 页

用 mincore() 逐页数出来的,一页不剩。「冷」这个前提是被验证过的,不是假设的。

对照结果

测试文件 1 GiB,read() 每次读 128 KiB,每组跑三次取中位数:

场景 耗时 (s) 吞吐 (MB/s) rchar (字节) read_bytes (字节) 设备读 (MB) minflt majflt
冷读 0.2664 3844.3 1073742573 1073741824 1024.0 35 0
热读 0.1009 10147.7 1073742573 0 0.0 35 0
冷读 4K 块 0.3836 2669.3 1073742573 1073741824 1024.0 4 0
热读 4K 块 0.2508 4082.7 1073742573 0 0.0 4 0

图二:冷热对照,rchar 一样,read_bytes 分岔

三次重复的原始耗时(seq_cold_128k):0.2752 / 0.2601 / 0.2664 秒,波动在 5% 以内。

这张表里最重要的一列

看 rchar 那一列:冷读和热读完全一样,都是 1073742573 字节。

这跟第一章那个结论是同一个道理:系统调用层只负责搬数据,不负责记录数据从哪来。进程要求读 1 GiB,read() 就忠实地交付 1 GiB,一次不多一次不少。

分岔在下一列。read_bytes 从 1073741824 掉到 0。

这就是页缓存在干活的确凿证据:进程以为自己读了 1 GiB,而块设备一个字节都没被要求提供。

再看设备级确认。/proc/diskstats 里 sda 的读扇区增量,冷读时是 1024.0 MB,热读时是 0.0 MB。设备自己也是这么说的。

三路证据(进程层 read_bytes、设备层 diskstats、页层 mincore)互相独立,结论一致。

系统调用记录:两遍一模一样

前面两节都是数字。这一节换成直接的调用记录。因为这台机器没有 strace,我用 ptrace 自己写了一个追踪器(第七章给实现)。

追踪一个只做一次 read() 的最小程序,把文件相关的调用挑出来。

热读:

1
2
3
openat     (-100, "data/small.txt", 0x0, 0) = 3
read (3, buf, 4096) = 4096
close (0x3, 0x0, 0x0) = 0

冷读(读取前先驱逐缓存):

1
2
3
4
openat     (-100, "data/small.txt", 0x0, 0) = 3
fadvise64 (3, 0, 0, 4) = 0
read (3, buf, 4096) = 4096
close (0x3, 0x0, 0x0) = 0

两条记录里,read(3, buf, 4096) = 4096 这一行一字不差。唯一的区别是我自己加的那个 fadvise64。

这个对照的价值在于它排除了一个常见的误判路径。有人会想:「冷热读肯定会在系统调用上有区别吧,比如多了个什么调用。」实测告诉你:没有。系统调用这一层自始至终是同一套动作,差别全部发生在它下面。

所以,想通过跟踪系统调用来判断缓存命中,方向本身就是错的。要看得再往下走一层。

连续读取

最后补一个更贴近现实的场景。同一个 1 GiB 文件,连续完整读三遍:

场景 耗时 (s) rchar (字节) read_bytes (字节) 设备读 (MB)
连读 3 次 0.3046 3221226225 0 0.0

rchar 是 3 GiB,三次读取的系统调用全部老老实实走完了,数据也全部拷进了用户缓冲区。而 read_bytes 是 0。

这就是「第二次读取几乎不产生物理磁盘 I/O」这句话的精确版本:不是几乎没有,是完全没有。三遍里只有第一遍的前半段碰了盘,之后全程由页缓存供给。

顺带说明一个容易被误读的点:热读只快了 2.6 倍,不是因为磁盘很慢,而是因为拷贝本身有成本。1 GiB 数据要从内核页拷贝到用户缓冲区,这个 memcpy 再快也要时间。热读的 0.1009 秒里,绝大部分花在这个拷贝上。想省掉它,就得看下一章的 mmap。

第四章 read、mmap、O_DIRECT 的三条路径

三个接口看起来都能「把文件内容弄到进程里」,但数据走的路完全不同。

read():内核替你拷

路径是第一章那张图。数据从页缓存拷贝到用户缓冲区,进程拿到一份副本。

  • 优点:简单、通用,文件描述符语义清晰(带文件位置,可 lseek)。
  • 代价:多一次内存拷贝。1 GiB 数据就要多搬 1 GiB。

mmap():建立映射,靠缺页搬数据

mmap() 不拷贝任何数据。它做的是建立映射:把文件的某段区间和进程虚拟地址空间的某段区间关联起来。

1
char *m = mmap(NULL, size, PROT_READ, MAP_SHARED, fd, 0);

接下来访问 m[i] 时,CPU 发现这个虚拟地址没有对应的物理页,触发缺页异常,内核此时才去页缓存找页、建立页表映射。

数据从头到尾没有被拷贝,进程直接读的是页缓存里的那些物理页。

这个差别在数据上留了非常清晰的指纹:

场景 耗时 (s) 吞吐 (MB/s) rchar (字节) read_bytes (字节) minflt majflt
mmap 冷 0.2152 4759.1 749 1073741824 919 1
mmap 热 0.0051 200666.3 749 0 919 0

rchar 只有 749 字节,而这 749 字节还是程序读取 /proc/self/io 自己造成的。

进程访问了整整 1 GiB 的文件内容,可是一个字节都没走 read()。数据是通过缺页异常进入进程的,绕过了系统调用层的字节计数。

如果不看 rchar 而只看耗时,会很自然地写出「mmap 比 read 快 50 倍」这样的结论。但热读状态下 mmap 比 read 快的是 2 万倍(0.0051 对 0.1009),差了三个数量级。这个量级差异不是「快一点」,而是「省掉了一整次拷贝」。

用 200 GB/s 这个数字反推:1 GiB / 0.0051 s ≈ 200 GB/s,远超任何磁盘、也超过单通道内存带宽。它测的其实只是「遍历一遍已经在缓存里的页」的成本,因为缺页搬页表这件事在第一遍就做完了,第二遍连缺页都不会发生(那 919 次缺页是在第一次触碰时产生的)。

O_DIRECT:主动绕开缓存

第三条路是显式声明「不要缓存」:

1
int fd = open(path, O_RDONLY | O_DIRECT);

O_DIRECT 让读写直接落在块设备上,不经过页缓存。它有两个硬性要求:

  • 缓冲区的地址必须按逻辑块大小对齐(本项目用 posix_memalign(&buf, 4096, size))
  • 读写长度和文件偏移也要对齐

实测:

场景 耗时 (s) 吞吐 (MB/s) rchar (字节) read_bytes (字节) 设备读 (MB)
O_DIRECT 128K 0.7153 1431.6 1073742575 1073741824 1024.0

注意它比冷读还慢(0.7153 对 0.2664)。

这个结果初看反直觉:绕开缓存不是应该更快吗?

原因在于预读。普通 read() 即使缓存没命中,内核也在背后做顺序预读:读当前位置的数据时,顺手把后面一段也读进来,下一次读就直接命中了。等于内核在帮你把延迟藏起来。O_DIRECT 放弃了页缓存,同时也就放弃了预读,每次都得实打实地去设备拿。

O_DIRECT 的价值不在于「更快」,而在于「可控」:它让 I/O 的时机和数量完全由程序决定,不被内核的缓存策略干扰。数据库这类自己管理缓存的软件才需要它。

三条路径的指纹

把三者的差异压缩成一句话:看 rchar 和缺页这两个计数器,就能认出是哪条路。

1
2
3
read()      rchar ≈ 读取量        数据是拷回来的
mmap() rchar ≈ 0 数据靠缺页搬
O_DIRECT rchar ≈ 读取量 但 read_bytes 永远等于读取量,没有任何缓存折扣

read() 和 O_DIRECT 的 rchar 看起来一样,区分它们的是 read_bytes:read() 在热读时能做到 0,O_DIRECT 永远做不到。

第五章 一个意外发现:缺页次数不等于访问页数

这一章是实验过程中撞出来的,不在原计划里,但它值得单独讲,因为它揭示了一个和第一章同一类型的错误:把某个计数器当成它字面上看起来的意思。

问题的起点

mmap 那条路径的缺页计数看着很不对劲。

文件 1 GiB,逐页触碰一遍(每 4 KiB 触一次,共 262144 次)。按理说,每一页第一次访问都要触发一次缺页,那就该有 262144 次缺页。

实测:915 次。

差了 286 倍。第一反应是计数器读错了,于是从两个独立来源各读一遍:

1
2
getrusage minflt: 917
/proc/self/stat : 915

两个来源一致。而且重复跑五次,结果是 915 / 915 / 916 / 916 / 915,波动只有一次。

所以数字是对的,是我对「缺页」的理解错了。

验证一:它和访问顺序有关

如果一次缺页能覆盖多页,那么「覆盖多少页」应该取决于内核能提前准备多少页。而内核提前准备的量,取决于它能不能预测你要访问哪里。

于是做一个对照实验:同一个文件、同一次 mmap、同样逐页触碰,只改访问顺序。

图三:缺页批量粒度与性能对比

文件 访问顺序 页数 缺页次数 每次缺页覆盖
1 GiB 顺序正读 262144 915 286.5 页
1 GiB 顺序倒读 262144 16382 16.0 页
1 GiB 随机 262144 16266 16.1 页
32 MB 顺序正读 8192 418 19.6 页
32 MB 顺序倒读 8192 511 16.0 页

结论很干净:

  • 顺序访问:内核识别出这个模式,提前把后面一大段读进内存,一次缺页可以顺手把几百个页表项都建好。
  • 逆序或随机访问:内核无法预测,退化成固定窗口,每次缺页恰好覆盖 16 页。

那个 16 不是巧合。它是内核 fault_around 机制的默认窗口大小:当一页缺页时,内核会顺便把它周围一片页的映射一起建好,默认窗口是 64 KiB,正好 16 个 4 KiB 页。验证方式是拿小文件测:1 MB 到 16 MB 的文件,无论多大,都精确是 16.0 到 16.5 页:

1
2
3
文件    1.0 MB  页数     256  首次缺页    16  每缺页覆盖  16.0 页
文件 4.0 MB 页数 1024 首次缺页 63 每缺页覆盖 16.3 页
文件 16.0 MB 页数 4096 首次缺页 255 每缺页覆盖 16.1 页

16 × 16 = 256,64 × 16 = 1024,255 × 16 = 4080,都和页数对得上。

验证二:排除大页的干扰

顺序访问那个 286 页的批量很大,是一个自然的怀疑对象:是不是透明大页(THP)在起作用?

这台机器的 THP 是开着的(/sys/kernel/mm/transparent_hugepage/enabled = [always]),而且 smaps 里确实能看到大页映射:

1
FilePmdMapped:   1007616 kB      ← 984 MB 的文件内容是用 2 MB 大页映射的

那就做个对照,显式关掉这个映射的大页:

1
madvise(m, size, MADV_NOHUGEPAGE);

结果:

1
2
不关大页:  缺页 915 / 916 / 915
关掉大页: 缺页 915 / 916 / 915

一模一样,一次都没变。

所以 286 页这个批量不是大页造成的。FilePmdMapped 那 984 MB 是另一回事(它统计的是页表用了多大的粒度),和缺页次数没有因果关系。

正确的解释

把两条验证合起来:

  • 批量大小随访问顺序变化(顺序 286 页,逆序/随机 16 页)
  • 关掉大页不改变任何结果

那么批量的来源只能是预读窗口:顺序访问时预读一直往前跑,页缓存里常驻着一大段数据;缺页时内核发现周围这些页已经在内存里,就一次性把它们的映射全建好,不用一页一页来。

这个批量的上限是动态的,取决于内核当前把预读窗口开到了多大。 它不是一个文件属性,也不是一个固定常数。

这条发现的实用价值

它推翻的是一个很常见的性能分析习惯:

「这个程序缺页很多,说明它在大量访问没缓存的数据。」

从前面的数据看,这句话在两个方向上都可能错:

  • 缺页多不代表访问的页多。16266 次缺页对应的是 262144 页,比例是 1:16。
  • 反过来,缺页少也不代表访问少。915 次缺页对应的是整整 1 GiB 数据,每一次缺页背后是 286 页的真实工作。

正确读法是:缺页次数衡量的是「内核为你做了多少次映射工作」,不是「你访问了多少数据」。 想知道后者,得看 rchar 或者别的计数器。

同样的坑在 majflt 上也存在。这次实验里 majflt 几乎全程是 0,即使在做冷读、真的从磁盘拿数据的时候。原因类似:顺序读取时,内核的预读把数据批量读进来,缺页发生时数据往往已经在内存里了,于是记为次缺页。majflt = 0 不代表没有磁盘 I/O。

第六章 证据能推出什么,推不出什么

前面几章给了不少结论。这一章专门讲这些结论的边界,哪些是证据支持的,哪些是推断,哪些根本推不出来。

这一节和前面一样重要。一份只有结论没有边界的分析,读者照着用到别处就会翻车。

能确定推出的

其一:页缓存确实替进程承担了全部读取工作。

三路独立证据同时指向这个结论:

  • 进程层:read_bytes = 0(内核记录「这个进程没有从块设备取字节」)
  • 页层:mincore() 报告 262144/262144 页驻留
  • 设备层:diskstats 的 sda 读扇区增量为 0

这三条分别由不同的内核子系统维护,出错方向各不相同。三者一致,结论可靠。

其二:read() 的耗时差异确实来自缓存层,而不是别的地方。

论证方式:冷读和热读的系统调用序列逐字相同(第三章的 trace 对照)。既然系统调用这一层没有任何差别,差异只能发生在它下面;而下面各层里,唯一被独立证据证明发生了状态变化的就是页缓存。

这是一条排除法论证,但它比单纯的「耗时短所以是缓存」强得多,因为它先排除了同一层内的其他解释。

其三:mmap 和 read 的差异是结构性的,不是性能调优级别的。

rchar 从 1073742573 掉到 749,差了六个数量级。这不是「快一点」,而是数据根本没有流经 read() 这条路径。这个差异由接口设计决定,换机器、换文件系统都不会消失。

不能推出的

其一:rchar - read_bytes 并不总是「缓存省下的字节」。

这是最容易踩的一个坑,因为第二章那个公式看起来很自然。实测一下就会发现它会崩:

只读 4 KiB,看这次 read() 造成的增量:

1
2
rchar      +4189
read_bytes +16384 ← 比要的还多!

read_bytes 比 rchar 大,差值是个负数。放大倍数分别是:读 4 KiB 时 3.9 倍,读 64 KiB 时 4.0 倍,读 1 MiB 时 2.0 倍。

原因就是预读。你只要 4 KiB,内核顺手读了 16 KiB。

连续读 8 次 4 KiB 更能看清这个过程(每次都只读 4 KiB):

1
2
3
4
5
6
7
8
第1次: read_bytes +16384      窗口开了 16 KiB
第2次: read_bytes +65536 预读窗口涨到 64 KiB
第3次: read_bytes +0 命中上一轮预读
第4次: read_bytes +0
第5次: read_bytes +245760 窗口涨到 240 KiB
第6次: read_bytes +0
第7次: read_bytes +0
第8次: read_bytes +0

八次要了 32 KiB,设备上实际读走 320 KiB。预读是自适应的:命中一次就加大窗口,所以增量是一整块一整块地来的,并不均匀。

所以那个公式要加个前提:rchar - read_bytes 表示「净缓存收益」,只在读完整个文件(或稳态连续读取)时才等于「省下的字节」。 局部小读时它可能是负数,因为内核在为未来投资。

其二:这台虚拟机的「物理读」不是真的物理读。

这是虚拟化环境特有的坑,而且很隐蔽。

实验里有一组数据我看第一遍时觉得不对:用 O_DIRECT 读一个刚写完的文件,测出 3.3 GB/s。这个速度远超这台虚拟机的磁盘能力。

原因在于:虚拟机的磁盘是宿主机上的一个文件。 虚拟机里的「块设备读」,到了宿主机上可能只是一次内存拷贝,因为宿主机自己也有一层页缓存。

后果是:在这个环境里测出的「冷读」速度,反映的是宿主机的缓存状态,不是真实磁盘的性能。

这不影响本文关于机制的结论(页缓存命中与否、rchar 与 read_bytes 的语义、mmap 不走 read(),这些都是客户机内核的行为,与后端是不是虚拟磁盘无关)。但只要涉及绝对性能数字,就必须注明环境。本文表里的 MB/s 都只在这台机器、这个 KVM 配置下成立。

其三:diskstats 的增量归属不到具体进程。

/proc/diskstats 是设备级计数。本次实验期间没有别的负载,所以可以认为增量全部来自被测进程。但在有并发 I/O 的机器上,这个增量是所有进程共享的,不能直接归给某一个。

需要进程级归属时得换工具,iotop、bpftrace 的 biolatency/iosnoop,或者 cgroup 的 io.stat。它们在这台机器上要么没装、要么需要 root。

其四:精确的 folio 粒度无法确定。

第五章确认了批量来自预读窗口而不是大页,但没有测出内核实际用的是多大的 folio。能查到 /proc/meminfo 的 FileHugePages: 1189888 kB(说明这个文件确实被多页 folio 缓存着),但要读具体的 folio 阶数需要 debugfs,而这台机器上 debugfs 没挂载、也拿不到 root。

所以第五章给出的结论精确到「批量由预读窗口决定」为止,再往下就没有证据支撑了。

边界汇总

结论 证据强度 边界
页缓存承担了全部读取 强(三路独立) 无
冷热差异来自缓存层 强(排除法 + 直接计数) 无
mmap 不走 read() 强(rchar 六个数量级差) 无
缺页批量由预读窗口决定 中(顺序对照 + 大页排除) folio 阶数未测出
具体吞吐数字 弱(单机单次) 仅本环境成立,宿主缓存有干扰
diskstats 归因 中 需无并发负载才成立

有一点想说明:把「中」和「弱」的结论也列出来、并写清边界,不会削弱文章的说服力。相反,它让「强」那几条更可信,因为读者知道作者是真的在区分,而不是把所有结论都写成同等肯定。

第七章 实现:把分析沉淀成工具

这一章把前面用的东西固化成可复用的工具。总共三件,都是单文件 C,gcc 直接编译,不需要 root,不需要装任何依赖。

这个前提本身就值得说一句:这台机器上 strace、perf、blktrace 一个都没装,sudo 也要密码。 如果分析能力依赖于「先装一套工具」,那么在受限环境里就什么都做不了。而内核在 /proc 和系统调用层面暴露的接口,非特权进程就能用,缺的只是一个把它们串起来的程序。

工具一:minitrace,自己写的最小追踪器

ptrace 是这个工具的支点。它本来是给调试器用的:让一个进程停下来,另一个进程检查它的寄存器和内存。追踪自己 fork 出来的子进程不需要任何特权。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
/*  @Moxin1044 2026-10-15
minitrace , 精简版系统调用追踪器(x86_64)

核心就三件事:
1. fork + PTRACE_TRACEME + execve,让子进程一出生就被追;
2. PTRACE_SYSCALL 让子进程跑到「进系统调用」和「出系统调用」时各停一次;
3. 停两次配成一个完整的调用:第一次在寄存器里取编号和参数,
第二次从 rax 取返回值。

编译:gcc -O2 -o minitrace minitrace.c
用法:./minitrace <程序> [参数...]
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <string.h>
#include <unistd.h>
#include <errno.h>
#include <sys/ptrace.h>
#include <sys/wait.h>
#include <sys/user.h>

/* 从被追踪进程的内存里读一个以 \0 结尾的字符串。
PTRACE_PEEKDATA 一次只能取一个机器字,所以要逐字节判断有没有到结尾。 */
static void read_string(pid_t pid, unsigned long addr, char *buf, size_t n) {
for (size_t i = 0; i < n - 1; i += sizeof(long)) {
errno = 0;
long w = ptrace(PTRACE_PEEKDATA, pid, addr + i, NULL);
if (errno) break;
memcpy(buf + i, &w, sizeof(long));
if (memchr(&w, '\0', sizeof(long))) break;
}
buf[n - 1] = '\0';
}

/* x86_64 上这几个编号足够看清文件 I/O 的主干 */
static const char *name_of(long n) {
switch (n) {
case 0: return "read"; case 1: return "write";
case 2: return "open"; case 3: return "close";
case 8: return "lseek"; case 9: return "mmap";
case 11: return "munmap"; case 17: return "pread64";
case 27: return "mincore"; case 217: return "getdents64";
case 221: return "fadvise64"; case 257: return "openat";
case 275: return "splice"; case 326: return "copy_file_range";
case 332: return "statx";
default: return NULL;
}
}

int main(int argc, char **argv) {
if (argc < 2) { fprintf(stderr, "用法: %s <程序> [参数...]\n", argv[0]); return 2; }

pid_t pid = fork();
if (pid < 0) { perror("fork"); return 1; }
if (pid == 0) { /* 子进程 */
ptrace(PTRACE_TRACEME, 0, 0, 0); /* 声明「我要被父进程追」 */
execvp(argv[1], &argv[1]); /* execve 会被父进程拦下 */
_exit(127);
}

int status;
waitpid(pid, &status, 0); /* 等 execve 停住 */
/* 关键选项:让系统调用产生的停止带上 0x80 标志,
这样就能和普通信号引发的停止区分开 */
ptrace(PTRACE_SETOPTIONS, pid, 0, (void *)PTRACE_O_TRACESYSGOOD);

long nr = -1;
unsigned long a0 = 0, a1 = 0, a2 = 0;
int entering = 1;
char path[512];

for (;;) {
if (ptrace(PTRACE_SYSCALL, pid, 0, 0) < 0) break; /* 放行到下一个停止点 */
if (waitpid(pid, &status, 0) < 0) break;
if (WIFEXITED(status)) break;
if (WSTOPSIG(status) != (SIGTRAP | 0x80)) continue; /* 不是系统调用,跳过 */

struct user_regs_struct r;
ptrace(PTRACE_GETREGS, pid, 0, &r);

if (entering) { /* 进入:rdi/rsi/rdx 是前三个参数 */
nr = (long)r.orig_rax;
a0 = r.rdi; a1 = r.rsi; a2 = r.rdx;
} else { /* 退出:rax 是返回值 */
const char *nm = name_of(nr);
if (nm) {
long ret = (long)r.rax;
if (!strcmp(nm, "read") || !strcmp(nm, "pread64"))
fprintf(stderr, "%-11s(%d, buf, %lu) = %ld\n", nm, (int)a0, a2, ret);
else if (!strcmp(nm, "openat")) {
read_string(pid, a1, path, sizeof(path)); /* 第二个参数是路径 */
fprintf(stderr, "%-11s(%d, \"%s\", 0x%lx, 0) = %ld\n",
nm, (int)a0, path, a2, ret);
}
else
fprintf(stderr, "%-11s(0x%lx, ...) = %ld\n", nm, a2, ret);
}
nr = -1;
}
entering = !entering; /* 进 / 出 交替 */
}
return 0;
}

三个关键点:

PTRACE_O_TRACESYSGOOD。 不加这个选项,系统调用停止和普通信号停止在 waitpid 返回值里长得一样,追踪器会被信号干扰得一团乱。加上之后系统调用停止会多一个 0x80 位,一个判断就能滤干净。

进 / 出交替。 PTRACE_SYSCALL 每放行一次,子进程就在下一个系统调用边界停住。停奇数次数是「进入」,偶数是「退出」。参数只在进入时有效,返回值只在退出时有效,这个交替是追踪器最容易写错的地方。

读字符串要分块。 要打印 openat 的路径名,得从子进程内存里读。PTRACE_PEEKDATA 一次只能读一个机器字,所以得循环读、逐块查 \0。本文的精简版为了可读性把这块省略了,直接打印参数值。

这个工具立刻给了一个反直觉的结果

拿它去追 cat,本来想看一次「经典的文件读取」,结果看到的是:

1
2
3
4
5
6
openat     (AT_FDCWD, ..., 0x0) = 3
fadvise64 (0x0, ...) = 0
splice (0x5, ...) = 262144
splice (0x1, ...) = 262144
splice (0x5, ...) = 0
close (0x0, ...) = 0

没有 read。 一个都没有。

现代 coreutils 的 cat 在检测到「文件到文件」的拷贝时,走的是 splice(),一个零拷贝接口。数据从文件描述符直接搬到管道,再搬到标准输出,中间根本不进入用户态缓冲区,所以也不需要 read()。

这个发现有两层意义:

  • 具体层面:「读文件」这件事不必然经过 read()。 想看真实的读取路径,得用自己控制的程序,不能拿 cat 当样本。
  • 抽象层面:这也是本文反复出现的那类错误的又一个实例:工具的名字和它实际调用的系统调用之间,没有必然联系。 只靠猜是猜不到的,必须测。

工具二:cacheproof,一次读取走没走缓存

第二个工具把第二章的四类证据打包在一起,一条命令给出结论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
/*  @Moxin1044 2026-10-15
cacheproof , 判断一次读取到底走没走页缓存的最小工具

输出四项互相独立的证据:
rchar / read_bytes , 进程视角(前者含缓存命中,后者只算真去设备的)
minflt / majflt , 缺页视角
mincore 驻留页数 , 页缓存视角
diskstats 增量 , 设备视角

编译:gcc -O2 -o cacheproof cacheproof.c
用法:./cacheproof <文件> [--cold]
--cold 会先用 posix_fadvise(DONTNEED) 把该文件踢出页缓存
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <fcntl.h>
#include <errno.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <sys/resource.h>

static void io_now(unsigned long long *rchar, unsigned long long *rbytes) {
FILE *f = fopen("/proc/self/io", "r");
char k[64]; unsigned long long v;
*rchar = *rbytes = 0;
while (f && fscanf(f, "%63s %llu", k, &v) == 2) {
if (!strcmp(k, "rchar:")) *rchar = v;
if (!strcmp(k, "read_bytes:")) *rbytes = v;
}
if (f) fclose(f);
}

static unsigned long long disk_read_sectors(void) {
FILE *f = fopen("/proc/diskstats", "r");
if (!f) return 0;
char line[512]; unsigned long long total = 0;
while (fgets(line, sizeof(line), f)) {
int maj, min; char dev[64]; unsigned long long sectors;
if (sscanf(line, "%d %d %63s %*u %*u %llu", &maj, &min, dev, &sectors) == 4
&& maj == 8 && min == 0) /* sda 整盘;换机器要改这两个号 */
total += sectors;
}
fclose(f);
return total;
}

static void resident_pages(const char *path, long long *res, long long *tot) {
int fd = open(path, O_RDONLY);
struct stat st; *res = *tot = 0;
if (fd < 0 || fstat(fd, &st) < 0) { if (fd >= 0) close(fd); return; }
long ps = sysconf(_SC_PAGESIZE);
long long n = (st.st_size + ps - 1) / ps;
void *m = mmap(NULL, (size_t)n * ps, PROT_READ, MAP_SHARED, fd, 0);
unsigned char *v = calloc(n, 1);
if (m != MAP_FAILED && v && mincore(m, (size_t)n * ps, v) == 0) {
*tot = n;
for (long long i = 0; i < n; ++i) if (v[i] & 1) ++*res;
}
free(v);
if (m != MAP_FAILED) munmap(m, (size_t)n * ps);
close(fd);
}

int main(int argc, char **argv) {
if (argc < 2) { fprintf(stderr, "用法: %s <文件> [--cold]\n", argv[0]); return 2; }
const char *path = argv[1];
int cold = (argc > 2 && !strcmp(argv[2], "--cold"));

int fd = open(path, O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
if (cold) {
posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
long long r, t; resident_pages(path, &r, &t);
printf("冷启动确认:%lld/%lld 页驻留\n", r, t);
}

long long res0, tot; resident_pages(path, &res0, &tot);
unsigned long long rc0, rb0; io_now(&rc0, &rb0);
unsigned long long d0 = disk_read_sectors();
struct rusage u0, u1; getrusage(RUSAGE_SELF, &u0);

/* 用 read() 把整个文件读一遍 */
size_t chunk = 128 * 1024;
char *buf = malloc(chunk);
long long total = 0; ssize_t n;
while ((n = read(fd, buf, chunk)) > 0) total += n;

getrusage(RUSAGE_SELF, &u1);
unsigned long long rc1, rb1; io_now(&rc1, &rb1);
unsigned long long d1 = disk_read_sectors();
long long res1; resident_pages(path, &res1, &tot);

printf("读取字节 : %lld\n", total);
printf("rchar : %llu\n", rc1 - rc0);
printf("read_bytes : %llu <- 真去了块设备的字节\n", rb1 - rb0);
printf("设备读扇区增量 : %llu (= %.1f MB)\n", d1 - d0, (d1 - d0) / 2048.0);
printf("缺页 : minflt=%ld majflt=%ld\n",
u1.ru_minflt - u0.ru_minflt, u1.ru_majflt - u0.ru_majflt);
printf("缓存驻留 : %lld -> %lld / %lld 页\n", res0, res1, tot);

printf("\n判读:");
if (rb1 - rb0 < (unsigned long long)(total / 100))
printf("read_bytes 接近 0,数据全部来自页缓存。\n");
else
printf("read_bytes 约等于读取量,数据是从块设备真读上来的。\n");

close(fd); free(buf);
return 0;
}

在一个 1 GiB 文件上的实际输出:

冷读(--cold,先驱逐):

1
2
3
4
5
6
7
8
9
冷启动确认:0/262144 页驻留
读取字节 : 1073741824
rchar : 1073742247
read_bytes : 1073741824 <- 真去了块设备的字节
设备读扇区增量 : 2097152 (= 1024.0 MB)
缺页 : minflt=0 majflt=0
缓存驻留 : 0 -> 262144 / 262144 页

判读:read_bytes 约等于读取量,数据是从块设备真读上来的。

热读(紧接着再跑一次):

1
2
3
4
5
6
7
8
读取字节        : 1073741824
rchar : 1073742247
read_bytes : 0 <- 真去了块设备的字节
设备读扇区增量 : 0 (= 0.0 MB)
缺页 : minflt=32 majflt=0
缓存驻留 : 262144 -> 262144 / 262144 页

判读:read_bytes 接近 0,数据全部来自页缓存。

注意冷读那次的 minflt=0 majflt=0,一次缺页都没有。但这不代表没读数据,那 1 GiB 确实从磁盘上来了(read_bytes 和 diskstats 都能证明)。原因就是第五章那个发现:用 read() 而不是 mmap() 时,数据由内核拷贝到用户缓冲区,页缓存的填充发生在内核自己的地址空间里,不经过进程的缺页异常。

这一条再一次说明:任何单一的计数器都可能给出误导性的信号,必须交叉验证。

工具三:readbench

第三个工具是把前面的测量组织成可对照的实验台:同一个文件跑 read() / mmap / O_DIRECT 三条路径,每组重复三次取中位数,输出 JSON 方便二次分析。它复用的就是 cacheproof 里的那几个采集函数,加上 posix_memalign 对齐缓冲区(O_DIRECT 的硬要求)和不同的访问循环。

三件套的分工

工具 回答的问题 关键手段
minitrace 它到底调了哪些系统调用 ptrace 单步拦截
cacheproof 这一次读取走没走缓存 四路计数器交叉验证
readbench 三条路径的差异有多大 受控对照 + 重复取中位数

三件事合起来就是一套完整的取证链:先看它调了什么,再验证数据从哪来,最后量化差异。

第八章 一套可复用的诊断流程

把前面的方法整理成可以照着走的步骤。

第一步:先问清楚要回答哪个问题

这一步经常被跳过,结果是忙了半天没答到点上。三个问题对应三套完全不同的做法:

  • 「它调了哪些系统调用?」 → 用 minitrace 这类追踪器。
  • 「数据是从缓存还是磁盘来的?」 → 用 cacheproof 这类计数器工具。
  • 「慢在哪里?」 → 这个最难,需要时间维度,得用采样类工具(perf)或分阶段插桩。

第二步:把环境钉住

任何性能结论都必须带上环境。至少要记录:

1
2
3
4
5
6
7
内核版本          uname -r
文件系统 df -T <路径>
设备与你关系的 lsblk / /proc/diskstats 里的主次设备号
文件大小 stat -c%s
内存总量 /proc/meminfo 的 MemTotal
是否虚拟机 systemd-detect-virt
缓存状态 用 mincore 在实验前后各测一次

缺少任何一项,结论就只能在这个环境里成立,换台机器不保证复现。本文所有吞吐数字都只属于文末那份环境清单。

第三步:确保「冷」是真的冷

不要假设缓存没了,要验证:

1
2
用 posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED) 驱逐
再用 mincore() 逐页确认驻留数为 0

看到 0/262144 页驻留 才算冷。如果 mincore 还报有页驻留,说明驱逐失败(比如文件在别的挂载点上,或者有别的进程刚把它读回来了),这时的「冷热对比」是无效的。

第四步:用计数器交叉验证,不要只看耗时

至少取两项独立证据。推荐组合是 read_bytes(进程视角)加 diskstats(设备视角),两者由不同子系统维护,同时出错的可能性极低。

耗时只作为辅助参考。 本实验里冷热耗时只差 2.6 倍,而 I/O 量差到无穷大。耗时对「走没走缓存」这个问题的分辨力很弱。

第五步:对不上就先怀疑自己的理解

第五章那个「缺页 915 次对 262144 页」的异常,第一反应应该是「我这个计数器理解错了」,而不是「数据有问题」。做法是:

  1. 换一个独立来源读同样的量(getrusage 对 /proc/self/stat)
  2. 重复几次看稳定性
  3. 做对照实验找出因果(改访问顺序、关大页)
  4. 只有前三条都过了,才承认这个反直觉的数字是对的

这次流程走完,得到的是一个真结论(批量由预读窗口决定),而不是一个被误读的数字。

第六步:写清边界

结论分三档写:证据强的、证据中等的、单机观察的。前面第六章那张表就是模板。

把边界写出来反而提升可信度,因为读者能看出作者在区分「测出来的」和「推出来的」。

一个快速决策清单

1
2
3
4
5
6
7
8
9
10
要追系统调用?
→ minitrace(自写 ptrace 追踪器),或装 strace
要判断数据来源?
→ cacheproof:rchar / read_bytes / mincore / diskstats 四项一起看
要对比不同 I/O 接口?
→ 同一文件、同样块大小、每组三次取中位数,别只跑一次
要定位慢在哪?
→ 先分阶段插桩测时间,再上采样工具
结果反直觉?
→ 先怀疑自己的理解,做对照实验,换独立来源交叉验证

附录 A:实验环境

本文所有数据来自这台机器,一次完整的实验记录:

1
2
3
4
5
6
7
8
9
10
11
12
内核版本        Linux 7.2.7-arch1-1
发行版 Arch Linux
虚拟化 KVM(QEMU,Standard PC i440FX + PIIX)
CPU Intel Core i5-4590T @ 2.00GHz,4 核
内存 5.8 GiB(实验时 cached 约 2.7 GiB)
根文件系统 ext4,挂在 /dev/sda2
测试文件 1 GiB,位于 /home/moxin/.../data/big.bin
块设备 sda(主设备号 8,次设备号 0),扇区 512 字节
页大小 4096 字节
透明大页 /sys/kernel/mm/transparent_hugepage/enabled = [always]
权限 普通用户,无 sudo
缺失的工具 strace / perf / blktrace / bpftrace / iotop

重要提示:big.bin 不能放在 /tmp,这台机器的 /tmp 是 tmpfs,也就是内存文件系统,在上面测不出任何块设备 I/O,读多少次 read_bytes 都是 0。这个坑很隐蔽,因为一切看起来都正常。

几个必须跟着结论一起讲的限制

宿主缓存。 这是虚拟机的固有干扰。虚拟机的「虚拟磁盘」是宿主机上的文件,客户机发出的「块设备读」在宿主机上可能只命中宿主机的页缓存。实测证据:用 O_DIRECT 读一个刚写完的文件,跑出 3.3 GB/s,远超这台虚拟机的磁盘能力,说明数据实际来自宿主内存。

因此:机制层面的结论不受影响(那是客户机内核的行为),但绝对性能数字只在这个环境下成立。

无法完全排除的干扰。 实验期间没有刻意制造并发负载,也没有做 CPU 绑核或频率固定,所以表里的 MB/s 含有一定噪声。三次重复的耗时波动在 5% 以内,这个量级的变化不影响任何一条结论的方向。

没有 root 的代价。 perf、blktrace、ftrace、debugfs 都不可用,所以拿不到块层的详细统计、也读不到 fault_around_bytes 的实际值和 folio 的阶数。第五章的结论到「预读窗口决定批量」为止,再往下没有证据。

附录 B:复现步骤

全部命令不需要 root:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
# 1. 准备测试文件。注意不能放 /tmp(那是 tmpfs)
mkdir -p /path/on/real/fs && cd /path/on/real/fs
dd if=/dev/zero of=data/big.bin bs=1M count=1024 conv=fdatasync
sync

# 2. 编译三个工具
gcc -O2 -Wall -o minitrace minitrace.c
gcc -O2 -Wall -o cacheproof cacheproof.c
gcc -O2 -Wall -o readbench readbench.c

# 3. 看一次 read() 的真实系统调用序列
./minitrace ./oneread data/small.txt 4096

# 4. 冷读 / 热读的证据对照(关键:--cold 会验证驱逐生效)
./cacheproof data/big.bin --cold
./cacheproof data/big.bin

# 5. 三条路径的完整对照,每组三次
for i in 1 2 3; do ./readbench data/big.bin seq 128 --fadvise --json; done
for i in 1 2 3; do ./readbench data/big.bin seq 128 --json; done
for i in 1 2 3; do ./readbench data/big.bin mmap --fadvise --json; done
for i in 1 2 3; do ./readbench data/big.bin mmap --json; done
for i in 1 2 3; do ./readbench data/big.bin odirect 128 --json; done

# 6. 连续读三遍,看 read_bytes 是否归零
./readbench data/big.bin reopen 128 --times 3 --json

复现时最值得自己动手改的两处:把测试文件换成你实际在意的那个,以及把 disk_read_sectors() 里 maj == 8 && min == 0 换成你这台机器根文件系统所在设备的主次设备号(lsblk -o NAME,MAJ:MIN 可以查)。不改这一处,设备级证据会一直是 0。

参考资料

  1. man 2 read / man 2 mmap / man 2 open:三个接口的语义、返回值、错误码,以及 O_DIRECT 的对齐要求。

  2. man 2 posix_fadvise:POSIX_FADV_DONTNEED 的确切语义:只影响指定文件的指定区间,不干扰其他进程。这是非 root 条件下制造「冷读」的关键依据。

  3. man 2 mincore:逐页查询驻留状态,本文页缓存层证据的来源。

  4. man 2 ptrace 与 PTRACE_O_TRACESYSGOOD 的说明:追踪器实现的依据;0x80 标志位的用法在这里有正式定义。

  5. Linux 内核文档 Documentation/filesystems/proc.rst:/proc/self/io 各字段的正式定义,特别是 rchar(含缓存命中)与 read_bytes(只计块设备)的区别,这是本文全部证据链的起点。

  6. Linux 内核文档 Documentation/admin-guide/blockdev/stat.rst:/proc/diskstats 的字段含义与单位(扇区数,512 字节)。

  7. man 5 proc:/proc/diskstats 与 /proc/stat 的字段布局;本文 disk_read_sectors() 解析的就是其中的第 6 个字段。

  8. coreutils 的 cat 实现:splice 路径的引入背景。本文是用 minitrace 实测到 splice 而非 read 的,源码可作为对照确认。

文中所有数据均为本机实测;三个工具的源码在文末附录给出,可自行编译复现。