上一篇看了匿名页和 COW,它们处理的是没有文件 backing 的内存。文件 backing 是另一条主线:文件内容如何进入内核管理的页面池,又如何被 read() 复制到用户缓冲区或被 mmap() 映射进进程地址空间。这一切的共同枢纽是 page cache。

核心问题可以压成一句话:

page cache 让文件 I/O 和虚拟内存共享同一批页面对象;read()mmap() 只是进入 page cache 的两种路径。

问题从哪里来

教材常把文件 I/O 和虚拟内存分开讲。读文件用 read,写文件用 write,文件系统维护自己的“缓冲缓存”;进程内存用 mmapmallocfork,由 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
2
3
4
5
6
7
file (inode, address_space)
-> page cache index by (mapping, pgoff)
-> folio / page
-> filled by filesystem read_folio or read_pages
-> consumed by
read() -> copy_to_user
mmap() -> install in page table (via filemap_fault)

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_SHAREDMAP_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 里的 ps 标志区分这两种映射。smapsShared_*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
file offset -> page cache index -> folio -> user access

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
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
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <unistd.h>

static long count_resident(unsigned char *vec, size_t n) {
long c = 0;
for (size_t i = 0; i < n; i++) {
if (vec[i] & 1) c++;
}
return c;
}

static void report(const char *stage, struct rusage *prev) {
struct rusage cur;
getrusage(RUSAGE_SELF, &cur);
printf("[%s] minflt=+%ld majflt=+%ld\n",
stage,
cur.ru_minflt - prev->ru_minflt,
cur.ru_majflt - prev->ru_majflt);
*prev = cur;
}

static int do_read_stage(const char *path, struct rusage *prev) {
int fd = open(path, O_RDONLY);
if (fd < 0) { perror("open(read)"); return -1; }
char *buf = malloc(64 * 1024);
if (!buf) { perror("malloc"); close(fd); return -1; }
ssize_t n;
while ((n = read(fd, buf, 64 * 1024)) > 0) { /* drain */ }
if (n < 0) { perror("read"); }
report("after read() sequential", prev);
free(buf);
close(fd);
return 0;
}

static int do_mmap_stage(const char *path, struct rusage *prev) {
int fd = open(path, O_RDONLY);
if (fd < 0) { perror("open(mmap)"); return -1; }
struct stat st;
if (fstat(fd, &st) != 0) { perror("fstat"); close(fd); return -1; }
size_t length = (size_t)st.st_size;
size_t page = (size_t)sysconf(_SC_PAGESIZE);
size_t npages = (length + page - 1) / page;

void *map = mmap(NULL, length, PROT_READ, MAP_SHARED, fd, 0);
if (map == MAP_FAILED) { perror("mmap"); close(fd); return -1; }

unsigned char *vec = malloc(npages);
if (!vec) { perror("malloc vec"); munmap(map, length); close(fd); return -1; }

mincore(map, length, (void *)vec);
printf("[after mmap, before access] resident pages = %ld / %zu\n",
count_resident(vec, npages), npages);

volatile long sink = 0;
for (size_t i = 0; i < npages; i++) {
sink += ((char *)map)[i * page];
}
(void)sink;
report("after mmap sequential access", prev);

mincore(map, length, (void *)vec);
printf("[after mmap, after access] resident pages = %ld / %zu\n",
count_resident(vec, npages), npages);

free(vec);
munmap(map, length);
close(fd);
return 0;
}

int main(int argc, char **argv) {
if (argc != 3) {
fprintf(stderr, "usage: %s read|mmap <path>\n", argv[0]);
return 1;
}
struct rusage prev;
getrusage(RUSAGE_SELF, &prev);

if (strcmp(argv[1], "read") == 0) {
return do_read_stage(argv[2], &prev) == 0 ? 0 : 1;
} else if (strcmp(argv[1], "mmap") == 0) {
return do_mmap_stage(argv[2], &prev) == 0 ? 0 : 1;
}
fprintf(stderr, "unknown stage: %s\n", argv[1]);
return 1;
}

准备文件并按阶段运行:

1
2
3
4
5
6
7
8
9
10
dd if=/dev/urandom of=/tmp/page_cache_demo.bin bs=1M count=16
cc -Wall -Wextra -O2 page_cache_demo.c -o page_cache_demo

# 丢一次 page cache(需要 root,影响整机)
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
./page_cache_demo read /tmp/page_cache_demo.bin

# 再丢一次,跑 mmap 阶段
sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
./page_cache_demo mmap /tmp/page_cache_demo.bin

如果不愿意 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
[after mmap, before access] resident pages = 0 / 4096

第三阶段,顺序访问 mmap 区间的所有页。每次访问触发 filemap_fault,按需把 folio 装进 page cache 并安装进 PTE。结束后 mincore 报告几乎全部 1:

1
[after mmap, after access] resident pages = 4096 / 4096

minfltmajflt 的分布会随预读、MAP_SHARED 的写回机制和具体文件系统而变化。重要的不是某个具体数值,而是几个不变量:

  • mmap 本身不会让所有页驻留。mincore 在访问前显示 0,说明 mmap 是延迟兑现。
  • 第一次访问每一页都要走一次 fault;后续访问只要 PTE 还 present 就不再 fault。
  • 即便不同进程分别用 readmmap,它们消费的都是同一组 folio。

mmap 改成 MAP_PRIVATE 后写入第 0 页,再 mincore 看一次:那一页变成本进程的私有匿名页,从 page cache 视角看仍然有原始 folio,但写入数据不再可见也不会被写回。

模式提炼:访问层和缓存层解耦

1
2
3
4
filesystem stores bytes
-> page cache caches them as folios
-> read() copies bytes out
-> mmap() exposes folios as a virtual range

把“持久化层、缓存层、访问层”分开看,能解释为什么修改文件不必一定通过 writeMAP_SHARED 写入 + writeback 也能做到,效率不同。也能解释为什么 O_DIRECT 那么严格:它绕过 cache 层,必须自己处理同步、对齐和与 cache 写回的竞争。

核心结构:address_space

struct address_space 是 page cache 的核心索引结构(include/linux/fs.h,精简):

1
2
3
4
5
6
7
8
9
struct address_space {
struct inode *host; /* 所属 inode */
struct xarray i_pages; /* 页面索引(xarray 替代了旧 radix tree) */
atomic_t i_mmap_writable; /* 可写共享映射计数 */
struct rb_root_cached i_mmap; /* 所有映射此文件的 VMA(interval tree) */
unsigned long nrpages; /* 缓存中的总页数 */
const struct address_space_operations *a_ops; /* readpage/writepage 等 */
unsigned long flags; /* GFP mask 等 */
};

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_operationspage_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 适合做趋势性观察,不适合做并发判定。

练习

第一,按文中代码跑实验,记录两个阶段的 minfltmajflt 增量和 mincore 报告的 resident 数量。重复 3 次,观察 readahead 是否让 majflt 出现明显波动。

第二,在第二阶段后再读 /proc/self/smaps 中该映射段,比较 RssPssShared_Clean 的关系。再开一个进程对同一文件 mmap,比较两个进程的 Pss

第三,把 MAP_SHARED 改成 MAP_PRIVATE,写入若干页,重新读 smaps。验证写入的页面在 Private_Dirty、未写入的页面仍在 Shared_CleanPrivate_Clean

第四,配合 strace -e trace=openat,read,mmap,munmap,fadvise64 跑实验,观察系统调用次数与每次调用的字节量。从系统调用角度比较 readmmap 的开销结构。

第五,阅读 mm/filemap.cfilemap_fault 的实现,画一张流程图,标出 cache 命中、未命中、readahead 路径分别走到哪里。这条路径会成为 folio 篇的入口。

系列导航

参考资料