文件映射和 page cache:文件内容怎样变成页面
上一篇看了匿名页和 COW,它们处理的是没有文件 backing 的内存。文件 backing 是另一条主线:文件内容如何进入内核管理的页面池,又如何被 read() 复制到用户缓冲区或被 mmap() 映射进进程地址空间。这一切的共同枢纽是 page cache。
核心问题可以压成一句话:
page cache 让文件 I/O 和虚拟内存共享同一批页面对象;
read()和mmap()只是进入 page cache 的两种路径。
问题从哪里来
教材常把文件 I/O 和虚拟内存分开讲。读文件用 read,写文件用 write,文件系统维护自己的“缓冲缓存”;进程内存用 mmap、malloc、fork,由 VM 子系统管理。两套体系,互不相干。
Linux 不是这样实现的。当前官方文档对 page cache 给出的定位非常直接:
The page cache is the primary way that the user and the rest of the kernel interact with filesystems.
也就是说,绝大多数 read/write/mmap 都要从 page cache 经过。O_DIRECT 是一条主动绕过 page cache 的旁路,正因为是旁路,需要用户额外满足对齐和并发约束。
这条架构选择有几个连带后果。一个文件被任何一个进程访问过,对应页面会进入 page cache 并被其他进程复用。read() 把 page cache 中的页面数据复制进用户缓冲区,mmap() 则把 page cache 中的页面直接映射进进程页表,前者是“拷一份给你”,后者是“给你一个翻译”。文件页和匿名页在回收路径上看似都是“页”,但写回机制完全不同:匿名页要回 swap,文件页可以写回原文件。
把这一切串起来的就是 page cache。
最小模型
文件内容进入页面的路径可以画成:
1 | |
address_space 是文件在 page cache 端的句柄。它通过 inode 关联到具体文件系统,内部维护一棵以 pgoff_t(页对齐的文件偏移)为索引的结构,存放当前缓存的页面对象。
页面进入 page cache 时不会立刻有内容。read_folio 这类回调由具体文件系统提供,负责把磁盘上的数据装进 folio。装好之后 folio 才解锁,等待消费者。文档对 read_folio 的描述很短:
->read_folio()unlocks the folio, either synchronously or via I/O completion.
read() 是一种消费者。它确认 page cache 中有命中的 folio(必要时触发 read_folio),再把 folio 中的字节复制到用户缓冲区。
mmap() 是另一种消费者。fault 入口 filemap_fault 找到或加载对应的 folio,把它安装进进程页表。后续访问 TLB miss 时硬件直接通过新装的 PTE 拿到 PFN,不经过 page cache 查找。
两条路径的产物不同:read() 之后用户态拿到的是数据副本,mmap() 之后用户态指针直接指向 page cache 的物理页面。
MAP_SHARED 与 MAP_PRIVATE 是两种 mmap
mmap 文件时,最关键的标志是 MAP_SHARED 与 MAP_PRIVATE。它们决定写入语义如何穿过 page cache。
| 标志 | 读语义 | 写语义 | 持久性 |
|---|---|---|---|
MAP_SHARED |
读 page cache 中的 folio | 写直接落到 page cache 的 folio,标脏后由 writeback 写回文件 | 写入对其他进程和后续磁盘可见 |
MAP_PRIVATE |
读 page cache 中的 folio | 写触发 COW:分配私有匿名页,复制原内容,写入新页 | 写入只在本进程地址空间内可见,不回写文件 |
MAP_SHARED 的写路径上,第一次写到只读 PTE 会触发写保护异常,进入 page_mkwrite/pfn_mkwrite。文件系统在这里做必要的预留(块分配、quota、稀疏空洞填充)并把 folio 标脏。脏 folio 之后由 writepages 在 writeback 时机回写:
->writepages()is used for periodic writeback and for syscall-initiated sync operations.
MAP_PRIVATE 的写路径走 do_wp_page,逻辑与上一篇 fork 后的 COW 相同:分配新页、复制内容、把写入方的 PTE 改成可写并指向新页。新页此后是匿名页,不再属于 page cache 的文件视图,也不会被写回到原文件。
/proc/<pid>/maps 里的 p 和 s 标志区分这两种映射。smaps 中 Shared_* 与 Private_* 字段对应它们的页面分类。
read() 和 mmap() 各有代价
两条消费路径不是“一种快、一种慢”的关系,而是各有适用场景。
| 维度 | read() |
mmap() |
|---|---|---|
| 触发 I/O | 同步 read(可能阻塞)或读 page cache | 缺页时按需触发 |
| 数据访问 | 用户缓冲区有副本 | 用户指针直接指向 page cache |
| 内存压力 | 用户缓冲区算进 RSS | page cache 部分算进 RSS 与 PSS(MAP_SHARED 共享部分 PSS 较小) |
| 错误模型 | 返回值/errno |
缺页异常、SIGBUS |
| 顺序读取 | 容易让内核做预读 | 也能预读,需要 MADV_SEQUENTIAL 等提示更明显 |
| 小量随机访问 | 多次 pread 切换开销大 |
一次 mmap 后多次访问开销低 |
| 写入语义 | write 复制+提交 |
MAP_SHARED 直接脏 page cache;MAP_PRIVATE 走 COW |
read() 把数据从 page cache 拷进用户缓冲区,意味着用户态多一份拷贝、多一次系统调用,但错误以返回值出现,处理简单。mmap() 省掉用户缓冲区那份拷贝,访问路径很短,但错误以信号形式出现,文件被 truncate 后访问超出范围的映射可能拿到 SIGBUS。
理解“两种消费者共享同一个 page cache”这件事,比争论“read 快还是 mmap 快”更重要。命中 page cache 时两者都不读盘;未命中时两者都触发 read_folio,差别在于把数据交给谁。
模式提炼:cache 是接口的统一点
1 | |
page cache 不是 VM 子系统的私有缓存,也不是文件系统的私有缓存,而是两者之间的统一对象池。回收策略、脏页写回、reverse mapping 都围绕这个池子展开。
| 角色 | 在 page cache 上做什么 |
|---|---|
| 文件系统 | 通过 read_folio 装填 folio;通过 writepages 写回 |
| VM 子系统 | 通过 LRU 决定回收,通过 reclaim 释放 folio |
read/write 系统调用 |
把 folio 内容拷给用户或从用户拷进 folio |
mmap |
把 folio 直接安装进进程页表 |
| 反向映射 | 让回收路径找到映射该 folio 的所有进程 |
理解这一点,后续 folio、回收、writeback、cgroup memory 几篇都会更顺。
一个最小实验:read vs mmap
这个实验创建一个 16MB 文件,分两阶段消费:先用 read() 顺序读,再用 mmap() 顺序访问。每阶段开始前在 shell 里手动丢一次 page cache。用 mincore() 观察哪些页面已经驻留。
C 程序本身只做读取和观察,丢 cache 的动作放在 shell 里完成,避免依赖某些发行版上行为不一的 POSIX_FADV_DONTNEED。
1 | |
准备文件并按阶段运行:
1 | |
如果不愿意 drop_caches 影响整机,可以用 dd if=<big-other-file> of=/dev/null 通过 LRU 压力把目标文件 page cache 自然挤出去,或者用 vmtouch -e 这类第三方工具单文件清理;前者不可控,后者不在标准发行版自带工具里。两种替代都不如 drop_caches 干净。
读取实验结果
可能看到的结构是:
第一阶段,read() 顺序读完文件。这是从冷 cache 开始的,所有页面都要从底层 backing 取上来。majflt 增量取决于内核 readahead 与 backing store 行为;read() 在用户态拿到的是缓冲区里的数据副本,与 page cache 中的 folio 内容相同。
第二阶段,再次丢 cache 后 mmap()。此时 page cache 中没有该文件的 folio,mincore 报告几乎全部 0:
1 | |
第三阶段,顺序访问 mmap 区间的所有页。每次访问触发 filemap_fault,按需把 folio 装进 page cache 并安装进 PTE。结束后 mincore 报告几乎全部 1:
1 | |
minflt 与 majflt 的分布会随预读、MAP_SHARED 的写回机制和具体文件系统而变化。重要的不是某个具体数值,而是几个不变量:
mmap本身不会让所有页驻留。mincore在访问前显示 0,说明 mmap 是延迟兑现。- 第一次访问每一页都要走一次 fault;后续访问只要 PTE 还 present 就不再 fault。
- 即便不同进程分别用
read与mmap,它们消费的都是同一组 folio。
把 mmap 改成 MAP_PRIVATE 后写入第 0 页,再 mincore 看一次:那一页变成本进程的私有匿名页,从 page cache 视角看仍然有原始 folio,但写入数据不再可见也不会被写回。
模式提炼:访问层和缓存层解耦
1 | |
把“持久化层、缓存层、访问层”分开看,能解释为什么修改文件不必一定通过 write:MAP_SHARED 写入 + writeback 也能做到,效率不同。也能解释为什么 O_DIRECT 那么严格:它绕过 cache 层,必须自己处理同步、对齐和与 cache 写回的竞争。
核心结构:address_space
struct address_space 是 page cache 的核心索引结构(include/linux/fs.h,精简):
1 | |
i_pages(xarray)按 pgoff_t 索引 folio,是文件缺页和 readahead 的查找入口。
源码锚点
| 入口 | 读它的目的 |
|---|---|
mm/filemap.c |
filemap_fault()、filemap_get_folio()、read_cache_folio() |
mm/readahead.c |
顺序读时的预读判断 |
fs/buffer.c |
老的 buffer head 路径,看历史脉络 |
include/linux/pagemap.h |
folio 与 page cache 的 API |
Documentation/filesystems/locking.rst |
address_space_operations 与 page_mkwrite |
读 filemap_fault 时可以带着一个问题:缺页路径如何与 readahead 配合,决定本次拉的是一页还是一组页。这条路径连接前一篇的 fault 主题与下一篇的 folio 抽象。
研究生迁移表
| Linux 概念 | 一般系统设计 |
|---|---|
address_space |
文件到页面索引的句柄 |
| page cache | 持久化层与访问层之间的统一对象池 |
read_folio |
缓存填充回调 |
writepages |
缓存写回回调 |
filemap_fault |
mmap 的缺页适配器 |
MAP_SHARED |
共享视图 + 写回义务 |
MAP_PRIVATE |
共享读 + COW 写 |
O_DIRECT |
旁路 cache,自己负责一致性 |
这层架构在数据库(page cache + buffer pool)、对象存储客户端(远端对象 + 本地缓存)、CDN 边缘节点都能找到对应。共同点是“持久层是事实,缓存层是性能,访问层是接口”。
常见误解
第一个误解是把 page cache 当成文件系统私有缓存。它是文件系统与 VM 的交集,回收、reverse mapping、cgroup memory 都把它纳入统一管理。
第二个误解是认为 read() 总是慢于 mmap()。命中 page cache 时,read() 的开销主要是一次系统调用加一次拷贝;mmap() 的开销在第一次 fault,后续访问几乎无内核开销,但页表建立、TLB 占用、munmap 释放有自己的成本。哪一种快取决于访问模式。
第三个误解是把 MAP_SHARED 视为“立即同步到磁盘”。MAP_SHARED 写入只是把 folio 标脏,真正回写依赖 writeback 路径。msync(2) 才能强制写回。
第四个误解是把 MAP_PRIVATE 文件映射的写入视为“写不到文件”。写不到文件是事实,但写入的过程仍然走 COW,分配新页并复制;这一动作是 lazy 的,但成本真实。
第五个误解是认为 mincore() 的结果稳定。man 页面写得很清楚:
the information returned in vec is only a snapshot … pages that are not locked in memory can come and go at any moment.
mincore 适合做趋势性观察,不适合做并发判定。
练习
第一,按文中代码跑实验,记录两个阶段的 minflt、majflt 增量和 mincore 报告的 resident 数量。重复 3 次,观察 readahead 是否让 majflt 出现明显波动。
第二,在第二阶段后再读 /proc/self/smaps 中该映射段,比较 Rss、Pss、Shared_Clean 的关系。再开一个进程对同一文件 mmap,比较两个进程的 Pss。
第三,把 MAP_SHARED 改成 MAP_PRIVATE,写入若干页,重新读 smaps。验证写入的页面在 Private_Dirty、未写入的页面仍在 Shared_Clean 或 Private_Clean。
第四,配合 strace -e trace=openat,read,mmap,munmap,fadvise64 跑实验,观察系统调用次数与每次调用的字节量。从系统调用角度比较 read 与 mmap 的开销结构。
第五,阅读 mm/filemap.c 中 filemap_fault 的实现,画一张流程图,标出 cache 命中、未命中、readahead 路径分别走到哪里。这条路径会成为 folio 篇的入口。
系列导航
- 上一篇:匿名页和 COW:
fork为什么便宜,写入为什么变贵 - 本文:文件映射和 page cache:文件内容怎样变成页面
- 下一篇:Folio:为什么内核重新组织 page 抽象
