Page Cache 与 I/O:缓存、写回和持久化

本页目录

写入几秒很快,持续写入却降速;write 成功之后,fsync 仍可能报错。Page Cache 既保存内容,也协调并发填充、脏页预算和完成边界⁠。

要解释的现象阅读位置
多个 fd 是否各有一份缓存?第 1 节:文件偏移、XArray 与 folio
命中缓存为何仍要等?第 2~3 节:填充与写回
缓存为何撑不住持续高速写入?第 4~5 节:节流与完成语义
mmap、直接 I/O 改变哪条路径?第 6~7 节:接口与实验

1. 缓存以文件内容组织,不按每个 fd 复制

常规文件的 inode 通常通过 i_mapping 关联 address_space;其中 i_pages 用 XArray 索引缓存内容。名字不同但指向同一 inode 的硬链接,以及独立 open 得到的多个 fd,通常共享这份内容缓存。address_space 不是进程的虚拟地址空间 mm_struct。

手算:文件偏移 10000 落在哪个索引

假设 PAGE_SIZE=4096 字节,10000 = 2 × 4096 + 1808:缓存索引为 2,页内偏移为 1808。

索引单位是基础页大小,不是文件系统块、设备逻辑扇区,也不是 folio 个数。基础页大小仍依架构与内核配置而定。

为什么引入 folio

folio 表示一组按整体管理、物理连续的基础页,大小为基础页的二次幂倍数,可以只有一页。明确“这里操作的是整组页”能减少把 compound page 尾页误当独立对象的歧义,也有助于批量管理缓存。

大 folio 不意味着所有文件都使用 2 MiB 页,也不意味着文件系统无需关心大小。分配能力、对齐、文件系统支持、读写有效范围、回收和拆分都会影响实际选择。一个跨多个基础页的 folio 仍按文件页索引覆盖相应范围,不能把索引改算成“第几个 folio”。

XArray 支持多索引条目、标记和并发查找,但查到的条目不一定是普通指针,也可能是 shadow/swap 等值条目。它的内部同步不自动解决所存对象的寿命或文件范围锁定;页缓存代码应使用相应 filemap/folio 接口,不能直接 xa_store() 一个指针就认为已完成插入、引用和记账。见 XArray 文档。

2. 读路径:存在于缓存不等于数据已可用

一次缓冲读取需要定位请求范围、取得有效引用、确认对应内容就绪,才能复制到用户缓冲区。若 folio 正由另一请求填充,读取者可能等待;缓存命中因此不保证零等待。文件系统可能按块追踪部分有效状态,整 folio 的 uptodate 与某一范围已有效也要区分。

多个请求读取同一未缓存范围时,需要围绕同一份填充状态协调:

  1. 建立候选。 分配并尝试插入锁定的 folio,处理并发插入竞争。
  2. 推进填充。 发起后端读取,其他请求可等待正在填充的对象。
  3. 发布结果。 I/O 完成后更新有效状态、解锁并唤醒等待者。

若各请求在缓存外独立读完,最后再无条件插入,会难以避免重复填充。实际路径还要处理截断与 I/O 错误。

后端不必产生 bio:本地块文件系统可能通过块层,NFS 使用网络请求,tmpfs 的后备方式又不同。file_accessed(file) 与访问时间相关,不能写成接收 folio 并更新 LRU 的缓存访问标记函数。

预读是在赌下一次访问

顺序访问适合提前读后面的范围,让后端 I/O 与应用计算重叠。随机跳转或工作集远大于内存时,过量预读可能浪费带宽并挤走有用缓存。

“同步预读”通常指当前缓存缺失触发的预读路径;“异步预读”常由消费到预读窗口中的触发标记来推进窗口。这里的命名不表示同步路径必须等待整个预读窗口完成,也不等于固定预读 128 KiB。窗口受访问历史、文件边界、设备设置和实现策略影响。见 页缓存与预读 API。

3. 写路径:先保证局部内容正确,再管理脏数据

缓冲写入取得相应缓存范围,把用户数据复制进去,更新大小及脏状态。部分覆盖原有文件块时,可能需要先读取未覆盖的旧内容;若旧内容已在缓存,或范围是新分配、空洞且应为零,就不必机械地先读磁盘。不同文件系统可使用 buffer-head 或 iomap 等路径,不能把一条固定函数链当作所有实现。

缓冲写入:可见、写回与持久化的完成点不同 应用 write() 把新数据交给内核 页缓存 dirty 普通读取可观察新数据 writeback 进行中 可同时再次被写脏 后端完成 I/O 还需满足持久化协议 fsync:请求并等待相应的数据、元数据与存储同步 文件同步不自动替代父目录同步,也不把多次写变成事务 dirty 与 writeback 可重叠;write 返回成功不是断电后必然保留的承诺。

dirty 表示有尚需写回的修改;writeback 表示某次写回正在进行。开始写回后,应用可能再次修改同一 folio,所以两种状态能够同时出现。本轮 I/O 完成不自动清除之后产生的新脏数据。写回代码需要跟踪范围、状态与完成时序,详细契约见 iomap 设计。

延迟分配允许文件系统在后续写回时再决定物理块,有利于聚合布局,但也使空间不足等错误可能晚于最初的 write。内核使用写回错误记录机制向相关文件描述返回错误,应用不能忽略后续 fsync 的失败。即使 close 返回成功,也不能据此认定已持久化。

4. 写者为什么会被脏页限流

先算缓存积压速度,假设输入、输出速率持续不变:

流量速率
应用产生脏数据1 GiB/s = 1024 MiB/s
后端排出200 MiB/s
暂存差额824 MiB/s

每多持续一秒,就需要再容纳约 824 MiB 差额。缓存能吸收突发,却不能无限维持这个速度;脏页预算受到压力后,写者需要节流。短测的快速 write 可能只是消耗了这份预算。

控制项含义易错理解
dirty_background_ratio / dirty_background_bytes后台写回启动相关阈值不是数据持久性的截止时间
dirty_ratio / dirty_bytes写者节流相关阈值ratio 的分母不是简单的物理总内存
dirty_expire_centisecs脏数据达到一定年龄后可成为周期写回对象不是每个字节到龄即已落盘
dirty_writeback_centisecs周期写回唤醒间隔不是唯一写回触发来源

ratio 与 bytes 是成对的替代配置方式,写入一方会使对应另一方清零;实际控制还涉及可写脏内存估算、各写回域份额及 cgroup 支持。当前值应读取 /proc/sys/vm/,不要把某组默认值写成所有机器的事实。后台工作也不是永久“一块盘对应一个独占 flusher 线程”。见 VM sysctl 文档。

5. 三种完成语义

观察点能说明什么不能说明什么
缓冲 write 返回成功返回的字节数已被本次写入接受所有请求字节都写完;掉电后仍在
后端写请求完成相应 I/O 达到该协议的完成条件自动满足设备易失缓存、目录更新和应用事务要求
fsync 成功文件数据及所需元数据按同步契约完成父目录项已同步;多个文件形成原子事务

fdatasync() 仍要同步恢复数据所必需的元数据,例如影响读取的文件大小;它不是完全不写元数据。创建或重命名后要保证名字持久存在,通常还需要同步相关父目录,具体故障协议应按文件系统能力设计。同步失败必须上报或进入明确的恢复流程。见 fsync(2)。

特意在一次写回进行期间再次写入,再完成旧请求。下面分别保留缓存、设备易失状态和稳定内容,用断电结果检查每个完成点究竟保住了哪个版本。

正在呈现知识画面
一次写入,四个不同的完成位置

读者看到的缓存版本、一次 I/O 的快照、设备易失缓存和稳定介质可以不同。fsync 的文件完成契约不替代目录同步。

6. mmap 与直接 I/O 分别改变了什么

普通文件的 MAP_SHARED 映射通常接到同一页缓存,写入可形成脏页;MAP_PRIVATE 的写时复制形成私有修改,不把这些修改写回原文件。映射在关闭原 fd 后仍可存续;文件被并发截短后访问超出新末尾的页可能收到 SIGBUS。msync(MS_SYNC) 可同步共享映射范围,但不代替跨文件事务或目录同步。见 mmap(2)。

直接 I/O:绕开缓存路径,不扩大持久承诺

O_DIRECT 通常让文件数据传输避开缓冲 I/O 的缓存路径,但不等于 O_SYNC,也不自动承诺零复制、异步执行或断电持久性。缓冲区地址、长度和文件偏移的对齐要求依文件系统与设备而异;支持时可通过 statx() 的 STATX_DIOALIGN 查询,不能统一写死为 512 或 4096 字节。不满足约束时可能报错,部分路径也可能回退。

混用路径时,仍要协调缓存和缓冲区寿命

直接 I/O 与缓冲写入、共享映射混用时,内核仍可能需要写回或失效缓存来维持一致性,性能和并发推理都更复杂。异步请求完成前,用户缓冲区必须保持有效且满足访问约束;涉及 fork 与私有映射的缓冲区还要遵守额外限制。接口限制见 open(2) 的 O_DIRECT 说明。

7. 小实验:共享映射和私有副本

下面只创建一个临时文件,验证可见内容,不测吞吐量或断电恢复:

import mmap
import os
import tempfile

with tempfile.TemporaryFile() as file:
    file.write(b"A" * mmap.PAGESIZE)
    file.flush()                    # 先清 Python 用户态缓冲
    with mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_WRITE) as shared:
        shared[0:1] = b"S"
        shared.flush()              # Linux 上同步共享映射
    os.fsync(file.fileno())
    assert os.pread(file.fileno(), 1, 0) == b"S"
    with mmap.mmap(file.fileno(), 0, access=mmap.ACCESS_COPY) as private:
        private[0:1] = b"P"
        assert private[0:1] == b"P"
        assert os.pread(file.fileno(), 1, 0) == b"S"
    assert os.pread(file.fileno(), 1, 0) == b"S"
    print("共享映射写入可见;私有修改未写回文件:通过")

file.flush()、映射 flush 和 fsync() 分别跨越不同缓冲/同步边界;一个 Python 方法名相同,并不意味着所做工作相同。

只读排查可查看 /proc/meminfo 的 Cached、Dirty、Writeback 和 /proc/vmstat 的相关计数。Cached 不应直接当作可随时无成本释放的普通磁盘文件缓存,tmpfs/shmem 等也影响统计。mincore() 报告的驻留状态是快照,不保证下一次读取延迟。drop_caches 是系统范围操作,会扰动其他进程的缓存状态,不应放进日常观测步骤当作无副作用的“刷新”。

进一步沿 mm/filemap.c 阅读缓存读取,沿 mm/readahead.c 阅读预读,沿 mm/page-writeback.c 与 fs/fs-writeback.c 阅读节流和写回。文件布局与日志机制接 文件系统实现。