文件页怎么回收:page cache、干净页和脏页
番外一讲了回收入口:水位线紧张时,分配路径可能唤醒 kswapd,更紧张时当前分配者可能进入 direct reclaim。
番外二讲了扫描对象:回收框架在 lruvec 里按 anon/file、active/inactive 或 Multi-Gen LRU 的 generation 近似判断冷热。
番外三讲了匿名页:普通匿名页没有文件后盾,不能直接丢,通常要通过 swap entry、swap cache 和 do_swap_page() 完成换出换入。
这一篇接着拆文件页:
text
文件页为什么看起来更容易回收?
read/write 背后的 page cache 和 mmap 文件映射是不是同一套东西?
clean file page 能不能直接丢?
dirty file page 为什么必须和 writeback 配合?
mmap 的 PTE 被 unmap,是否等于 page cache 被释放?
先把结论放前面。以 Linux stable v6.12.65 源码为准,普通文件页回收的大路径是:
text
shrink_lruvec
│
▼
shrink_inactive_list
│
▼
shrink_folio_list
│
├─ folio 最近仍被引用?
│ └─ 保留 / 激活,暂不回收
│
├─ folio 被进程页表映射?
│ └─ try_to_unmap
│ └─ PTE: present file PFN -> not-present
│
├─ folio 是 dirty?
│ ├─ 多数情况下先标记 reclaim / 让 flusher 写回
│ ├─ kswapd 在特定脏页压力下可 pageout
│ └─ 写回完成后变 clean,才有机会释放物理页
│
└─ folio 是 clean 且不忙?
├─ filemap_release_folio 释放 buffer/private 状态
├─ __remove_mapping 从 address_space->i_pages 删除
├─ 可留下 workingset shadow 供 refault 判断
└─ 释放 folio,把物理页还给 buddy
一句话压缩:
干净文件页有文件后盾,可以从 page cache 移除;脏文件页不是不能回收,而是必须先把"内存里更新过、磁盘上还没有"的内容写回,才能按干净文件页处理。
一、文件页的"后盾"到底是什么
匿名页的后盾需要临时找:普通匿名私有数据要回收,通常先写到 swap。
文件页的后盾天然存在:它来自某个文件的某个偏移范围。只要内存中的内容和后备存储一致,内核就可以丢掉这份内存副本;以后再访问,再从文件系统读回来。
这就是 clean file page 和 anonymous page 的根本区别:
text
anonymous page
内容只在内存里。
回收前通常要找 swap 保存内容。
clean file page
内容已经能从文件后盾重新获得。
回收时可以直接丢掉内存副本。
dirty file page
内容来自文件,但内存里的版本比后备存储新。
不能直接丢,必须先 writeback。
注意这里说的是 buffered I/O 和普通文件映射。下面这些路径要另看:
O_DIRECT/ direct I/O:尽量绕过 page cache,不是本文主线。- DAX:文件直接映射持久内存,page cache 语义不同。
tmpfs/shmem:走 page cache 形态,但后盾不是普通块设备文件;压力下可能走 swap。MAP_PRIVATE文件映射写入:写 fault 后通常 COW 成匿名页,不再是"把文件页写脏"。
本文主线讲的是最常见的普通文件 buffered read/write 和文件 mmap。
二、page cache 的核心对象:address_space
主线第 4 篇讲 VMA 时说过,文件映射 VMA 里有 vm_file。但是一个文件的缓存页不挂在 VMA 下面,而是挂在文件 inode 相关的 address_space 上。
v6.12.65 的 include/linux/fs.h 里,struct address_space 注释直接说它表示一个可缓存、可映射对象的内容。本文只需要抓住这几个字段:
text
struct address_space
├─ host
│ 所属 inode 或 block_device。
│
├─ i_pages
│ xarray,保存 page cache 里的 folio。
│ key 通常是文件页偏移 index。
│
├─ i_mmap
│ 映射这个文件的 VMA 区间树。
│ 文件页 rmap、truncate、unmap 等路径会用到。
│
├─ writeback_index
│ writeback 扫描起点。
│
└─ a_ops
address_space_operations。
里面有 read_folio、readahead、writepage、writepages、
dirty_folio、write_begin、write_end 等文件系统方法。
画成关系图:
text
inode
│
└─ i_mapping / i_data
│
▼
address_space
├─ i_pages: xarray
│ ├─ index 0 -> folio for file offset 0
│ ├─ index 1 -> folio for file offset PAGE_SIZE
│ └─ ...
│
├─ i_mmap: 映射这个文件的 VMA
│
└─ a_ops
├─ read_folio / readahead
├─ write_begin / write_end
├─ dirty_folio
└─ writepage / writepages
所以 page cache 不是"每个进程一份"。同一个文件的同一段内容,在 page cache 里通常对应同一个 address_space + index 下的 folio。不同进程用 read() 读它,或者用 mmap() 映射它,最终都可能碰到这份 folio。
三、read() 和 mmap() 为什么会看到同一份 page cache
先看 buffered read()。
用户调用 read(fd, buf, len),普通文件最终常走到 filemap_read() 一类路径。简化后是:
text
read(fd)
│
▼
filemap_read
│
▼
filemap_get_pages
│
├─ 先从 mapping->i_pages 找 folio
│
├─ 命中:把 folio 内容复制到用户缓冲区
│
└─ 未命中:
├─ page_cache_sync_readahead
├─ filemap_create_folio / __filemap_get_folio(FGP_CREAT)
├─ 文件系统 read_folio / readahead 发起 I/O
└─ 读回后放进 mapping->i_pages
再看文件 mmap()。
mmap() 本身主要建立 VMA,不等于立刻把文件内容读进内存。第一次访问映射地址时,CPU 发现 PTE 不 present,进入缺页路径。对普通文件映射,VMA 的 fault handler 通常会走到 filemap_fault():
text
access mmap address
│
▼
page fault
│
▼
do_fault
│
▼
vma->vm_ops->fault
│
▼
filemap_fault
│
├─ filemap_get_folio(mapping, index)
│ │
│ ├─ page cache 命中
│ │ └─ 返回 folio,建立 PTE
│ │
│ └─ page cache 未命中
│ ├─ 计 major fault
│ ├─ __filemap_get_folio(FGP_CREAT | FGP_FOR_MMAP)
│ ├─ read_folio / readahead 读入
│ └─ 建立 PTE
这说明一个关键事实:
text
read() 填进来的缓存
和
mmap fault 查到的缓存
都在同一个 address_space->i_pages 里。
因此,如果你先 read() 一个文件,让它进入 page cache,再 mmap() 同一个文件并访问,mmap 缺页通常只需要把已有 folio 映射进页表;它不必再从磁盘读同一份内容。
这里说的"映射进页表",指的是用户地址空间 里的映射:read() 建立或使用的是用户缓冲区的用户页表映射,不是 mmap 文件区间到 page-cache folio 的用户页表映射。普通 read() 读取 page cache 里的 folio 时,是内核把 page-cache folio 里的数据复制到用户缓冲区;内核本身能够通过内核地址空间访问这个 folio,内核访问普通 RAM 通常走第 2 篇讲过的 direct map。所以这里一定要分清用户地址空间映射和内核地址空间映射。
反过来也成立:如果先通过 mmap 触发文件页读入,再 read() 同一段文件,read() 也能从 page cache 拿到内容并复制到用户缓冲区。
四、文件映射的 PTE 和 page cache 是两层状态
主线第 5 篇反复强调过:VMA 是地址区间承诺,PTE 是当前映射兑现。这里还要加第三层:
text
VMA:
这段虚拟地址映射了哪个文件、权限是什么。
PTE:
某个虚拟页当前是否直接映射到某个物理页。
page cache:
某个文件 offset 的内容是否已经缓存在内存 folio 里。
三者可以处在不同状态:
text
文件 mmap 后还没访问:
VMA 存在
PTE 不 present
page cache 可能没有这个 folio
read() 读过同一段文件,但 mmap 没访问:
VMA 存在
PTE 不 present
page cache 有 folio
mmap 访问过,PTE 建立:
VMA 存在
PTE present -> PFN
page cache 有 folio
reclaim 只 unmap 了 PTE:
VMA 仍存在
PTE 不 present
page cache 可能仍有 folio
reclaim 从 page cache 移除了 clean folio:
VMA 仍存在
PTE 不 present
page cache 没有这个 folio
下次访问需要重新读文件
这解释了一个常见误解:
文件映射的 PTE 被回收器 unmap,并不等于文件页已经从 page cache 释放。
try_to_unmap() 只是撤销进程页表里的 present 映射。只有后续 __remove_mapping() 成功把 folio 从 address_space->i_pages 删除,并释放 folio,物理页才真正回到可分配池。
五、clean file page 的回收路径
一个干净文件页大致长这样:
text
file offset N
│
▼
address_space->i_pages[index=N]
│
▼
page cache folio F
├─ 共同部分
│ ├─ file LRU
│ ├─ mapping = address_space
│ ├─ index = 文件页偏移
│ └─ 可能被一个或多个进程 PTE 映射
│
└─ 回收关键状态
├─ dirty = 0 ← 干净:内存内容和后端文件一致
├─ writeback = 0
├─ xarray dirty tag = 0
└─ 条件满足时可从 page cache 删除
回收器从 LRU 上拿到这个 folio 后,要分几步确认它真的能释放:
text
clean file folio
│
▼
是否最近被访问?
│
├─ 是 -> 保留 / 激活
└─ 否
│
▼
是否被页表映射?
│
├─ 是 -> try_to_unmap
└─ 否
│
▼
是否还有额外引用、private buffer、writeback、dirty?
│
├─ 有 -> 暂时不能释放
└─ 没有
│
▼
__remove_mapping(mapping, folio)
│
├─ 从 mapping->i_pages 删除
├─ 可能留下 workingset shadow
└─ 释放 folio
严格说,从 page cache 移除只是断开"文件 offset -> folio"这层缓存关系;只有 folio 没有其他引用并继续走到 free folio,物理页才真正回到 buddy,内存才算被回收。
__remove_mapping() 的检查很关键。它不是看见 clean 就无脑删除,而是要确认:
- folio 锁已经拿到。
mapping == folio_mapping(folio)。- folio 没有 dirty。
- folio refcount 能 freeze 到期望值,说明没有别的使用者。
- 如果是文件 LRU,可在 xarray 里留下 workingset shadow,用于后续 refault 判断。
为什么要留下 shadow?
因为文件页被丢以后,如果很快又 fault/read 回来,说明它可能其实属于工作集。Linux 的 workingset/refault 逻辑会用这个信号影响后续保护策略。番外二里说过,Linux 不是严格 LRU,它会根据 refault 反馈调整冷热判断。
六、dirty file page 为什么不能直接丢
脏文件页和干净文件页不是两种不同的结构体;它同样是 page cache 里的 folio,只是状态位、xarray 标签和 writeback 账本不同:
text
file offset N
│
▼
address_space->i_pages[index=N]
│
▼
page cache folio F
├─ 共同部分
│ ├─ file LRU
│ ├─ mapping = address_space
│ ├─ index = 文件页偏移
│ └─ 可能被一个或多个进程 PTE 映射
│
└─ 回收关键状态
├─ dirty = 1 ← 脏:内存内容比后端文件更新
├─ writeback = 0 ← 尚未进入 writeback
├─ xarray dirty tag = 1
├─ inode / bdi_writeback 账本里也记了脏页
└─ 不能直接删除,必须先 writeback 变 clean
如果这时直接把 folio 从内存丢掉,会发生数据丢失:
text
用户 write() / MAP_SHARED store
│
▼
只更新 page cache,磁盘还没更新
│
▼
如果直接 reclaim 掉 dirty folio
│
▼
新数据消失
所以 dirty file page 的正确路径是:
text
dirty file folio
│
▼
writeback
│
├─ mapping->a_ops->writepage / writepages
├─ 文件系统把 folio 内容提交给块层或后端
└─ I/O 完成后清 writeback,folio 变 clean
│
▼
后续 reclaim 才能按 clean file folio 删除
注意:writeback 只是把页从 dirty 变 clean,不等于释放内存。真正释放内存仍要靠 reclaim 后续把 clean folio 从 page cache 移除。
text
dirty page --writeback--> clean page --reclaim--> free page
七、shrink_folio_list() 如何处理 dirty / writeback 文件页
在 v6.12.65 的 mm/vmscan.c 里,shrink_folio_list() 是理解文件页回收的核心函数之一。
它对 dirty/writeback folio 的处理可以抽象成下面的流程图:
text
LRU 隔离出候选 folio
│
▼
lock(folio)
│
▼
folio 不可回收?
├─ 是 -> activate / keep
└─ 否
│
▼
folio 正在 writeback?
├─ 是
│ ├─ kswapd 看到 reclaim 标记,且 pgdat 写回拥塞?
│ │ └─ 记 nr_immediate,activate
│ │
│ ├─ 全局/新 memcg 回收,或当前不适合等待 I/O?
│ │ └─ folio_set_reclaim,activate
│ │
│ └─ 可以等待
│ └─ wait_on_writeback,retry
│
└─ 否
│
▼
最近有引用?
├─ 是 -> activate / keep
└─ 否
│
▼
folio 被 PTE 映射?
├─ 是 -> try_to_unmap(folio)
│ ├─ 仍然 mapped -> activate
│ └─ 已经 unmapped -> 继续
└─ 否 -> 继续
│
▼
mapping = folio_mapping(folio)
│
▼
folio 是 dirty?
├─ 是
│ ├─ file LRU,且不满足 kswapd 特定 pageout 条件?
│ │ └─ folio_set_reclaim,activate
│ │
│ ├─ 不能进入文件系统 / 不允许写页?
│ │ └─ keep
│ │
│ └─ pageout(folio, mapping)
│ └─ mapping->a_ops->writepage(..., WB_SYNC_NONE, for_reclaim=1)
│ ├─ 仍 writeback 或 dirty -> keep
│ └─ 已变 clean -> 继续尝试释放
│
└─ 否 -> 继续
│
▼
folio 有 buffer/private?
├─ 是 -> filemap_release_folio()
└─ 否 / 已释放
│
▼
clean、unmapped、refcount 合适?
├─ 是 -> __remove_mapping(mapping, folio) -> free folio
└─ 否 -> keep
这里的 activate / keep 都表示本轮 reclaim 不释放这个 folio。activate 偏向于把它放回 active LRU 或标成更值得保护,keep 泛指因为当前条件不满足而保留、放回去,等后续扫描再处理。
这里最重要的是两个事实。
第一,direct reclaim 通常不想在文件系统写回里陷太深。
源码注释里明确说,文件系统 folio 的写回只有 kswapd 能做,以避免栈溢出风险;并且为了避免把低效的单页 I/O 注入 flusher writeback,它只在已经遇到很多 dirty folio、并且这些 folio 带 reclaim 标记又再次出现时才写。
也就是说,回收遇到 dirty file page,经常不是"当前线程马上写磁盘",而是:
text
标记这个 folio 需要尽快回收
把它放回去
让后台 writeback/flusher 更合适地批量写
第二,正在 writeback 的页也不一定马上等。
如果每个 reclaim 都等 I/O,系统可能被慢盘、网络文件系统或循环设备拖死。源码把 writeback folio 分成几种情况:有的标 reclaim 后跳过,有的记录拥塞,有的 memcg 场景会等待,目的是在"释放内存"和"不要把分配路径拖进不可控 I/O"等目标之间折中。
八、文件页不是永远优先于匿名页
番外二已经说过,干净文件页成本低,所以常常更容易回收。但真实内核不会简单地写成:
text
永远先回收文件页,再回收匿名页
原因有几个:
- 文件页可能是热工作集,刚丢就 refault,会造成抖动。
- 匿名页如果有 swap,冷匿名页也可以回收。
- 脏文件页要 writeback,成本可能高于某些可换出的匿名页。
swappiness会影响 anon/file 扫描倾向。- memcg 回收只在某个 cgroup 账本里找页,不等于全局最优。
- Multi-Gen LRU 会按 generation 和类型综合挑选。
更准确的模型是:
text
clean cold file:
回收成本最低,常是优秀候选。
dirty file:
需要 writeback,不是免费候选。
hot file:
可能被 refault/workingset 保护。
cold anon with swap:
可以换出,但有 swap I/O 成本。
anon without swap:
普通私有数据很难回收。
九、实验:read() 填充的 page cache,mmap() 能直接看到
这个实验不用 root,不依赖 drop_caches。它做四件事:
- 创建一个普通文件,写入 64 MiB 数据并
fsync(),然后对这个文件调用posix_fadvise(POSIX_FADV_DONTNEED),尽量让 clean cache 离开内存。 mmap()同一个文件,但先不访问映射,用mincore()看 page cache 驻留页数。- 用
read()读完整个文件,再用mincore()看刚才那个mmap区间是否已经 resident。 - 访问
mmap每一页,记录 minor/major fault;然后madvise(MADV_DONTNEED)+posix_fadvise(DONTNEED),观察 clean file cache 被丢掉。
完整代码如下。把它保存为当前目录下的 file-cache-reclaim-demo.c 后运行后面的命令:
c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <inttypes.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 <sys/types.h>
#include <unistd.h>
static long page_size;
static void die(const char *msg)
{
perror(msg);
exit(1);
}
static void die_fadvise(int err, const char *msg)
{
if (err) {
errno = err;
die(msg);
}
}
static size_t parse_mib(int argc, char **argv)
{
size_t mib = 64;
if (argc >= 2) {
char *end = NULL;
unsigned long v = strtoul(argv[1], &end, 10);
if (!v || (end && *end)) {
fprintf(stderr, "usage: %s [MiB]\n", argv[0]);
exit(2);
}
mib = (size_t)v;
}
return mib * 1024UL * 1024UL;
}
static void full_write(int fd, const void *buf, size_t len)
{
const char *p = buf;
while (len) {
ssize_t n = write(fd, p, len);
if (n < 0)
die("write");
p += n;
len -= (size_t)n;
}
}
static void full_read(int fd, void *buf, size_t len, size_t chunk, uint64_t *sum)
{
unsigned char *p = buf;
while (len) {
size_t want = len < chunk ? len : chunk;
ssize_t n = read(fd, p, want);
if (n < 0)
die("read");
if (n == 0) {
fprintf(stderr, "short read\n");
exit(1);
}
for (ssize_t i = 0; i < n; i += 4096)
*sum += p[i];
len -= (size_t)n;
}
}
static void create_file(int fd, size_t len)
{
const size_t chunk = 1024 * 1024;
unsigned char *buf = malloc(chunk);
if (!buf)
die("malloc");
for (size_t i = 0; i < chunk; i++)
buf[i] = (unsigned char)(i * 131u + 7u);
size_t left = len;
while (left) {
size_t n = left < chunk ? left : chunk;
full_write(fd, buf, n);
left -= n;
}
if (fsync(fd) < 0)
die("fsync initial file");
if (lseek(fd, 0, SEEK_SET) < 0)
die("lseek");
free(buf);
}
static size_t resident_pages(void *addr, size_t len)
{
size_t pages = (len + (size_t)page_size - 1) / (size_t)page_size;
unsigned char *vec = calloc(pages, 1);
size_t resident = 0;
if (!vec)
die("calloc mincore vec");
if (mincore(addr, len, vec) < 0)
die("mincore");
for (size_t i = 0; i < pages; i++)
resident += vec[i] & 1;
free(vec);
return resident;
}
static void print_resident(const char *label, void *addr, size_t len)
{
size_t pages = (len + (size_t)page_size - 1) / (size_t)page_size;
size_t resident = resident_pages(addr, len);
printf("%-34s resident=%zu/%zu pages (%.1f%%)\n",
label, resident, pages, resident * 100.0 / pages);
}
static void print_fault_delta(const char *label,
const struct rusage *before,
const struct rusage *after)
{
printf("%-34s minor=%ld major=%ld\n",
label,
after->ru_minflt - before->ru_minflt,
after->ru_majflt - before->ru_majflt);
}
int main(int argc, char **argv)
{
size_t len = parse_mib(argc, argv);
char path[] = "/tmp/file-cache-demo-XXXXXX";
int fd;
void *map;
unsigned char *buf;
volatile uint64_t map_sum = 0;
uint64_t read_sum = 0;
struct rusage r1, r2;
page_size = sysconf(_SC_PAGESIZE);
if (page_size <= 0)
die("sysconf page size");
fd = mkstemp(path);
if (fd < 0)
die("mkstemp");
printf("arch-visible file cache demo\n");
printf("file=%s size=%zu MiB page_size=%ld\n",
path, len / 1024 / 1024, page_size);
create_file(fd, len);
die_fadvise(posix_fadvise(fd, 0, (off_t)len, POSIX_FADV_DONTNEED),
"posix_fadvise initial DONTNEED");
map = mmap(NULL, len, PROT_READ, MAP_SHARED, fd, 0);
if (map == MAP_FAILED)
die("mmap");
print_resident("after initial DONTNEED", map, len);
buf = malloc(1024 * 1024);
if (!buf)
die("malloc read buffer");
full_read(fd, buf, len, 1024 * 1024, &read_sum);
free(buf);
print_resident("after read()", map, len);
if (getrusage(RUSAGE_SELF, &r1) < 0)
die("getrusage before mmap touch");
for (size_t off = 0; off < len; off += (size_t)page_size)
map_sum += ((volatile unsigned char *)map)[off];
if (getrusage(RUSAGE_SELF, &r2) < 0)
die("getrusage after mmap touch");
print_fault_delta("mmap touch after read()", &r1, &r2);
printf("checksums read=%" PRIu64 " mmap=%" PRIu64 "\n", read_sum, map_sum);
if (madvise(map, len, MADV_DONTNEED) < 0)
die("madvise MADV_DONTNEED");
die_fadvise(posix_fadvise(fd, 0, (off_t)len, POSIX_FADV_DONTNEED),
"posix_fadvise final DONTNEED");
print_resident("after mmap+file DONTNEED", map, len);
if (munmap(map, len) < 0)
die("munmap");
if (close(fd) < 0)
die("close");
if (unlink(path) < 0)
die("unlink");
return 0;
}
我在 linux/amd64 容器里跑过,容器内 uname -m 是 x86_64:
bash
docker run --rm --platform linux/amd64 \
-v "$PWD:/work" -w /work \
alpine:3.20 sh -lc '
set -e
apk add --no-cache build-base >/dev/null
uname -m
gcc -O2 -Wall -Wextra -std=c11 \
file-cache-reclaim-demo.c \
-o /tmp/file-cache-reclaim-demo
/tmp/file-cache-reclaim-demo 64
'
真实输出:
text
x86_64
arch-visible file cache demo
file=/tmp/file-cache-demo-DAbKJo size=64 MiB page_size=4096
after initial DONTNEED resident=0/16384 pages (0.0%)
after read() resident=16384/16384 pages (100.0%)
mmap touch after read() minor=1025 major=0
checksums read=114688 mmap=114688
after mmap+file DONTNEED resident=0/16384 pages (0.0%)
这组结果对应源码路径:
text
posix_fadvise(DONTNEED)
│
▼
clean page cache 尽量被丢弃
│
▼
mincore: 0 / 16384 resident
read(fd)
│
▼
filemap_read -> filemap_get_pages
│
▼
folio 进入 address_space->i_pages
│
▼
mincore(mmap 区间): 16384 / 16384 resident
touch mmap
│
▼
filemap_fault 查到已有 page cache folio
│
├─ 建 PTE,有 minor fault
└─ 不需要磁盘 I/O,major fault = 0
madvise + posix_fadvise(DONTNEED)
│
▼
PTE 和 clean file cache 都可丢
│
▼
mincore: 0 / 16384 resident
实验里的 minor=1025 不是 16384,是因为内核可以用 fault-around、readahead 或较大 folio 等机制一次 fault 映射多页。关键点不是 minor fault 数量,而是 major=0:mmap 访问没有再从后端读数据,因为 read() 已经把同一批文件页放进了 page cache。
十、把文件页回收放回整条内存链路
现在可以把文件页和前几篇串起来:
text
用户 read()
│
▼
page cache miss
│
├─ 从 buddy 分配物理页 / folio
├─ 文件系统 read_folio / readahead 读入内容
└─ folio 挂入 address_space->i_pages 和 file LRU
用户 mmap 访问同一文件
│
▼
filemap_fault
│
├─ 查 address_space->i_pages
├─ 命中同一 folio
└─ 建立进程 PTE
内存压力
│
▼
shrink_lruvec 扫描 file LRU
│
├─ clean cold file folio
│ ├─ try_to_unmap
│ ├─ __remove_mapping
│ └─ free folio -> buddy
│
└─ dirty file folio
├─ 交给 writeback 或等待 writeback
└─ clean 后才可释放
这也解释了主线第 8 篇里那句话:用户数据页来自 buddy,但文件页不只属于某个进程。它们常常先属于文件的 page cache,再被一个或多个进程页表映射。
十一、常见误解
误解一:mmap 文件就是把文件直接搬进进程内存。
不是。mmap 主要创建 VMA。第一次访问才缺页,缺页时再查 page cache 或发起 I/O。
误解二:read() 和 mmap() 各有一份缓存。
普通 buffered I/O 和普通文件映射共用文件的 address_space->i_pages。read() 可以填充 mmap 后续会查到的 page cache,反过来也一样。
误解三:文件映射 PTE 被 unmap,就表示 page cache 已释放。
不是。PTE 是进程页表状态;page cache 是文件 address_space 状态。unmap PTE 后,folio 仍可能留在 page cache 里。
误解四:文件页一定比匿名页先回收。
不绝对。clean cold file page 通常成本低,但 dirty file page 要 writeback,hot file page 可能被 workingset/refault 保护,匿名页如果有 swap 也可能被回收。
误解五:writeback 会释放内存。
writeback 只是把 dirty file page 写成 clean。释放物理页还需要 reclaim 把 clean folio 从 page cache 移除。
十二、收束
这一篇需要记住的主线是:
text
文件页的核心容器是 address_space->i_pages。
read() 和 mmap fault 都会查同一个 page cache。
clean file page 可以被直接移出 page cache,
因为内容还能从文件后盾重新读取。
dirty file page 不能直接丢,
必须先 writeback。
unmap PTE 只是撤销进程映射,
remove_mapping 才是从 page cache 删除 folio。
最后压成一张图:
text
file folio on LRU
│
▼
referenced?
│
├─ yes -> protect / activate
└─ no
│
▼
mapped by PTE?
│
├─ yes -> try_to_unmap
└─ no / unmapped
│
▼
dirty or writeback?
│
├─ clean
│ └─ __remove_mapping -> free folio
│
├─ dirty
│ └─ writeback first -> clean later
│
└─ writeback
└─ mark reclaim / wait in selected cases
下一篇继续拆 writeback:write() 为什么不一定立刻落盘,脏页阈值如何限制写入者,flusher/writeback 线程怎么按 backing device 组织写回,以及 page reclaim 遇到脏页时到底是在回收还是在等 I/O。
参考源码
本文源码判断基于 Linux stable v6.12.65:
include/linux/fs.h:struct address_space/address_space_operationsmm/filemap.c:__filemap_get_folio()mm/filemap.c:filemap_get_pages()/filemap_read()mm/filemap.c:filemap_fault()mm/vmscan.c:shrink_folio_list()mm/vmscan.c:pageout()/__remove_mapping()/remove_mapping()mm/truncate.c:mapping_evict_folio()