文件页怎么回收:page cache、干净页和脏页

文件页怎么回收:page cache、干净页和脏页

番外一讲了回收入口:水位线紧张时,分配路径可能唤醒 kswapd,更紧张时当前分配者可能进入 direct reclaim。

番外二讲了扫描对象:回收框架在 lruvec 里按 anon/fileactive/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.65include/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.65mm/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。它做四件事:

  1. 创建一个普通文件,写入 64 MiB 数据并 fsync(),然后对这个文件调用 posix_fadvise(POSIX_FADV_DONTNEED),尽量让 clean cache 离开内存。
  2. mmap() 同一个文件,但先不访问映射,用 mincore() 看 page cache 驻留页数。
  3. read() 读完整个文件,再用 mincore() 看刚才那个 mmap 区间是否已经 resident。
  4. 访问 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 -mx86_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_pagesread() 可以填充 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

相关推荐
小宇子2B21 小时前
匿名页怎么回收:反向映射、swap entry、swap cache 和换入
操作系统
小宇子2B21 小时前
区间树:反向映射的前置数据结构
操作系统
半条-咸鱼1 天前
【FreeRTOS】核心原理与实战速查手册
c语言·操作系统·rtos
花花无缺2 天前
Windows 定时执行 Python 脚本方案
python·操作系统·命令行
小宇子2B2 天前
LRU 不只是最近最少使用:active/inactive、anon/file、folio 和分代 LRU
操作系统
凉、介2 天前
Linux 设备驱动匹配机制
linux·笔记·单片机·学习·操作系统·嵌入式
沅姬3 天前
如何优雅地关闭找不到终端的 roslaunch 进程
操作系统
小宇子2B4 天前
从 malloc 到物理页:把 Linux 内存管理链路串起来
操作系统
无忧.芙桃4 天前
线程同步与线程互斥(上)
linux·运维·操作系统·运维开发