匿名页怎么回收:反向映射、swap entry、swap cache 和换入

匿名页怎么回收:反向映射、swap entry、swap cache 和换入

看本篇之前请先看前置文章,如果对本篇所讲的反向映射数据结构熟悉的可以跳过前置文章直接看本篇。

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

番外二讲的是扫描对象:回收框架不是维护一条精确全局 LRU,而是在 lruvec 里按 anon/fileactive/inactive 或 Multi-Gen LRU 的 generation 近似判断冷热。

这一篇继续拆番外二结尾留下的问题:

text 复制代码
   匿名页没有文件后盾。

   那 Linux 为什么还能回收它?
   回收时怎么找到所有映射这个物理页的 PTE?
   PTE 不再指向物理页以后,里面还剩下什么?
   swap cache 为什么存在?
   之后再次访问,内核怎么把页换回来?

先把结论放在前面。以 Linux stable v6.12.65 源码为准,普通匿名页换出的大路径是:

text 复制代码
   shrink_lruvec
        │
        ▼
   shrink_inactive_list
        │
        ▼
   shrink_folio_list
        │
        ├─ 匿名 swap-backed folio 还没有 swap cache?
        │      │
        │      └─ add_to_swap
        │            ├─ 分配 swap slot
        │            └─ 把 folio 加入 swap cache
        │
        ├─ folio 仍被进程页表映射?
        │      │
        │      └─ try_to_unmap
        │            ├─ 通过 rmap 找到 VMA 和 PTE
        │            ├─ 清掉 present PTE
        │            └─ 写入 not-present swap PTE
        │
        ├─ folio 是 dirty?
        │      │
        │      └─ pageout / swap_writepage
        │            └─ 写入 zswap / zram / swap 设备或 swap 文件
        │
        └─ 后续在合适时机释放物理页

   再次访问这个虚拟地址
        │
        ▼
   page fault
        │
        ▼
   handle_pte_fault 发现 PTE not present
        │
        ▼
   do_swap_page
        │
        ├─ pte_to_swp_entry 取出 swap entry
        ├─ 先查 swap cache
        ├─ 命中:直接重新建立 present PTE,通常是 minor fault
        └─ 未命中:swapin_readahead / swap_read_folio 读回,通常是 major fault

这条路径说明一个核心事实:

匿名页不是被"丢弃"了,而是把物理页内容搬到了另一个后盾里。PTE 从"present + PFN"变成"not present + swap entry"。swap entry 记录去哪里找旧内容。

一、匿名页在回收前到底长什么样

主线第 5 篇讲过,VMA 是"地址区间是否合法"的承诺,PTE 是"这个虚拟页现在能不能被硬件翻译"的兑现状态。

一个已经被写过的匿名页,大致是这样:

text 复制代码
   进程虚拟地址 A
        │
        ▼
   VMA: [start, end) rw-p anonymous
        │
        ▼
   PTE(A): present, writable, PFN = X
        │
        ▼
   struct page / folio for PFN X
        ├─ anon
        ├─ swap-backed
        ├─ 在 anon LRU 上
        ├─ 通过 anon_vma/rmap 能找到映射它的 VMA/PTE
        └─ 内容是进程私有数据

这里有两个容易混淆的点。

第一,匿名页不是"不属于任何文件"就没有元数据。它没有普通磁盘文件作为后盾,但它仍然有 struct page / folio、LRU 状态、mapcount、memcg 记账、反向映射信息。

第二,回收扫描的起点通常不是"某个进程的某个虚拟地址",而是 LRU 上的物理页/folio。也就是说,回收器先拿到的是:

text 复制代码
   这个 folio 看起来冷,可以尝试回收。

但要把它回收掉,内核必须反过来回答:

text 复制代码
   哪些进程的哪些 PTE 还指向这个 folio?

这就是反向映射 rmap 的位置。

二、为什么需要反向映射

页表是正向映射:

text 复制代码
   mm(进程地址空间 mm_struct)+ virtual address -> PTE -> PFN(物理页框号) -> folio

回收时手里拿到的是 folio(现代内核里的页管理单位,可以先理解成"一页或一组连续 base page"),所以要走反向:

text 复制代码
   folio -> anon_vma / mapping -> VMA -> virtual address -> PTE

前文只做过两层铺垫:第 2 篇讲过 struct page 是物理页框的元数据,番外二讲过 folio 是现代内核里"一页或一组 base page 作为一个整体"的管理单位。但前文没有系统展开 struct folio 的静态布局。

本文不需要完整记住 struct folio,只需要知道 v6.12.65 里它有几类和回收相关的元数据:

text 复制代码
   struct folio
   ├─ flags               页状态标志,例如 anon、dirty、writeback、swapcache 等
   ├─ lru                 挂到 LRU / reclaim 相关链表
   ├─ mapping             指向 address_space(文件/page cache 的映射对象),或编码匿名页的 anon_vma 关系
   ├─ index               在所属映射/关系坐标里的页偏移;匿名 rmap 里常用作 pgoff
   ├─ private / swap      私有字段;进入 swap cache 后可保存 swap entry
   ├─ _mapcount           有多少页表映射
   └─ _refcount           folio 引用计数

这里最重要的是 mappingindex:回收器拿到 folio 后,正是靠这类元数据找到"它关联哪组 anon_vma、在这套坐标里的 pgoff 是多少",然后才有后面的 rmap 查找。

下面按六个问题把匿名页 rmap 讲清楚:

text 复制代码
   anon_vma 是什么,结构长什么样?
   anon_vma 什么时候创建,怎么创建?
   anon_vma、folio、VMA 怎么关联?
   folio 怎么通过 anon_vma 找到覆盖它的 VMA?
   为什么需要 anon_vma?
   区间树在内核 rmap 里具体怎么用?

1. anon_vma 是什么,具体结构如何

anon_vma 前文也只点过名:第 4 篇vm_area_struct 字段图里说过它是"匿名页反向映射相关结构",但没有展开它怎么工作。

从这里开始,可以先把 anon_vma 理解成:

text 复制代码
   匿名页 rmap 用来组织一组相关 VMA 的关系域。

它不是页表,不保存 PTE;也不是"每个物理页一个对象"。它表达的是一组因为匿名映射、fork()、COW 等关系而相关的 VMA。这个关系域的粒度更接近:

text 复制代码
   一组可能共享或曾经共享同一批匿名页历史的 VMA。

先看 anon_vmaanon_vma_chain 里本文会用到的字段,不需要背完整源码。

anon_vma 里重点看这些:

text 复制代码
   anon_vma
   ├─ root / parent
   │    表示 fork、COW 后形成的 anon_vma 层级关系。
   │    rmap 检查时常会比较 root,判断两个 VMA 是否在同一组匿名关系里。
   │
   ├─ rwsem
   │    保护这组 rmap 关系的读写锁。
   │    回收扫描时通常拿读锁,VMA 关系变化时拿写侧保护。
   │
   └─ rb_root
        一棵 interval tree。
        树里挂的是 anon_vma_chain,
        按 VMA 覆盖的页偏移范围组织。

anon_vma_chain 里重点看这些:

text 复制代码
   anon_vma_chain
   ├─ vma
   │    指向一个可能映射相关匿名页的 vm_area_struct。
   │
   ├─ anon_vma
   │    指回它所属的 anon_vma。
   │
   ├─ same_vma
   │    链表节点,挂到 vma->anon_vma_chain
   │    (这条链表表示这个 VMA 关联的所有 anon_vma 关系域)。
   │
   └─ rb / rb_subtree_last
        红黑树节点和 interval tree 辅助字段,
        挂到 anon_vma->rb_root,用于按页偏移范围查找 VMA。

这两个结构的分工是:

text 复制代码
   anon_vma:
      表示一组匿名页反向映射关系。
      它不是页表,不保存 PTE。
      它的 rb_root 是一棵 interval tree,用来按页偏移范围索引相关 VMA。

   anon_vma_chain:
      是连接 VMA 和 anon_vma 的桥。
      一个 anon_vma_chain 同时知道:
         我连着哪个 VMA
         我连着哪个 anon_vma

anon_vma_chain 里有两个"挂接点",这就是它容易绕的地方:

text 复制代码
   same_vma:
      挂到 vma->anon_vma_chain 链表上。
      用来从一个 VMA 找到它关联的所有 anon_vma。

   rb:
      挂到 anon_vma->rb_root interval tree 里。
      用来从一个 anon_vma + pgoff 找到覆盖该页偏移的 VMA。

   rb_subtree_last:
      interval tree 的增强字段。
      它记录子树里最大的结束偏移,帮助快速跳过不可能命中的分支。

这两个挂接点不是重复记录,而是服务两种相反的查询入口:

text 复制代码
   方向一:folio / anon_vma -> VMA

      场景:
         LRU 回收、引用检查、迁移等路径从一个 folio 出发。
         内核手里先有的是 folio,不是某个进程的虚拟地址。

      查询:
         folio->mapping 取出 anon_vma
         folio->index   给出 pgoff
         anon_vma->rb_root interval tree 按 pgoff 找候选 VMA

      为什么要这样查:
         回收器要把所有映射这个 folio 的 PTE 找出来,
         但它不能扫描所有进程的所有 VMA。
         所以必须从 folio 归属的 anon_vma 出发,
         用 interval tree 快速缩小到覆盖这个 pgoff 的 VMA。

   方向二:VMA -> anon_vma

      场景:
         fork、munmap、VMA split/merge、VMA 释放等路径从一个 VMA 出发。
         内核要克隆、调整或拆掉这个 VMA 和匿名 rmap 的关系。

      查询:
         遍历 vma->anon_vma_chain
         每个 anon_vma_chain 里有 avc->anon_vma

      为什么要这样查:
         一个 VMA 可能因为 fork/COW 历史关联多个 anon_vma。
         VMA 生命周期变化时,内核必须知道这个 VMA 挂进过哪些
         anon_vma->rb_root,才能把对应的 avc 克隆、移动或移除。
         如果没有 vma->anon_vma_chain,就只能反过来扫描所有 anon_vma 的树。

所以这不是"两份关系",而是同一个 anon_vma_chain 同时提供两个索引方向:rbanon_vma 能按区间找到 VMA,same_vma 让 VMA 能找到自己连到的所有 anon_vma

把关系画出来是这样:

text 复制代码
   VMA P1
   ├─ vma->anon_vma = anon_vma B
   │    当前 VMA 新建匿名 folio 时默认使用的 anon_vma。
   │
   └─ vma->anon_vma_chain
        这条链表表示:VMA P1 关联了哪些 anon_vma 关系域。
        │
        ├─ avc1 = struct anon_vma_chain
        │    ├─ avc1->vma      = VMA P1
        │    ├─ avc1->anon_vma = anon_vma A
        │    ├─ avc1->same_vma 挂在 VMA P1 的链表里
        │    └─ avc1->rb       挂在 anon_vma A 的 rb_root interval tree 里
        │
        └─ avc2 = struct anon_vma_chain
             ├─ avc2->vma      = VMA P1
             ├─ avc2->anon_vma = anon_vma B
             ├─ avc2->same_vma 挂在 VMA P1 的链表里
             └─ avc2->rb       挂在 anon_vma B 的 rb_root interval tree 里

   anon_vma A
   └─ anon_vma A->rb_root interval tree
        按 avc->vma 覆盖的 pgoff 范围索引。
        │
        ├─ avc1 -> VMA P1, pgoff [80, 120]
        └─ avc3 -> VMA P2, pgoff [100, 140]

   anon_vma B
   └─ anon_vma B->rb_root interval tree
        按 avc->vma 覆盖的 pgoff 范围索引。
        │
        ├─ avc2 -> VMA P1, pgoff [80, 120]
        └─ avc4 -> VMA P3, pgoff [300, 360]

如果想逐条看清楚 same_vma 链表、rb_root 区间树和 folio 查询路径,可以打开这个交互图:

打开 anon_vma / anon_vma_chain 关系交互图:点击字段逐条追踪指针关系

注意,图里的 avc1 不是复制了两份。它是同一个 anon_vma_chain 对象:same_vma 这个链表节点挂进 VMA P1vma->anon_vma_chainrb 这个红黑树节点挂进 anon_vma Arb_root interval tree。avc2 也是同理。

所以 anon_vma 的具体结构要抓住一句话:

text 复制代码
   anon_vma 用 interval tree 索引"哪些 VMA 覆盖哪些匿名页偏移范围";
   anon_vma_chain 是同时挂在 VMA 和 anon_vma 两边的连接节点。

2. anon_vma 什么时候创建,怎么创建

这里先把一个容易混的问题说清楚:anon_vma 不是 mmap(MAP_ANON | MAP_PRIVATE) 时就为每个 folio 建好的。mmap() 主要建立 VMA,这时可能还没有任何物理页;匿名读缺页还可能只映射全局 zero page,也不会产生一个私有匿名 folio。

真正需要匿名 rmap 的时机,是内核准备把一个"私有匿名 folio"插入某个 VMA 的页表时。典型场景有两类:

text 复制代码
   匿名写缺页:
      do_anonymous_page
        ├─ vmf_anon_prepare
        │    └─ __anon_vma_prepare(vma)
        │         确保 vma->anon_vma 存在
        ├─ alloc_anon_folio
        └─ folio_add_new_anon_rmap
             └─ __folio_set_anon

   私有映射 COW 写缺页:
      wp_page_copy
        ├─ vmf_anon_prepare
        ├─ 分配新的匿名 folio
        └─ folio_add_new_anon_rmap
             └─ __folio_set_anon

__anon_vma_prepare(vma) 可以简化理解成三件事:

text 复制代码
   1. 如果 vma->anon_vma 已经存在,直接复用。
   2. 如果还没有,就分配或复用一个合适的 anon_vma。
   3. 分配 anon_vma_chain,
      把这个 VMA 同 anon_vma 连起来:
         avc->same_vma 挂到 vma->anon_vma_chain
         avc->rb       挂到 anon_vma->rb_root interval tree

fork() 不是给旧 folio 重新设置 mapping,而是扩展 VMA 和 anon_vma 的关系。anon_vma_fork() / anon_vma_clone() 会给子进程 VMA 建立新的 anon_vma_chain,让旧的、尚未 COW 的匿名 folio 通过原来的 anon_vma 也能找到子进程 VMA。之后某一方写入触发 COW,新分配的 folio 才会按当前 VMA 的 anon_vma 建立自己的 rmap 身份。

3. anon_vma、folio 和 VMA 怎么关联

所以要分成两步看:

text 复制代码
   第一步:给 VMA 准备 anon_vma

      vma->anon_vma = 某个 anon_vma
      anon_vma_chain 把这个 VMA 挂进 anon_vma->rb_root interval tree

   第二步:给 folio 设置匿名 rmap 身份

      folio->mapping 编码这个 anon_vma 指针
      folio->index   记录这个 folio 在该 VMA 坐标里的页偏移

从三方分别看,是这几条线:

方向 字段 / 结构 含义
VMA -> anon_vma vma->anon_vma 当前 VMA 建立新匿名 folio rmap 时使用的 anon_vma
VMA -> 多个 anon_vma vma->anon_vma_chain 这个 VMA 关联到的所有 anon_vma_chain,fork/COW 历史会让它不只连一个关系域。
anon_vma -> VMA anon_vma->rb_root interval tree,里面挂 anon_vma_chain,按 VMA 覆盖的 pgoff 范围索引。
folio -> anon_vma folio->mapping 对匿名 folio 来说,低位打标后编码 anon_vma 指针。
folio -> pgoff folio->index 记录这个 folio 在匿名 rmap 坐标里的起始页偏移。

也就是说,anon_vma 不是作为链表节点"挂到 folio 上"。真正挂树的是 anon_vma_chain,它连接 VMA 和 anon_vma;folio 里只是用 mapping 字段编码一个 anon_vma 指针,作为后续反向查找的入口。

一个 folio 归到哪组 anon_vma 关系,是在它第一次建立匿名 rmap 时确定的。v6.12.65mm/rmap.c 里,__folio_set_anon() 做了两件关键事:

text 复制代码
   folio->mapping = anon_vma + PAGE_MAPPING_ANON
   folio->index   = linear_page_index(vma, address)

第一行不是普通指针赋值。folio->mapping 这个字段被复用了:

text 复制代码
   文件页:
      folio->mapping = struct address_space *

   普通匿名页:
      folio->mapping = anon_vma 指针 + PAGE_MAPPING_ANON 标记位

这里能这么做,是因为内核对象指针按对齐分配,低几位通常不会用于真实地址。Linux 就用低位标记区分 mapping 里放的是普通 address_space,还是匿名页的 anon_vma 关系。PAGE_MAPPING_ANONv6.12.65 里是 0x1

可以把它画成:

text 复制代码
   anon_vma A 的真实指针:
      0xffff888012340000

   写入 folio->mapping 时:
      0xffff888012340000 | PAGE_MAPPING_ANON
      = 0xffff888012340001

   读取时:
      folio_anon_vma(folio)
          先检查低位标记是不是 PAGE_MAPPING_ANON
          再把标记位去掉
          得到 anon_vma A

所以 folio->mapping 对匿名页来说不是"指向文件映射对象",而是"编码了 anon_vma 指针"。后面 folio_anon_vma(folio) 就是从这里把 anon_vma 取出来。

第二行定义了这个 folio 在匿名 rmap 坐标里的页偏移。linear_page_index(vma, address) 大致表示:

text 复制代码
   pgoff = ((address - vma->vm_start) >> PAGE_SHIFT) + vma->vm_pgoff

所以这个"对象粒度"不是按字节,也不是按一次 malloc 调用。它是按 VMA 的页偏移坐标来组织的;一个 folio 在这套坐标里占一个或多个连续 pgoff

另外,swap cache 也不会覆盖匿名 folio 的这组 rmap 元数据。进入 swap cache 时,内核设置的是 SwapCache 状态和 folio->swap;匿名 folio 的 mapping/index 仍然保留匿名 rmap 坐标。否则后面 try_to_unmap() 就无法从 folio 反查 VMA 和 PTE。

4. folio 怎么通过 anon_vma 找到覆盖它的 VMA

把这段关系压缩成一句更口语化的话,可以这样理解:

text 复制代码
   一个 anon_vma 的 interval tree 上,挂着一批 anon_vma_chain。
   每个 anon_vma_chain 指向一个 VMA。

   folio 通过 mapping 找到 anon_vma,
   再通过 index / folio_pgoff 得到自己覆盖的 pgoff 范围。

   rmap 会在 anon_vma 的 interval tree 里找:
      哪些 VMA 的匿名页偏移范围,和这个 folio 的 pgoff 范围相交?

   因为 folio 可能包含多个 base page,
   所以这里查的是一个 pgoff 范围,不一定只是单个页偏移。

   命中的 VMA 只是候选。
   内核还要把 pgoff 反算成虚拟地址,
   再走页表确认对应 PTE 是否真的指向这个 folio。

前面几段建立的关系,服务的就是这条查找路径:folio 自己不挂在 anon_vma 的树里,树里挂的是 anon_vma_chain;folio 只是用 mapping/index 提供入口和坐标。

接下来要注意:mapping + index 不是直接找到唯一 VMA。它只给出:

text 复制代码
   mapping -> 哪组 anon_vma 关系
   index   -> 在这组关系里的页偏移 pgoff

真正找 VMA 还要走 anon_vma->rb_root。过程是:

text 复制代码
   folio->mapping
        │
        │  去掉 PAGE_MAPPING_ANON 标记
        ▼
   anon_vma A
        │
        │  folio->index = pgoff 100
        ▼
   在 anon_vma A 的 rb_root interval tree 里查:
      哪些 VMA 覆盖 pgoff 100?
        │
        ├─ VMA P1: 覆盖 [80, 120]   -> 命中
        ├─ VMA P2: 覆盖 [100, 140]  -> 命中
        └─ VMA P3: 覆盖 [300, 360]  -> 不命中

对每个命中的 VMA,内核再用 vma_address(vma, pgoff, nr_pages)pgoff 反算成用户虚拟地址:

text 复制代码
   address = vma->vm_start + ((pgoff - vma->vm_pgoff) << PAGE_SHIFT)

这里能反算,是因为每个 VMA 都定义了一段"虚拟地址 <-> pgoff"的线性坐标关系。建立 rmap 时,内核用 linear_page_index(vma, address) 从虚拟地址算出 pgoff:

text 复制代码
   pgoff = ((address - vma->vm_start) >> PAGE_SHIFT) + vma->vm_pgoff

反向查找时,folio 已经带着自己的 folio->index / folio_pgoff(folio),候选 VMA 也带着自己的 vm_startvm_pgoff。只要这个 VMA 的 pgoff 范围覆盖 folio 的 pgoff,就可以把这个 pgoff 反推成"如果它映射在这个 VMA 中,应该落在哪个用户虚拟地址"。

比如:

text 复制代码
   folio 的 pgoff = 100

   某个候选 VMA:
      vm_start = 0x70000000
      vm_pgoff = 80

   那么 pgoff 100 在这个 VMA 里的虚拟地址是:

      0x70000000 + ((100 - 80) << PAGE_SHIFT)
    = 0x70000000 + 20 * 4KB
    = 0x70014000

如果 folio 是大 folio,就不是只查一个 pgoff,而是查一段范围:

text 复制代码
   folio pgoff range = [100, 103]
   VMA   pgoff range = [ 98, 110]

这说明这个 VMA 可能覆盖 folio 里的多个 base page。内核再把这段 pgoff 范围换算成该 VMA 里的用户虚拟地址范围。

这和前面的 linear_page_index() 正好方向相反:

text 复制代码
   建立 rmap 时:
      VMA + address -> pgoff

   反向查找时:
      VMA + pgoff -> address

拿到 address 后,还没结束。这个 VMA 只是候选,页表可能已经变化。最后必须走 page_vma_mapped_walk() 进入该进程的页表,确认对应 PTE 当前是否真的指向这个 folio。

也就是说,匿名页 rmap 的关键关系可以抽象成四段:

text 复制代码
   第一段:folio -> anon_vma 关系域 + pgoff

      folio 元数据记录:
         我属于哪组 anon_vma 关系
         我在这组关系里的页偏移 pgoff 是多少

   第二段:anon_vma + pgoff -> 候选 VMA

      anon_vma 里有一棵 interval tree。
      这棵树按"VMA 覆盖的页偏移范围"组织 anon_vma_chain。
      给定 folio 的 pgoff,就能找出哪些 VMA 的范围可能覆盖它。

   第三段:VMA + pgoff -> virtual address

      找到候选 VMA 后,根据 VMA 的起点和偏移关系,
      算出这个 folio 如果映射在该 VMA 中,应该对应哪个用户虚拟地址。

   第四段:mm + virtual address -> PTE

      最后才走这个进程的页表,
      检查该地址的 PTE 是否真的 present,
      是否真的指向当前 folio 的 PFN。

画成一张图:

text 复制代码
   不是这样:

      PFN X ──► PTE* 列表
                ├─ PTE of process A
                ├─ PTE of process B
                └─ ...

   而是这样:

      folio X
      ├─ anon_vma = A
      └─ pgoff    = 100
             │
             ▼
      anon_vma A
      └─ rb_root interval tree
          ├─ [pgoff  80 - 120] -> anon_vma_chain -> VMA of process P1
          ├─ [pgoff 100 - 140] -> anon_vma_chain -> VMA of process P2
          └─ [pgoff 300 - 360] -> 不相关,不会被这次查询命中
             │
             ▼
      对每个命中的 VMA:
          address = vma_address(vma, folio_pgoff)
             │
             ▼
          PTE = walk_page_table(vma->vm_mm, address)
             │
             ▼
          检查 PTE 是否真的指向 folio X

所以这里有三类"映射",不要混在一起:

关系 谁到谁 作用
页表映射 mm + virtual address -> PTE -> PFN CPU 真正用来翻译地址。
rmap 候选索引 anon_vma + pgoff -> VMA 缩小搜索范围,找到可能映射该 folio 的 VMA。
连接结构 anon_vma_chain -> VMA + anon_vma 把一个 VMA 同它相关的匿名对象连起来,同时挂入 VMA 链表和 anon_vma interval tree。

前面给出的 anon_vma / anon_vma_chain 结构,正是上图里的 anon_vma A -> rb_root interval treeanon_vma_chain -> VMA 连接节点。建立匿名 VMA、fork、COW 相关关系时,内核会把 anon_vma_chain 接到 VMA 链表和 anon_vma interval tree 这两个方向里。

所以它确实有"表/树"一类的数据结构,但这张结构只回答:

text 复制代码
   哪些 VMA 可能映射这个匿名 folio?

它不直接回答:

text 复制代码
   哪些 PTE 当前一定指向这个 folio?

真正的 PTE 仍然要根据候选 VMA 现场计算地址、现场走页表、现场确认。

v6.12.65mm/rmap.c 里,rmap_walk() 对匿名 folio 走 rmap_walk_anon()。这条路径的核心步骤是:

text 复制代码
   1. 从 folio 找到 anon_vma

      anon_vma = folio_anon_vma(folio)

      匿名 folio 的 mapping 字段里会编码 anon_vma 指针。
      这里不是从所有进程里找,而是直接从 folio 元数据找到相关 anon_vma。

   2. 计算 folio 在匿名对象里的页偏移范围

      pgoff_start = folio_pgoff(folio)
      pgoff_end   = pgoff_start + folio_nr_pages(folio) - 1

      对普通 4KB 页,范围就是一个页偏移。
      对大 folio,范围覆盖多个 base page。

   3. 在 anon_vma->rb_root 这棵 interval tree 里找重叠 VMA

      anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root,
                                     pgoff_start, pgoff_end)

      找出来的是 anon_vma_chain。
      每个 avc 里有 avc->vma。

   4. 根据 VMA 和 folio offset 算出虚拟地址

      address = vma_address(vma, pgoff_start, folio_nr_pages(folio))

      这一步不是猜地址,而是使用 VMA 定义的线性坐标关系:

         pgoff = ((address - vma->vm_start) >> PAGE_SHIFT) + vma->vm_pgoff

      反过来得到:

         address = vma->vm_start + ((pgoff - vma->vm_pgoff) << PAGE_SHIFT)

      所以它能把"folio 在匿名 rmap 坐标里的偏移"
      转换成"如果它映射在这个 VMA 里,应该落在哪个用户虚拟地址"。

   5. 对这个 VMA + address 走页表

      page_vma_mapped_walk(...)

      这一步才真正进入页表,检查对应 PTE/PMD 是否存在、
      是否指向当前 folio 的 PFN 范围。

   6. 找到真实 PTE 后执行回调

      try_to_unmap_one(...)

      对匿名换出而言,回调会清 present PTE,
      再写入 not-present swap PTE。

简化成图:

text 复制代码
   folio
     │
     ├─ folio_test_anon(folio) == true
     │
     ▼
   folio_anon_vma(folio)
     │
     ▼
   anon_vma
     │
     ▼
   anon_vma->rb_root interval tree
      │
      ├─ anon_vma_chain -> VMA 1
      ├─ anon_vma_chain -> VMA 2
      └─ ...
             │
             ▼
        vma_address(vma, folio_pgoff)
             │
             ▼
        page_vma_mapped_walk
             │
             ├─ 找到 PTE,并确认 PTE 指向这个 folio
             └─ 没找到,说明这个候选 VMA 当前没有映射它

5. 为什么需要 anon_vma

回收匿名页时,内核的起点通常是 LRU 上的 folio,不是某个进程的虚拟地址。要释放这个 folio,内核必须先找到还在指向它的 PTE,把这些 present PTE 改成 swap PTE。问题是匿名页没有普通文件后盾,也就没有文件页那种天然的 address_space->i_mmap 可以反查 VMA。

所以匿名页需要 anon_vma 来补上这个"对象级反向索引":

text 复制代码
   文件页:
      folio->mapping -> address_space -> i_mmap interval tree -> 候选 VMA

   匿名页:
      folio->mapping -> anon_vma      -> rb_root interval tree -> 候选 VMA

Linux 没有为每个物理页维护一张简单的全局表:

text 复制代码
   PFN -> PTE 指针列表

那样做会带来几个问题:

  • 每建立或拆除一个 PTE 都要维护这张表,开销很高。
  • PTE 位于各进程的页表页里,生命周期、锁、并发规则都复杂,长期保存 PTE 指针很难安全。
  • 一个 folio 可能是大 folio / THP,可能被多个 VMA 以不同地址映射;直接保存 PTE 指针也不能绕过后续校验。

anon_vma 的设计更像:

text 复制代码
   对象级反向索引 + 运行时页表查验

它先用 anon_vma + pgoff 找到候选 VMA,再根据 VMA 反算虚拟地址,最后现场走页表确认 PTE。这样既避免扫描所有进程页表,也避免长期保存 PTE 指针。

anon_vma 还有一个关键作用:表达 fork() / COW 带来的匿名页共享历史。父子进程的 VMA 可能暂时映射同一批旧匿名 folio,之后某一方写入又会 COW 出新 folio。内核需要从旧 folio 找到所有相关 VMA,而不是只记住"它最初属于哪个进程"。这正是 anon_vmaanon_vma_chain 要组织一组相关 VMA 的原因。

所以本节的答案是:

Linux 有反向映射相关的数据结构,但不是一张直接的"物理页到 PTE"的全量反向映射表。匿名页 rmap 是通过 folio 里的 anon_vma 关系,借助 anon_vma->rb_root interval tree 找候选 VMA,再按 offset 算虚拟地址,最后实时走页表确认 PTE。

文件页也类似,但入口换成文件的 address_space。文件 folio 的 mapping 指向文件地址空间,文件地址空间里有 i_mmap interval tree,用来找到映射这个文件页范围的 VMA。匿名页走 anon_vma->rb_root,文件页走 mapping->i_mmap,二者都是"先找候选 VMA,再查页表",不是保存每个 PTE 指针。

这也解释了为什么第 4 篇讲 VMA、第 5 篇讲 PTE 时不能把它们看成孤立结构。匿名页回收正是把这两者重新串起来:回收器从 folio 出发,经 rmap 找回 VMA 和 PTE。

6. 区间树在内核 rmap 里具体怎么用

如果刚看完 番外三A,可以把那篇里的抽象模型直接替换成内核对象:

区间树模型 匿名页 rmap 里的对象 说明
查询对象 folio 回收器从 LRU 上拿到的底层页。
查询范围 [folio_pgoff, folio_pgoff + nr_pages - 1] 这个 folio 在匿名 rmap 坐标里的页偏移范围。
关系域 anon_vma folio 的 mapping 字段编码出来的匿名关系域。
树根 anon_vma->rb_root 一棵 struct rb_root_cached 表示的 interval tree。
树节点 anon_vma_chain 节点本身挂在树上,同时指向一个 vm_area_struct
节点区间 [vma->vm_pgoff, vma->vm_pgoff + vma_pages(vma) - 1] 这个 VMA 在同一套 pgoff 坐标里覆盖的范围。
增强字段 anon_vma_chain->rb_subtree_last 对应前置篇里的 maxEnd,记录子树里最大的结束 pgoff。
查询结果 一批 anon_vma_chain / VMA 只是候选 VMA,后面还要走页表确认。

这里最容易误会的一点是:区间不是直接存在 anon_vma_chain 的两个 start/end 字段里。 anon_vma_chain 挂树时,节点的 start/end 是从它指向的 VMA 实时算出来的:

text 复制代码
   start = avc_start_pgoff(avc)
         = avc->vma->vm_pgoff

   last  = avc_last_pgoff(avc)
         = avc->vma->vm_pgoff + vma_pages(avc->vma) - 1

也就是说,anon_vma_chain 是树节点,但"这个节点代表多大的区间"由 avc->vma 决定。rb_subtree_last 则是 interval tree 维护出来的增强值,用来表达"这棵子树里最大的 last 是多少"。

v6.12.65mm/interval_tree.c 里,匿名 rmap 这棵树不是手写一套查找循环,而是用通用宏生成:

text 复制代码
   INTERVAL_TREE_DEFINE(
       struct anon_vma_chain,
       rb,
       unsigned long,
       rb_subtree_last,
       avc_start_pgoff,
       avc_last_pgoff,
       static inline,
       __anon_vma_interval_tree
   )

这行宏把"节点类型、红黑树节点字段、增强字段、start 函数、last 函数"告诉通用 interval tree 模板。模板再生成匿名 rmap 专用的插入、删除和迭代函数。外面包一层更清晰的接口:

text 复制代码
   anon_vma_interval_tree_insert(avc, &anon_vma->rb_root)
   anon_vma_interval_tree_remove(avc, &anon_vma->rb_root)
   anon_vma_interval_tree_iter_first(root, first, last)
   anon_vma_interval_tree_iter_next(avc, first, last)

平时在 rmap 代码里看到的:

text 复制代码
   anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root,
                                  pgoff_start, pgoff_end)

可以理解成:

text 复制代码
   在 anon_vma 这棵 interval tree 里,
   找出所有 VMA 区间和 [pgoff_start, pgoff_end] 相交的 anon_vma_chain。

接着看这棵树什么时候维护。

当一个 VMA 第一次需要匿名 rmap,或者 fork() 需要让子进程 VMA 也能被旧匿名 folio 找到时,内核会创建 anon_vma_chain,然后把它同时挂到两个地方:

text 复制代码
   anon_vma_chain_link(vma, avc, anon_vma)
        │
        ├─ avc->vma      = vma
        ├─ avc->anon_vma = anon_vma
        │
        ├─ list_add(&avc->same_vma, &vma->anon_vma_chain)
        │     挂到这个 VMA 的 anon_vma_chain 链表上。
        │
        └─ anon_vma_interval_tree_insert(avc, &anon_vma->rb_root)
              从 anon_vma 出发,可以按 pgoff 区间找到候选 VMA。

这就是为什么前面一直说 anon_vma_chain 是桥:它一边挂在 vma->anon_vma_chain 链表(表示这个 VMA 关联了哪些 anon_vma 关系域)里,一边作为 interval tree 节点挂在 anon_vma->rb_root 里。

当 VMA 生命周期变化时,这棵树也要同步变化:

text 复制代码
   VMA 建立匿名 rmap:
      分配 avc,插入 anon_vma->rb_root。

   fork:
      子进程 VMA 通过 anon_vma_clone / anon_vma_fork
      继承或建立新的 avc,
      让旧 folio 的 anon_vma 也能找到子进程 VMA。

   VMA 拆分、合并、释放:
      相关路径会克隆、链接或移除 avc。
      旧区间不能继续留在树里,
      新区间要以新的 start/last 进入树。

插入和删除时,底层红黑树负责平衡;interval tree 增强层负责维护 rb_subtree_last。所以它和前置篇里的模型完全对应:

text 复制代码
   普通红黑树顺序:
      按 start pgoff 排列节点。

   interval tree 增强字段:
      每个节点维护子树最大 last pgoff。

   查询剪枝:
      如果某棵子树的 rb_subtree_last 小于查询起点,
      这棵子树不可能有重叠区间,可以跳过。

最后把它放回匿名页回收的真实调用路径。

rmap_walk_anon() 拿到一个待处理 folio 后,核心逻辑是:

text 复制代码
   anon_vma = folio_anon_vma(folio)

   pgoff_start = folio_pgoff(folio)
   pgoff_end   = pgoff_start + folio_nr_pages(folio) - 1

   anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root,
                                  pgoff_start, pgoff_end) {
       vma = avc->vma
       address = vma_address(vma, pgoff_start, folio_nr_pages(folio))
       rwc->rmap_one(folio, vma, address, rwc->arg)
   }

这里的 rwc->rmap_one 是调用方给 rmap walker 的回调。匿名页回收走 try_to_unmap() 时,这个回调就是后面的 try_to_unmap_one()。所以区间树只负责把范围快速缩小到"可能相关的 VMA",并不直接修改 PTE。

举个具体例子:

text 复制代码
   folio X:
      anon_vma = A
      pgoff range = [100, 101]

   anon_vma A 的 interval tree:
      avc1 -> VMA P1, pgoff range [ 80, 120]
      avc2 -> VMA P2, pgoff range [100, 140]
      avc3 -> VMA P3, pgoff range [200, 260]

   查询 [100, 101] 命中:
      avc1 / P1
      avc2 / P2

   查询 [100, 101] 不命中:
      avc3 / P3

对命中的 P1P2,rmap walker 再做两步:

text 复制代码
   1. 用 vma_address() 把 pgoff 反算成该 VMA 里的虚拟地址。

   2. 用 page_vma_mapped_walk() 进入这个 mm 的页表,
      检查对应 PTE/PMD 是否真的指向 folio X。

第二步不能省。原因是 interval tree 的命中只说明"这个 VMA 的 pgoff 范围覆盖 folio 的 pgoff",并不保证页表当前一定还映射它。页表可能有洞,可能已经 COW 到别的 folio,可能已经被并发 unmap,或者这个范围里只有一部分 base page 还指向目标 folio。只有 page_vma_mapped_walk() 看见真实 PTE/PMD 后,try_to_unmap_one() 才能把 present PTE 改成 swap PTE。

所以区间树在匿名页反向映射中的实际位置可以压成一张图:

text 复制代码
   folio X
      │
      ├─ mapping  -> anon_vma A
      └─ index    -> pgoff range [first, last]
                      │
                      ▼
   anon_vma A 的 rb_root interval tree
      │
      ├─ 节点: anon_vma_chain
      ├─ 区间: avc->vma 的 [vm_pgoff, vm_pgoff + vma_pages - 1]
      └─ 增强: rb_subtree_last
                      │
                      ▼
   找到重叠区间对应的候选 VMA
                      │
                      ▼
   vma_address 反算虚拟地址
                      │
                      ▼
   page_vma_mapped_walk 查真实 PTE/PMD
                      │
                      ▼
   try_to_unmap_one 清 present PTE,写入 swap PTE

也就是说,区间树不是"物理页到 PTE 的表"。它在内核 rmap 中承担的是中间索引:用 folio 的 pgoff 范围,在对应 anon_vma 的 VMA 区间集合里快速找候选 VMA。 真正的页表项仍然要靠后续 page table walk 现场确认。

三、源码主线一:shrink_folio_list() 决定怎么处理匿名 folio

番外二讲到 shrink_lruvec() 会扫描 anon/file 的冷热列表。继续往下,传统 LRU 路径会把候选 folio 送进 shrink_folio_list()

在 Linux v6.12.65mm/vmscan.c 中,shrink_folio_list() 对匿名页的关键判断可以抽象成下面的伪代码:

c 复制代码
if (folio_test_anon(folio) && folio_test_swapbacked(folio)) {
    if (!folio_test_swapcache(folio)) {
        if (!reclaim_context_allows_io)
            keep_it;

        if (folio_maybe_dma_pinned(folio))
            keep_it;

        if (large_folio_needs_split)
            split_or_keep_it;

        if (!add_to_swap(folio))
            activate_or_split_it;
    }
}

if (folio_mapped(folio)) {
    try_to_unmap(folio, flags);
    if (folio_still_mapped)
        activate_it;
}

if (folio_test_dirty(folio)) {
    pageout(folio, mapping, ...);
}

if (can_remove_mapping)
    free_the_physical_folio;

如果把视角固定到"回收一个普通匿名 folio X",步骤可以更具体地写成:

text 复制代码
   1. LRU 扫描选中 folio X

      shrink_lruvec / shrink_inactive_list 把它交给 shrink_folio_list。
      这里的起点是物理页元数据 folio,不是某个进程的虚拟地址。

   2. 判断它能不能按普通匿名页回收

      它需要是 anon、swap-backed,并且当前上下文允许做必要的 swap I/O。
      如果被 DMA pin、大 folio 暂时不能整体处理、或者不适合当前回收上下文,
      内核可能保留、激活、拆分,或者稍后重试。

   3. 如果还没有 swap slot,先调用 add_to_swap

      add_to_swap 会给 folio X 分配 swap entry,
      把它加入 swap cache,
      并记录 folio->swap = entry。

      这一步必须在拆 PTE 之前完成,
      因为后面要把 present PTE 改成指向这个 swap entry 的 swap PTE。

   4. 如果 folio X 仍然被页表映射,调用 try_to_unmap

      try_to_unmap 通过 folio->mapping 找到 anon_vma,
      通过 folio->index / folio_pgoff 得到 pgoff 范围,
      再从 anon_vma->rb_root 里找覆盖这个范围的候选 VMA。

   5. 对每个候选 VMA 走页表确认真实 PTE

      rmap 先把 pgoff 反算成用户虚拟地址,
      再用 page_vma_mapped_walk 进入对应进程的页表。
      如果 PTE 确实指向 folio X,try_to_unmap_one 才会处理它。

   6. 把 present PTE 改成 not-present swap PTE

      原来的 PTE 指向 PFN X。
      拆映射后,PTE 不再保存 PFN,
      而是保存 swap entry 和必要的软件标志。

   7. 处理 dirty / writeback / 释放

      如果 folio 内容还没有写到 swap 后端,需要 pageout。
      写出完成并且没有页表映射、引用也允许移除时,
      物理 folio 才能从内存里释放。

这段伪代码对应几个源码事实:

  • 匿名 swap-backed folio 如果还没有进入 swap cache,回收路径先调用 add_to_swap()
  • 大 folio / THP 可能需要拆分,不能简单按 4KB 页模型理解所有情况。
  • 如果 folio 仍被页表映射,回收路径调用 try_to_unmap() 去拆 PTE。
  • PTE 被拆以后,folio 如果是 dirty,需要 pageout() 写到 swap 后端。
  • 写出和释放物理页不一定在同一个瞬间完成。folio 可能先变成 swap cache 中的页,稍后再被真正释放。

这里的"匿名 swap-backed"也值得解释。

普通被写过的堆、栈、匿名 mmap 页通常是 swap-backed:没有普通文件后盾,但可以用 swap 作为后盾。

但不是所有匿名页都必须换出。比如 MADV_FREE 语义下的 lazyfree 页,如果还没有被重新写脏,内核可以直接丢弃,未来再访问得到零页或新页。源码里也有 folio_test_anon(folio) && !folio_test_swapbacked(folio) 的 lazyfree 分支。本文主线讲的是普通匿名私有数据页。

四、add_to_swap():先有 swap slot,再能把 PTE 改成 swap entry

匿名页不能像干净文件页那样直接从 page cache 移除,因为它没有文件可以重新读。

所以回收普通匿名页前,内核要先给它找一个新后盾:

text 复制代码
   swap slot:
      某个 swap type + 某个 offset

Linux 用 swp_entry_t 表示这个位置。include/linux/swapops.h 里能看到它的基本思想:swp_entry_t 在架构无关格式里主要编码 typeoffsettype 表示哪一个 swap 设备/文件,offset 表示其中的页偏移。

抽象成图:

text 复制代码
   swp_entry_t
   ┌───────────────┬──────────────────────┐
   │ swap type      │ swap offset          │
   └───────────────┴──────────────────────┘
        │                  │
        │                  └─ 在这个 swap 后端里的页号
        └─ 第几个 swap 后端,例如 /var/lib/swap 这个 swap file

add_to_swap() 做两件关键事:

text 复制代码
   add_to_swap(folio)
        │
        ├─ folio_alloc_swap(folio)
        │     └─ 分配 swap slot,得到 swp_entry_t
        │
        └─ add_to_swap_cache(folio, entry, ...)
              ├─ 设置 folio 的 SwapCache 状态
              ├─ folio->swap = entry
              └─ 把 folio 插入 swap cache 的 XArray

swap cache 的组织方式和普通文件 page cache 很像,也是 address_space + XArray 这一套。区别是普通文件 page cache 的 key 来自文件 offset;swap cache 的 key 来自 swap entry。

mm/swap_state.c 中,add_to_swap_cache() 会:

  • 找到 swap_address_space(entry)
  • swap_cache_index(entry) 作为 XArray index。
  • 设置 folio_set_swapcache(folio)
  • 保存 folio->swap = entry
  • 增加 NR_SWAPCACHE 统计。

这一步非常关键,因为后面 rmap 拆 PTE 时要把 PTE 改成"去这个 swap entry 找内容"。如果 folio 还没有 swap entry,PTE 就没有地方可指。

五、try_to_unmap():通过 rmap 把 present PTE 改成 swap PTE

有了 swap entry 以后,回收路径还不能直接释放物理页。原因是进程页表里可能仍有 PTE 指向它:

text 复制代码
   PTE(A): present + PFN X

如果此时直接释放 PFN X,用户进程还能通过旧 PTE 访问这页,那就是内存破坏。

所以 shrink_folio_list() 会在 folio 仍被映射时调用:

text 复制代码
   try_to_unmap(folio, flags)

try_to_unmap() 本身只是搭好 rmap_walk_control,真正每个 PTE 的处理在 try_to_unmap_one()

这条路径可以画成:

flowchart TD A[&#34;shrink_folio_list 拿到冷匿名 folio&#34;] --> B{&#34;folio 在 swap cache?&#34;} B -- &#34;否&#34; --> C[&#34;add_to_swap: 分配 swap slot 并加入 swap cache&#34;] B -- &#34;是&#34; --> D[&#34;已有 folio->swap&#34;] C --> E{&#34;folio 仍被页表映射?&#34;} D --> E E -- &#34;是&#34; --> F[&#34;try_to_unmap&#34;] F --> G[&#34;rmap_walk_anon&#34;] G --> H[&#34;anon_vma interval tree 找 VMA&#34;] H --> I[&#34;page_vma_mapped_walk 找 PTE&#34;] I --> J[&#34;清掉 present PTE&#34;] J --> K[&#34;set_pte_at 写入 swp_entry_to_pte(entry)&#34;] E -- &#34;否&#34; --> L[&#34;跳过 unmap&#34;] K --> M[&#34;后续 pageout / free&#34;] L --> M

try_to_unmap_one() 里面最核心的状态变化是:

text 复制代码
   之前:
      PTE = present + PFN X + flags

   之后:
      PTE = not present + swap entry + preserved software flags

源码里还会保留一些软件语义,例如:

  • soft-dirty。
  • userfaultfd write-protect。
  • anonymous exclusive。

所以不能把 swap PTE 理解成"普通空 PTE"。它是 not-present,但不是 none。它里面编码了后续换入需要的信息。

从计数角度,try_to_unmap_one() 对普通匿名 swapout 会把这个进程的匿名 RSS 账本往下调,把 swap entry 账本往上调:

text 复制代码
   MM_ANONPAGES  减少
   MM_SWAPENTS   增加

这正是实验里 smaps 从:

text 复制代码
   Rss:       262144 kB
   Anonymous: 262144 kB
   Swap:           0 kB

变成:

text 复制代码
   Rss:            0 kB
   Anonymous:      0 kB
   Swap:      262144 kB

的原因。

六、swap cache:页已经"换出",为什么还可能在内存里

很多人第一次看 swap 会以为:

text 复制代码
   PTE 变成 swap entry
      =
   物理页立刻不在内存里

这个理解不够准确。

更准确的是:

text 复制代码
   PTE 变成 swap entry
      =
   进程页表不再 present 映射这页。

   但该页对应的 folio 可能仍在 swap cache 里。

swap cache 表达的是:

text 复制代码
   这个 swap entry 对应的页内容,
   当前在内存里还有一份 folio,
   并且这份 folio 与 swap slot 关联。

它有几个作用。

第一,避免重复 I/O

多个线程或多个进程可能同时 fault 同一个 swap entry。如果没有 swap cache,它们可能重复分配页、重复读 swap。swap cache 给这个 swap entry 一个内存中的汇合点。

第二,处理并发换入/换出

源码里 __read_swap_cache_async() 会先查 swap cache,再决定是否分配新 folio 并加入 swap cache。它还要处理 SWAP_HAS_CACHE、并发删除、swapoff 等竞态。

第三,支持 swap readahead

swapin_readahead() 可以围绕 fault 地址或 swap offset 提前读一批页,读进来的页也要放进 swap cache。后续访问如果命中,就是 minor fault 或较便宜的 fault。

第四,承接 workingset/refault 信息

swap cache XArray 中还可能保存 shadow 信息,用于 workingset/refault 判断。实验里重新访问被挤出 swap cache 的匿名页后,workingset_refault_anon 增长,就是这个方向的外部可观察信号。

所以"swap cache 命中"的换入和"真的从 swap 后端读 I/O"的换入,在用户现象上不一样:

场景 PTE 状态 数据是否已在内存 访问后常见 fault
刚被 unmap,但 folio 还在 swap cache not present + swap entry minor fault
swap cache 也被回收了 not present + swap entry major fault,pswpin 增长

这张表是理解实验输出的关键。

七、写到哪里:swap file、zram、zswap 都是后端变化

shrink_folio_list() 看,匿名页写出会走 pageout();对 swap cache folio,底层会走到 swap_writepage() 一类路径。

mm/page_io.c 里可以看到几个分支:

text 复制代码
   swap_writepage
        │
        ├─ folio_free_swap 成功?
        │     └─ 不必写,释放 swap 关联
        │
        ├─ 全零页?
        │     └─ 记录 zeromap,避免真实 I/O
        │
        ├─ zswap_store 成功?
        │     └─ 压缩后放入 zswap
        │
        └─ __swap_writepage
              └─ 写到 swap 设备或 swap 文件

这说明"写入 swap"不是只能等于"写到一块慢磁盘"。真实系统可能是:

  • swap file。
  • swap partition。
  • zram 设备。
  • zswap 压缩缓存,再由后端 swap 承接。

但不管后端是什么,对页表来说核心状态仍然是:

text 复制代码
   PTE 不再保存 PFN,而是保存 swap entry。

后端变化影响的是 I/O 成本、是否压缩、是否真正落盘,不改变这篇的主线。

八、换入:handle_pte_fault() 为什么会走 do_swap_page()

主线第 5 篇说过,PTE not present 不只有一种含义。它可能是:

  • 从未分配过。
  • 文件页还没从 page cache / 磁盘读入。
  • 被换出到 swap。
  • migration/device/private 等特殊 entry。

mm/memory.chandle_pte_fault() 中,逻辑非常直接:

text 复制代码
   如果没有 PTE 或 PTE none:
      do_pte_missing

   如果 PTE not present:
      do_swap_page

   如果 PTE present 但权限不满足:
      do_wp_page / do_numa_page 等

注意这里函数名叫 do_swap_page(),但它也会先分辨 non_swap_entry(),例如 migration entry(页迁移期间临时占住 PTE,fault 时等待或重试)、device private entry(页内容迁到设备私有显存/HMM 内存里)、hwpoison entry(硬件内存错误页,访问时返回内存损坏语义)、pte marker(不指向真实页,只保存 userfaultfd 等软件标记)。普通匿名 swap entry 才进入我们这里讲的换入路径。

普通 swap fault 的主线是:

flowchart TD A[&#34;CPU 访问虚拟地址&#34;] --> B[&#34;PTE not present&#34;] B --> C[&#34;page fault&#34;] C --> D[&#34;handle_pte_fault&#34;] D --> E[&#34;do_swap_page&#34;] E --> F[&#34;pte_to_swp_entry&#34;] F --> G[&#34;get_swap_device&#34;] G --> H{&#34;swap cache 命中?&#34;} H -- &#34;命中&#34; --> I[&#34;拿到内存中的 folio&#34;] H -- &#34;未命中&#34; --> J[&#34;swapin_readahead&#34;] J --> K[&#34;__read_swap_cache_async&#34;] K --> L[&#34;分配 folio,加入 swap cache&#34;] L --> M[&#34;swap_read_folio 从后端读入&#34;] I --> N[&#34;mk_pte / set_ptes&#34;] M --> N N --> O[&#34;重新建立 present PTE&#34;] O --> P[&#34;用户指令重跑成功&#34;]

do_swap_page() 有几步特别重要。

第一,取出 swap entry:

text 复制代码
   entry = pte_to_swp_entry(vmf->orig_pte)

第二,先查 swap cache:

text 复制代码
   folio = swap_cache_get_folio(entry, vma, address)

如果命中,说明数据已经在内存里,通常不需要真正读 swap 后端。

第三,如果未命中,走 swapin_readahead()。这个路径最终会调用 __read_swap_cache_async() 分配新 folio、加入 swap cache,然后由 swap_read_folio() 发起读入。

第四,读回或拿到 folio 以后,do_swap_page() 会:

  • swap_free_nr() 释放当前 PTE 对 swap entry 的引用。
  • 视情况 folio_free_swap(),尝试释放 swap cache / swap slot。
  • 增加 MM_ANONPAGES
  • 减少 MM_SWAPENTS
  • mk_pte() 构造 present PTE。
  • 调用 folio_add_anon_rmap_ptes() 更新 folio/page 的 rmap 账本。
  • set_ptes() 写回页表。

对普通被换出的匿名 folio 来说,进入 swap cache 时它的 mapping/index 通常仍保留匿名 rmap 身份:

text 复制代码
   folio->mapping  仍编码 anon_vma
   folio->index    仍是匿名 rmap 坐标里的 pgoff

换入时真正要恢复的是这个进程页表里的 present 映射。普通路径会调用 folio_add_anon_rmap_ptes(),可以压缩理解成"校验 + 更新状态":最关键的是 __page_check_anon_rmap() 会检查 folio 现有的 anon_vma/rootpgoff,是否能和当前 VMA + address 对上;对上以后,再更新 mapcount、NR_ANON_MAPPED 等统计,以及 RMAP_EXCLUSIVE 对应的 PageAnonExclusive 状态。

所以这里所谓"记录",主要就是更新这些 rmap 状态。普通换入不会改变 VMA,也不会调整 anon_vma_chain / interval tree;这些关系在前面已经存在,换入时只是校验它们还能和 folio 的 anon_vma/root + pgoff 对上。至于"真实 PTE 在哪里",就是后面 set_ptes() 写进页表的 present PTE。

只有在换入过程中得到的是一个新匿名 folio,例如 KSM 读 fault 需要复制,或者拿到的 folio 还不是 anon folio 时,才会走 folio_add_new_anon_rmap() 这类路径,为这个新 folio 设置/确认 folio->mappingfolio->index

换入后,进程看到的是同一个虚拟地址,读到的是换出前的数据。但底层物理页 PFN 不一定和换出前相同。

九、没有 swap 时会怎样

没有 swap,普通匿名页就很难被回收。

在源码路径上,关键点是 add_to_swap()。如果没有可用 swap slot,或者 memcg swap 限制不允许继续使用 swap,add_to_swap() 失败,shrink_folio_list() 不能把普通匿名页变成 swap entry。结果通常是把它保留、重新激活、拆分后重试,或者继续扫描别的页。

所以无 swap 系统上常见现象是:

text 复制代码
   文件页 / page cache:
      干净页仍然容易回收。

   普通匿名页:
      没有 swap 后盾时不能随便丢。

   持续匿名内存压力:
      回收效果差,direct reclaim 可能变重,
      最后更容易走到 memcg OOM 或全局 OOM。

这也解释了番外一里的提醒:kswapd 不是"释放所有匿名内存"的线程。没有 swap,或者 swap 被 cgroup 限制住时,匿名页即使在 LRU 上,也不代表一定能被成功回收。

十、把匿名页换出压成一张状态图

text 复制代码
   阶段 1:已驻留

   virtual address A
        │
        ▼
   PTE: present + PFN X
        │
        ▼
   folio X
        ├─ anon
        ├─ on anon LRU
        └─ content = user private data

   ------------------------------------------------------------

   阶段 2:准备换出

   shrink_folio_list
        │
        ├─ add_to_swap
        │     ├─ allocate swap slot
        │     └─ folio X enters swap cache
        │
        └─ try_to_unmap
              └─ PTE: not present + swap entry

   ------------------------------------------------------------

   阶段 3:页表已经换出

   virtual address A
        │
        ▼
   PTE: not present + swap entry(type, offset)
        │
        ├─ swap cache 可能仍有 folio X
        └─ swap backend 有或将有持久/压缩后的内容

   ------------------------------------------------------------

   阶段 4:再次访问

   page fault
        │
        ▼
   do_swap_page
        │
        ├─ swap cache hit
        │     └─ 直接重新 map,常见 minor fault
        │
        └─ swap cache miss
              ├─ 从 swap 后端读回
              └─ 常见 major fault,pswpin 增长

十一、实验:在 x86_64 容器里观察 swap entry 和 swap cache

实验目标不是制造 OOM,而是稳定观察三个状态:

  1. 匿名映射刚创建时只有 VMA,没有物理页。
  2. 写入后变成 RSS 中的匿名页。
  3. MADV_PAGEOUT 后 PTE 变成 swap entry,smaps 显示 Swap
  4. swap cache 还在时,refault 可以不产生 pswpin
  5. 用 cgroup 内存压力挤掉 swap cache 后,refault 会产生 pswpin 和 major fault。

完整代码如下。把它保存为当前目录下的 anon-swap-reclaim-demo.c 后运行后面的命令。

c 复制代码
#define _GNU_SOURCE
#include <errno.h>
#include <inttypes.h>
#include <limits.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/utsname.h>
#include <time.h>
#include <unistd.h>

#ifndef MADV_PAGEOUT
#define MADV_PAGEOUT 21
#endif

#ifndef MADV_NOHUGEPAGE
#define MADV_NOHUGEPAGE 15
#endif

struct vm_counter {
    const char *name;
    unsigned long long value;
    int found;
};

static const char *const vmstat_keys[] = {
    "nr_inactive_anon",
    "nr_active_anon",
    "nr_anon_pages",
    "nr_swapcached",
    "workingset_refault_anon",
    "pgfault",
    "pgmajfault",
    "pswpin",
    "pswpout",
    "pgscan_kswapd",
    "pgscan_direct",
    "pgsteal_kswapd",
    "pgsteal_direct",
    "pgscan_anon",
    "pgsteal_anon",
    "pgactivate",
    "pgdeactivate",
};

static volatile unsigned long long sink;

static void die(const char *msg)
{
    perror(msg);
    exit(1);
}

static void sleep_ms(long milliseconds)
{
    struct timespec req;

    req.tv_sec = milliseconds / 1000;
    req.tv_nsec = (milliseconds % 1000) * 1000000L;

    while (nanosleep(&req, &req) != 0) {
        if (errno != EINTR)
            die("nanosleep");
    }
}

static void *xmalloc(size_t size)
{
    void *p = malloc(size);

    if (!p)
        die("malloc");
    return p;
}

static int read_first_line(const char *path, char *buf, size_t len)
{
    FILE *fp = fopen(path, "r");

    if (!fp)
        return -1;
    if (!fgets(buf, len, fp)) {
        fclose(fp);
        return -1;
    }
    buf[strcspn(buf, "\n")] = '\0';
    fclose(fp);
    return 0;
}

static void print_file_first_lines(const char *path, const char *title,
                                   unsigned int max_lines)
{
    FILE *fp = fopen(path, "r");
    char line[256];
    unsigned int lines = 0;

    printf("%s\n", title);
    if (!fp) {
        printf("  %s not readable: %s\n", path, strerror(errno));
        return;
    }

    while (lines < max_lines && fgets(line, sizeof(line), fp)) {
        printf("  %s", line);
        lines++;
    }
    fclose(fp);
}

static void print_cgroup_memory(void)
{
    const char *paths[] = {
        "/sys/fs/cgroup/memory.current",
        "/sys/fs/cgroup/memory.max",
        "/sys/fs/cgroup/memory.swap.current",
        "/sys/fs/cgroup/memory.swap.max",
    };
    size_t i;

    printf("cgroup memory:\n");
    for (i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) {
        char buf[128];

        if (read_first_line(paths[i], buf, sizeof(buf)) == 0)
            printf("  %s = %s\n", paths[i], buf);
    }
}

static void print_meminfo(const char *tag)
{
    const char *wanted[] = {
        "MemTotal:",
        "MemAvailable:",
        "AnonPages:",
        "SwapCached:",
        "SwapTotal:",
        "SwapFree:",
    };
    FILE *fp = fopen("/proc/meminfo", "r");
    char line[256];
    size_t i;

    if (!fp)
        die("fopen /proc/meminfo");

    printf("[%s] /proc/meminfo selected fields\n", tag);
    while (fgets(line, sizeof(line), fp)) {
        for (i = 0; i < sizeof(wanted) / sizeof(wanted[0]); i++) {
            if (strncmp(line, wanted[i], strlen(wanted[i])) == 0) {
                fputs(line, stdout);
                break;
            }
        }
    }

    fclose(fp);
}

static void print_self_status(const char *tag)
{
    const char *wanted[] = {
        "VmSize:",
        "VmRSS:",
        "RssAnon:",
        "VmSwap:",
    };
    FILE *fp = fopen("/proc/self/status", "r");
    char line[256];
    size_t i;

    if (!fp)
        die("fopen /proc/self/status");

    printf("[%s] /proc/self/status selected fields\n", tag);
    while (fgets(line, sizeof(line), fp)) {
        for (i = 0; i < sizeof(wanted) / sizeof(wanted[0]); i++) {
            if (strncmp(line, wanted[i], strlen(wanted[i])) == 0) {
                fputs(line, stdout);
                break;
            }
        }
    }

    fclose(fp);
}

static void init_counters(struct vm_counter *counters, size_t n)
{
    size_t i;

    for (i = 0; i < n; i++) {
        counters[i].name = vmstat_keys[i];
        counters[i].value = 0;
        counters[i].found = 0;
    }
}

static void read_vmstat(struct vm_counter *counters, size_t n)
{
    FILE *fp = fopen("/proc/vmstat", "r");
    char name[128];
    unsigned long long value;
    size_t i;

    if (!fp)
        die("fopen /proc/vmstat");

    for (i = 0; i < n; i++) {
        counters[i].value = 0;
        counters[i].found = 0;
    }

    while (fscanf(fp, "%127s %llu", name, &value) == 2) {
        for (i = 0; i < n; i++) {
            if (strcmp(name, counters[i].name) == 0) {
                counters[i].value = value;
                counters[i].found = 1;
                break;
            }
        }
    }

    fclose(fp);
}

static void print_vmstat_delta(const char *tag, const struct vm_counter *base,
                               const struct vm_counter *now, size_t n)
{
    size_t i;

    printf("[%s] /proc/vmstat selected counters\n", tag);
    for (i = 0; i < n; i++) {
        if (!now[i].found)
            continue;

        printf("%-26s %12llu", now[i].name, now[i].value);
        if (base[i].found) {
            long long delta = (long long)(now[i].value - base[i].value);

            printf("  delta=%+lld", delta);
        }
        putchar('\n');
    }
}

static int is_smaps_header(const char *line, unsigned long *start,
                           unsigned long *end)
{
    char perms[8];

    return sscanf(line, "%lx-%lx %7s", start, end, perms) == 3;
}

static void print_smaps_for_addr(void *addr, const char *tag)
{
    const char *wanted[] = {
        "Size:",
        "Rss:",
        "Pss:",
        "Referenced:",
        "Anonymous:",
        "AnonHugePages:",
        "Swap:",
        "SwapPss:",
        "Locked:",
        "THPeligible:",
        "VmFlags:",
    };
    FILE *fp = fopen("/proc/self/smaps", "r");
    char line[512];
    unsigned long target = (unsigned long)addr;
    int in_target = 0;
    int found = 0;
    size_t i;

    if (!fp)
        die("fopen /proc/self/smaps");

    printf("[%s] smaps entry for %p\n", tag, addr);
    while (fgets(line, sizeof(line), fp)) {
        unsigned long start;
        unsigned long end;

        if (is_smaps_header(line, &start, &end)) {
            if (in_target)
                break;
            if (start <= target && target < end) {
                in_target = 1;
                found = 1;
                fputs(line, stdout);
            }
            continue;
        }

        if (!in_target)
            continue;

        for (i = 0; i < sizeof(wanted) / sizeof(wanted[0]); i++) {
            if (strncmp(line, wanted[i], strlen(wanted[i])) == 0) {
                fputs(line, stdout);
                break;
            }
        }
    }

    if (!found)
        printf("  mapping not found\n");

    fclose(fp);
}

static size_t resident_pages(void *addr, size_t length, long page_size)
{
    size_t pages = length / (size_t)page_size;
    unsigned char *vec = xmalloc(pages);
    size_t resident = 0;
    size_t i;

    if (mincore(addr, length, (void *)vec) != 0)
        die("mincore");

    for (i = 0; i < pages; i++) {
        if (vec[i] & 1)
            resident++;
    }

    free(vec);
    return resident;
}

static unsigned char pattern_for_page(size_t page_index)
{
    return (unsigned char)((page_index * 131U + 17U) & 0xffU);
}

static void touch_write(unsigned char *p, size_t pages, long page_size)
{
    size_t i;

    for (i = 0; i < pages; i++) {
        unsigned char v = pattern_for_page(i);

        p[i * (size_t)page_size] = v ? v : 1;
    }
}

static void verify_read(unsigned char *p, size_t pages, long page_size)
{
    size_t i;
    unsigned long long sum = 0;

    for (i = 0; i < pages; i++) {
        unsigned char expected = pattern_for_page(i);
        unsigned char actual;

        if (!expected)
            expected = 1;
        actual = p[i * (size_t)page_size];
        if (actual != expected) {
            fprintf(stderr,
                    "data mismatch at page %zu: expected=%u actual=%u\n",
                    i, expected, actual);
            exit(1);
        }
        sum += actual;
    }

    sink += sum;
    printf("verified %zu pages, checksum=%llu\n", pages, sum);
}

static void print_rusage_delta(const char *tag, const struct rusage *base,
                               const struct rusage *now)
{
    printf("[%s] getrusage page faults\n", tag);
    printf("minor_faults=%ld delta=%+ld\n",
           now->ru_minflt, now->ru_minflt - base->ru_minflt);
    printf("major_faults=%ld delta=%+ld\n",
           now->ru_majflt, now->ru_majflt - base->ru_majflt);
}

static void print_stage(const char *tag, void *addr, size_t length,
                        long page_size, const struct vm_counter *base,
                        const struct vm_counter *now, size_t n,
                        const struct rusage *ru_base,
                        const struct rusage *ru_now)
{
    size_t pages = length / (size_t)page_size;
    size_t resident = resident_pages(addr, length, page_size);

    printf("\n=== %s ===\n", tag);
    printf("mincore resident pages: %zu / %zu (%.1f MiB / %.1f MiB)\n",
           resident, pages,
           (double)resident * (double)page_size / 1048576.0,
           (double)length / 1048576.0);
    print_meminfo(tag);
    print_cgroup_memory();
    print_self_status(tag);
    print_smaps_for_addr(addr, tag);
    print_vmstat_delta(tag, base, now, n);
    print_rusage_delta(tag, ru_base, ru_now);
}

static unsigned long parse_mib_arg(const char *arg, const char *name)
{
    char *end = NULL;
    unsigned long mib;

    errno = 0;
    mib = strtoul(arg, &end, 10);
    if (errno || !end || *end != '\0' || mib == 0 || mib > 4096) {
        fprintf(stderr, "%s must be in MiB range 1..4096\n", name);
        exit(2);
    }

    return mib;
}

static void touch_pressure_mapping(unsigned long pressure_mib, long page_size)
{
    size_t length = (size_t)pressure_mib * 1024UL * 1024UL;
    size_t pages;
    unsigned char *p;
    size_t i;

    length = length / (size_t)page_size * (size_t)page_size;
    pages = length / (size_t)page_size;

    printf("\nallocating and touching %lu MiB pressure mapping...\n",
           pressure_mib);

    p = mmap(NULL, length, PROT_READ | PROT_WRITE,
             MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED)
        die("mmap pressure");

    if (madvise(p, length, MADV_NOHUGEPAGE) != 0)
        printf("pressure MADV_NOHUGEPAGE failed: %s\n", strerror(errno));

    for (i = 0; i < pages; i++)
        p[i * (size_t)page_size] = (unsigned char)((i * 29U + 3U) & 0xffU);

    sink += p[0];
    sleep_ms(1000);

    if (munmap(p, length) != 0)
        die("munmap pressure");

    sleep_ms(1000);
}

int main(int argc, char **argv)
{
    const size_t nr_counters = sizeof(vmstat_keys) / sizeof(vmstat_keys[0]);
    struct vm_counter before[nr_counters];
    struct vm_counter after_mmap[nr_counters];
    struct vm_counter after_touch[nr_counters];
    struct vm_counter after_pageout[nr_counters];
    struct vm_counter after_pressure[nr_counters];
    struct vm_counter after_refault[nr_counters];
    struct rusage ru_before;
    struct rusage ru_after_mmap;
    struct rusage ru_after_touch;
    struct rusage ru_after_pageout;
    struct rusage ru_after_pressure;
    struct rusage ru_after_refault;
    struct utsname uts;
    unsigned long mib = 256;
    unsigned long pressure_mib = 0;
    long page_size = sysconf(_SC_PAGESIZE);
    size_t length;
    size_t pages;
    unsigned char *p;
    int ret;

    if (argc > 3) {
        fprintf(stderr, "usage: %s [victim_mib] [pressure_mib]\n", argv[0]);
        exit(2);
    }
    if (argc >= 2)
        mib = parse_mib_arg(argv[1], "victim_mib");
    if (argc >= 3)
        pressure_mib = parse_mib_arg(argv[2], "pressure_mib");

    if (page_size <= 0)
        die("sysconf _SC_PAGESIZE");

    length = (size_t)mib * 1024UL * 1024UL;
    length = length / (size_t)page_size * (size_t)page_size;
    pages = length / (size_t)page_size;

    if (uname(&uts) != 0)
        die("uname");

    init_counters(before, nr_counters);
    init_counters(after_mmap, nr_counters);
    init_counters(after_touch, nr_counters);
    init_counters(after_pageout, nr_counters);
    init_counters(after_pressure, nr_counters);
    init_counters(after_refault, nr_counters);

    read_vmstat(before, nr_counters);
    if (getrusage(RUSAGE_SELF, &ru_before) != 0)
        die("getrusage before");

    printf("machine=%s sysname=%s release=%s\n",
           uts.machine, uts.sysname, uts.release);
    printf("page_size=%ld bytes\n", page_size);
    printf("target=%lu MiB, pressure=%lu MiB, pages=%zu, MADV_PAGEOUT=%d\n",
           mib, pressure_mib, pages, MADV_PAGEOUT);
    print_file_first_lines("/proc/swaps", "/proc/swaps", 4);
    print_meminfo("before");
    print_cgroup_memory();
    print_self_status("before");
    print_vmstat_delta("before", before, before, nr_counters);

    p = mmap(NULL, length, PROT_READ | PROT_WRITE,
             MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (p == MAP_FAILED)
        die("mmap");

    ret = madvise(p, length, MADV_NOHUGEPAGE);
    if (ret != 0)
        printf("madvise(MADV_NOHUGEPAGE) failed: %s\n", strerror(errno));

    read_vmstat(after_mmap, nr_counters);
    if (getrusage(RUSAGE_SELF, &ru_after_mmap) != 0)
        die("getrusage after mmap");
    print_stage("after mmap", p, length, page_size, before, after_mmap,
                nr_counters, &ru_before, &ru_after_mmap);

    printf("\ntouching %zu pages with non-zero anonymous data...\n", pages);
    touch_write(p, pages, page_size);
    read_vmstat(after_touch, nr_counters);
    if (getrusage(RUSAGE_SELF, &ru_after_touch) != 0)
        die("getrusage after touch");
    print_stage("after touch", p, length, page_size, before, after_touch,
                nr_counters, &ru_before, &ru_after_touch);

    printf("\ncalling madvise(MADV_PAGEOUT) on the anonymous mapping...\n");
    if (madvise(p, length, MADV_PAGEOUT) != 0)
        printf("madvise(MADV_PAGEOUT) failed: %s\n", strerror(errno));

    sleep_ms(1500);
    read_vmstat(after_pageout, nr_counters);
    if (getrusage(RUSAGE_SELF, &ru_after_pageout) != 0)
        die("getrusage after pageout");
    print_stage("after MADV_PAGEOUT", p, length, page_size, before,
                after_pageout, nr_counters, &ru_before, &ru_after_pageout);

    if (pressure_mib > 0) {
        touch_pressure_mapping(pressure_mib, page_size);
        read_vmstat(after_pressure, nr_counters);
        if (getrusage(RUSAGE_SELF, &ru_after_pressure) != 0)
            die("getrusage after pressure");
        print_stage("after pressure release", p, length, page_size,
                    before, after_pressure, nr_counters, &ru_before,
                    &ru_after_pressure);
    }

    printf("\nreading the mapping again to force swap-in faults...\n");
    verify_read(p, pages, page_size);
    read_vmstat(after_refault, nr_counters);
    if (getrusage(RUSAGE_SELF, &ru_after_refault) != 0)
        die("getrusage after refault");
    print_stage("after refault", p, length, page_size, before,
                after_refault, nr_counters, &ru_before, &ru_after_refault);

    if (munmap(p, length) != 0)
        die("munmap");

    printf("\nsink=%llu\n", sink);
    return 0;
}

编译运行命令:

bash 复制代码
docker run --rm --platform linux/amd64 \
  --memory=384m --memory-swap=1024m \
  -v "$PWD:/work" -w /work \
  gcc:13 \
  bash -lc 'gcc -O2 -Wall -Wextra -std=c11 anon-swap-reclaim-demo.c -o /tmp/anon-swap-demo && /tmp/anon-swap-demo 256 384'

这里有几个选择是故意的:

  • --platform linux/amd64:确保用户态是 x86_64。
  • --memory=384m --memory-swap=1024m:让容器内存上限小于后面的压力映射,同时允许使用 swap。
  • 256 384:先创建 256 MiB 被观察的匿名映射,再用 384 MiB 临时匿名映射制造压力,把 swap cache 挤掉。
  • 程序用 MADV_NOHUGEPAGE 降低 THP 对 4KB 页观察的干扰。
  • 程序每页写非零字节,避免全零页被 zeromap 优化成"不需要真实写出"。

十二、真实 x86_64 容器输出

实验环境:

text 复制代码
Docker --platform linux/amd64
container uname -m: x86_64
kernel: Linux 6.12.65-linuxkit
page size: 4096 bytes
swap: /var/lib/swap, 1048572 KiB
cgroup memory.max: 402653184 bytes
cgroup memory.swap.max: 671088640 bytes

关键输出如下。全局 /proc/vmstat 计数可能已有历史值,所以重点看 delta=

mmap 后,VMA 出现,但没有 RSS,也没有 Swap:

text 复制代码
machine=x86_64 sysname=Linux release=6.12.65-linuxkit
page_size=4096 bytes
target=256 MiB, pressure=384 MiB, pages=65536, MADV_PAGEOUT=21

=== after mmap ===
mincore resident pages: 0 / 65536 (0.0 MiB / 256.0 MiB)
Size:             262144 kB
Rss:                   0 kB
Anonymous:             0 kB
Swap:                  0 kB
minor_faults=2068 delta=+10
major_faults=0 delta=+0

逐页写入后,65536 个 4KB 页都变成匿名 RSS。minor fault 增加约 65536 次,这是匿名页首次写入分配物理页:

text 复制代码
=== after touch ===
mincore resident pages: 65536 / 65536 (256.0 MiB / 256.0 MiB)
VmRSS:      266992 kB
RssAnon:    264812 kB

Size:             262144 kB
Rss:              262144 kB
Anonymous:        262144 kB
Swap:                  0 kB

pgfault        delta=+65567
pgmajfault     delta=+0
pswpout        delta=+0
minor_faults   delta=+65566
major_faults   delta=+0

调用 MADV_PAGEOUT 后,这段 VMA 的页表已经不再 present 映射物理页。smaps 显示 RSS 归零,Swap 变成 256 MiB;pswpout 增加 65536 页;nr_swapcached 也增加 65536 页:

text 复制代码
=== after MADV_PAGEOUT ===
mincore resident pages: 65536 / 65536 (256.0 MiB / 256.0 MiB)
SwapCached:       262144 kB
SwapFree:         785944 kB

VmRSS:        4916 kB
RssAnon:      2672 kB
VmSwap:     262144 kB

Size:             262144 kB
Rss:                   0 kB
Anonymous:             0 kB
Swap:             262144 kB
SwapPss:          262144 kB

nr_swapcached  delta=+65536
pswpout        delta=+65536
pswpin         delta=+0
pgmajfault     delta=+0

注意这里最有意思的一行:

text 复制代码
mincore resident pages: 65536 / 65536

这不和 Rss: 0 kB 矛盾。Rss 看的是进程页表里的 present 映射;这里 PTE 已经是 swap entry,所以 RSS 为 0。mincore 在这台内核上仍然认为这些页"resident",因为对应 folio 还在 swap cache 里,内存中仍有一份数据。

接着用 384 MiB 临时匿名映射制造 cgroup 内存压力。压力释放后,原映射仍然是 Swap: 262144 kB,但 SwapCached 从 262144 kB 掉到几百 kB,mincore 也显示 0 页驻留:

text 复制代码
=== after pressure release ===
mincore resident pages: 0 / 65536 (0.0 MiB / 256.0 MiB)
SwapCached:          344 kB

VmRSS:        2916 kB
RssAnon:       672 kB
VmSwap:     262628 kB

Size:             262144 kB
Rss:                   0 kB
Anonymous:             0 kB
Swap:             262144 kB
SwapPss:          262144 kB

nr_swapcached      delta=+50
pgscan_anon        delta=+220975
pgsteal_anon       delta=+92295
pswpout            delta=+96277

这一步说明:swap entry 仍在页表里,但大部分 swap cache folio 已经被内存压力回收。下一次访问不能只靠内存里的 swap cache 了。

这里的 pswpout delta=+96277 不只包含最初 256 MiB 目标映射的 65536 页,也包含压力映射在 cgroup 内存上限下被换出的页。这个阶段的目的不是精确度量目标映射写出量,而是把目标映射对应的 swap cache 从内存中挤掉。

最后重新读这 65536 个页,并校验数据:

text 复制代码
reading the mapping again to force swap-in faults...
verified 65536 pages, checksum=8356096

=== after refault ===
mincore resident pages: 65536 / 65536 (256.0 MiB / 256.0 MiB)

VmRSS:      265076 kB
RssAnon:    262832 kB
VmSwap:        468 kB

Size:             262144 kB
Rss:              262144 kB
Anonymous:        262144 kB
Swap:                  0 kB

workingset_refault_anon  delta=+65730
pgfault                  delta=+229803
pgmajfault               delta=+16492
pswpin                   delta=+65726
nr_swapcached            delta=+65629

minor_faults=215194 delta=+213136
major_faults=16492 delta=+16492

这次已经看到真实 swap-in:

text 复制代码
pswpin      delta=+65726 pages
major_faults delta=+16492

pswpin 的单位是页,约等于:

text 复制代码
65726 * 4096 bytes ≈ 256.7 MiB

major_faults 小于 pswpin 很正常。do_swap_page() 可能通过 swapin_readahead() 一次 fault 带入多个 swap 页,所以 I/O 页数和 fault 次数不是一一对应。

workingset_refault_anon 增加也符合源码路径:这些匿名页先被换出并从内存里挤走,随后又被访问,属于匿名页 refault。

另外,/proc/self/status 里的 VmSwap 是整个进程的汇总。refault 后它还剩几百 KiB,不代表目标 256 MiB 映射还有 swap;判断目标映射要看上面那段 smaps,其中 Swap: 0 kB

十三、把实验和源码对上

实验里的每个阶段都能对应到源码路径。

第一,mmap 后只有 VMA,没有物理页:

text 复制代码
   mmap anonymous
        │
        └─ VMA 创建成功
             PTE 仍然 none
             smaps Rss = 0

第二,逐页写入触发匿名缺页:

text 复制代码
   p[i] = pattern
        │
        ▼
   page fault
        │
        ▼
   do_anonymous_page / 匿名页分配
        │
        ▼
   PTE: present + PFN
        │
        ▼
   smaps Rss/Anonymous 增长

第三,MADV_PAGEOUT 让匿名页走换出机制:

text 复制代码
   MADV_PAGEOUT
        │
        ▼
   add_to_swap
        │
        ├─ 分配 swap entry
        └─ 加入 swap cache
        │
        ▼
   try_to_unmap
        │
        └─ PTE: present + PFN -> not present + swap entry
        │
        ▼
   pageout / swap_writepage
        │
        └─ pswpout 增长

第四,刚 pageout 后 swap cache 仍然在:

text 复制代码
   PTE 已经是 swap entry
   进程 RSS 已经归零
   但 swap cache 仍持有内存中的 folio

所以这时如果立刻访问,很可能是:

text 复制代码
   do_swap_page
        │
        └─ swap_cache_get_folio 命中
             └─ minor fault,不一定 pswpin

第五,压力阶段把 swap cache 挤掉:

text 复制代码
   临时匿名压力
        │
        ▼
   reclaim 扫描 anon
        │
        ▼
   回收不再被 PTE 映射的 swap cache folio
        │
        └─ nr_swapcached 下降,mincore resident 下降

第六,重新访问原映射:

text 复制代码
   访问原地址
        │
        ▼
   PTE not present + swap entry
        │
        ▼
   do_swap_page
        │
        ├─ swap cache miss
        ├─ swapin_readahead
        ├─ swap_read_folio
        ├─ pswpin 增长
        ├─ major fault 增长
        └─ set_ptes 建回 present PTE

数据校验通过说明:虚拟地址看到的数据没有丢,只是中途从匿名物理页变成了 swap entry,又被换回了匿名物理页。

十四、几个常见误解

误解一:匿名页换出后,PTE 就是空的。

不是。普通 swapout 后 PTE 是 not-present,但不是 none。它编码了 swap entry。handle_pte_fault() 正是靠这个 entry 走到 do_swap_page()

误解二:只要 VmSwap 增长,物理内存里一定没有这页。

不一定。VmSwap 表示进程页表里有 swap entry;swap cache 可能仍然在内存里保存这页内容。实验里 VmSwap=262144 kB 时,SwapCached=262144 kBmincore 仍显示 resident。

误解三:swap fault 一定是 major fault。

不一定。swap cache 命中时,数据已经在内存里,重新建立 PTE 可以是 minor fault。只有需要从 swap 后端读入时,才通常表现为 major fault,并让 pswpin 增长。

误解四:匿名页回收一定直接写磁盘。

不一定。可能命中 zeromap,全零页不做真实 I/O;可能进入 zswap;可能写到 zram;也可能写到普通 swap file/partition。页表层面统一表现为 swap entry。

误解五:有 kswapd 就能回收所有匿名页。

不行。普通匿名页需要 swap 后盾。没有 swap、swap 满了、memcg swap 达上限、页被 pin、页很热、页被 mlock 或其他条件不允许时,都可能回收失败或被跳过。

十五、收束

把这一篇压成一条主线:

text 复制代码
   匿名页没有普通文件后盾,
   所以不能像干净文件页那样直接丢。

   回收普通匿名页时,
   Linux 先分配 swap slot,
   把 folio 放进 swap cache,
   再通过 rmap 找到所有 PTE,
   把 present PTE 改成 not-present swap PTE。

   之后物理页可以被释放。

   再次访问时,
   do_swap_page 根据 swap entry 查 swap cache。
   命中则便宜地重新建 PTE;
   未命中则从 swap 后端读回,常见 major fault 和 pswpin。

最后一张图:

text 复制代码
   anon LRU 上的冷 folio
        │
        ▼
   add_to_swap
        │
        ├─ swap entry = type + offset
        └─ folio enters swap cache
        │
        ▼
   try_to_unmap
        │
        ├─ rmap: folio -> anon_vma -> VMA -> PTE
        └─ PTE: present PFN -> not-present swap entry
        │
        ▼
   pageout / reclaim
        │
        ├─ folio 可能暂留 swap cache
        └─ 后续可释放物理页
        │
        ▼
   later access
        │
        ▼
   do_swap_page
        │
        ├─ swap cache hit  -> minor fault  -> set present PTE
        └─ swap cache miss -> swap read    -> major fault -> set present PTE

下一篇继续拆文件页回收:文件页为什么有些可以直接丢,read/write 的 page cache 和文件 mmap 背后为什么是同一套缓存,以及脏文件页为什么必须和 writeback 配合。

参考源码

本文源码判断基于 Linux stable v6.12.65

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