一位开发者花了两个星期研究 Linux 读文件时的实际行为,发现内核在大量场景下并非机械执行指令,而是在猜测程序意图。他从冷文件起始位置读取一个 4 KiB 页,内核却返回了四页;把读取位置往后移一页再试,只返回一页。同样的文件、同样的系统调用,差别仅在于起始偏移量。
偏移量为零时,mm/readahead.c 中的分支会直接进入 initial_readahead,内核默认程序要顺序流式读取整个文件,于是立即预取更多数据。从其他位置开始读则被假定为随机访问,直到访问模式证明并非如此。内核完全从偏移量推断意图,调用本身不携带任何意图信息。
测试环境是 Apple Silicon Mac 上的 OrbStack Linux 虚拟机,loop 设备上的 ext4 文件系统,内核 7.0.14,4 KiB 页,read_ahead_kb 为 128。容器共享宿主机内核,宿主机内存回收激进,完全缓存的文件约十五秒就会变冷。读取用 dd 完成,逐页统计靠一个调用 mmap 和 mincore() 的小型 C 工具实现。
read() 并不直接访问磁盘,它从页缓存拷贝字节到用户缓冲区。页缓存是内核用来记忆文件内容的 RAM,目标数据已在缓存中就不涉及设备;不在则先填充缓存再拷贝。程序对话的对象始终是内存,磁盘是更下一层的问题。清空缓存后读一页,四页驻留;什么都不读再统计,归零。
内核需要从设备取数据时,面临调用从未回答的问题:该取多少?只取请求的精确字节数看似诚实但通常错误,内核假设读一个块的程序会想要下一个块。从冷状态逐页顺序读,预读窗口依次打开为四页、十二页、十二页、十二页、三十一页、三十一页,然后停止。配置上限应为三十二页(128 KiB 除以 4 KiB),实测两次都是三十一页,作者尚未查明缺失的那一页。
预读窗口能跨独立进程增长,靠的是 try_context_readahead() 向后扫描页缓存中顺序读者留下的痕迹。缓存是共享的,访问模式会在互不知情的进程间泄漏,一个程序的读取会改变另一个程序的读取形态。
mmap 与 read() 是进入同一页缓存的两扇门,行为却不同。触碰一个映射页带回三十二页,等价的 read() 只带回四页;且窗口以触碰页为中心展开——触发第 100 页的缺页,得到第 84 到 115 页。read() 携带长度,内核能推断方向;缺页不携带任何信息,对称展开猜测是信息不足时的最优下注。
文件系统布局本身也是一种猜测。4 MiB 文件在 ext4 上逻辑大小与分配块完全一致,在 ext2 上则多花 4096 字节。ext2 inode 持有十二个直接块指针后回退到间接寻址,需要一层间接块;ext4 改用 extent 记录连续块区间,几条小记录覆盖整个文件,开销被舍入掉。ext2 的开销按单间接、双间接、三间接分层增长。
最困扰作者的是 major fault 计数器。从冷文件读 8 MiB 后检查进程 major fault,首次测得十八次——这是 Python 解释器自身页面被清出缓存后重新加载所致,与本次读取无关。在单进程内读取前后分别采样,差值为零。八 MiB 真实 I/O 未让计数器移动分毫。major fault 只在 MMU 找不到映射且内核需从存储填充时发生,普通 read() 根本不走这条路,ru_majflt 天然对此盲视。平坦的 major fault 曲线无法判断服务是否 I/O 密集。真正会动的数字在 /proc/<pid>/io 的 read_bytes 和 iostat -x 1。
程序与数据之间的每一层都在未经询问的情况下用某种保证换取速度。这些交易几乎总是正确,正因如此,人们很难建立对例外形态的直觉。文件读取负载在调整数据遍历顺序后变快,通常是内核预测开始命中,而非系统调用或缓冲区大小的问题。这也解释了 tokio::fs 为何是套着异步接口的线程池——对文件询问 readiness 轮询本就是错误问题。自内核 5.9 起可挂载 wait_page_queue 回调并从 task work 重试读取,io_uring 提供真正的异步提交路径,线程池如今更多是兼容性决策而非硬限制。
#开发者 #工具 #Linux #内核 #页缓存 #预读 #ext4 #ext2 #mmap #io_uring
@DevToolboxHub