反向映射:从物理页找回虚拟地址
上一篇把 folio 作为缓存与回收的管理单位。一旦讨论“回收”,立刻冒出一个问题:内核拿到一个准备回收的 folio,怎么知道哪些进程的页表还指着它?正向页表只能从虚拟地址走到物理页,反过来走不通。反向映射就是为这件事存在的。
核心问题可以压成一句话:
正向页表回答“这个地址翻译到哪一页”,反向映射回答“这一页被哪些地址空间映射”。
问题从哪里来
页表只解决一个方向:拿到一个虚拟地址,按 PGD→P4D→PUD→PMD→PTE 走下去,最终得到 PFN。这是 MMU 在用户态访问路径上需要的。
但内核在很多场景需要反方向:拿到一个物理页(或 folio),找到所有当前映射它的虚拟页和对应 PTE。典型场景包括:
- 回收一个 folio。释放前必须撤销所有指向它的 PTE,否则用户态访问会拿到已释放的物理页。
- 迁移一个 folio。把内容搬到新物理页之后,所有原指针都要改写到新 PFN。
- COW 写入触发时。即使是
do_wp_page的判断,也要确认有没有其他映射方共享同一物理页。 - 在大页拆分、NUMA balancing、kernel same-page merging(KSM)等路径上,都要把同一物理页的所有映射当作一个整体处理。
仅靠正向页表做这件事的开销是不可接受的。它意味着每次回收都要遍历所有进程的页表,找哪个 PTE 的 PFN 字段等于目标。物理内存上有几十万到数百万个物理页,进程也可能成百上千,这条路走不通。
反向映射的本质是一组反向索引:让“物理页 → 映射方”这条查询有 O(映射数量) 的复杂度,而不是 O(全系统 PTE 数量)。
最小模型
正向与反向的对照:
1 | |
正向路径由 MMU 在访问时走。反向路径由内核在维护时走,典型入口是 rmap_walk、try_to_unmap、folio_referenced、page_mkclean 等。
反向映射在 Linux 里按页的来源分成两类:
| 类型 | 容器 | 数据结构 |
|---|---|---|
| 匿名页 | struct anon_vma 链 |
red-black tree(per anon_vma) |
| 文件页 | struct address_space->i_mmap |
interval tree(按 pgoff 区间) |
官方文档把这两类合称为反向映射,并且把保护它们的锁称为 rmap locks:
When trying to access VMAs through the reverse mapping via a
struct address_spaceorstruct anon_vmaobject …
We refer to these locks as the reverse mapping locks, or “rmap locks” for brevity.
两类访问路径走的也是不同的锁:
VMAs must be stabilised via
anon_vma_[try]lock_read()oranon_vma_[try]lock_write()for anonymous memory.
i_mmap_[try]lock_read()ori_mmap_[try]lock_write()for file-backed memory.
锁的细节后面回收篇会再用到。这里只需要记住:反向访问从来不是 cheap 的随便走,每次都要先稳定相应的 rmap 结构。
anon_vma 与 i_mmap 为什么不同
匿名页和文件页在“可能被谁映射”这个问题上有结构差异。
文件页的映射关系天生集中。所有用 mmap 把同一文件的同一段映射进自己地址空间的进程,对应的 VMA 都挂在该文件的 address_space->i_mmap 上。一棵 interval tree 索引按文件偏移区间组织,查询“pgoff X 被哪些 VMA 覆盖”是高效的。这棵树天然按文件偏移管理,能直接服务于回收和写回。
匿名页没有文件可以挂。它们的“归属”是某个进程在某次 mmap(MAP_ANONYMOUS) 时建立的 VMA。问题在于 fork 之后子进程会复制 VMA,原 VMA 的匿名页可能同时被父子两个进程映射;如果子进程再 fork,关系还会扩张。
anon_vma 系统解决的就是这个扩张。每个匿名 VMA 关联一个 anon_vma 对象,每个匿名页(folio)通过 mapping 字段反向指到属于自己的 anon_vma。anon_vma 内部维护一棵 red-black tree,记录所有当前可能映射其页面的 VMA。fork 时不复制页面,但要把子进程的新 VMA 接入这棵树,使得后续从原页反查仍能找到子进程那一份 VMA。
官方文档对匿名 VMA 与 anon_vma 的关系给了简短描述:
anon_vmaobject used by anonymous folios mapped exclusively to this VMA.
Initially set byanon_vma_prepare()serialised by thepage_table_lock.
“mapped exclusively to this VMA”是字面情况;非独占(如 fork 后)会通过 anon_vma_clone / anon_vma_chain 进一步组织。设计目标是同一个:让回收路径只需要走一棵小树,而不是全局扫描。
anon_vma_chain:VMA 与 anon_vma 之间的连接节点
struct anon_vma_chain(简称 AVC)是理解 fork 场景下反向映射扩张的关键。每个 AVC 充当一个 VMA 和一个 anon_vma 之间的双向连接:
1 | |
fork 时的链接过程(anon_vma_fork → anon_vma_clone):子进程的新 VMA 需要接入父 VMA 关联的所有 anon_vma。具体做法是遍历父 VMA 的 AVC 链表,为每个 anon_vma 创建一个新 AVC,把子 VMA 连进去。这样,从任何一个祖先 anon_vma 出发做 rmap_walk_anon,都能通过红黑树找到所有子孙 VMA。
这种设计的代价:深度 fork 链(如 fork-heavy 的 shell 脚本或容器 init 反复 fork)会让每个 VMA 的 AVC 链表线性增长,回收遍历变慢。内核通过 anon_vma_prepare 中的启发式(reuse 父 anon_vma 或新建)和 COW 后断开旧链接来控制扩张。
模式提炼:正向用于查询,反向用于维护
1 | |
| 维度 | 正向页表 | 反向映射 |
|---|---|---|
| 谁查 | MMU(每次访问) | 内核(回收、迁移、COW 判定、KSM、splittling) |
| 输入 | 虚拟地址 | 物理页 / folio |
| 输出 | PFN + 权限 | 一组 VMA + 对应 PTE 位置 |
| 触发频率 | 极高,硬件路径 | 较低,事件驱动 |
| 失败语义 | 缺页异常 | 维护操作回退或重试 |
很多系统都有类似的“正反向索引”分工:数据库里的主索引与二级索引、对象存储里的 object key 与反向引用表、文件系统里的 inode 与 dentry。共同点是高频查询路径要简洁,低频维护路径要可达。
一个最小实验:两个进程映射同一文件
下面这个实验让两个进程同时 mmap 同一个文件,观察 smaps 中的 shared/private 统计与 Pss,验证文件页在多个地址空间出现。
准备文件与脚本:
1 | |
完成实验后清理:
1 | |
读取实验结果
预期看到的结构:
每个 PID 都有一段对应 /tmp/rmap_demo.bin 的映射,权限 r--s(MAP_SHARED 文件映射)。两段 Size 都接近 4MB;Rss 接近“已触摸的页数 × 页大小”,受预读影响可能更大。
关键观察在 Shared_Clean 和 Pss:
- 两个 PID 的同段映射
Shared_Clean都计入相同的一组文件页。这些页只有一份物理拷贝。 Pss大致是Rss的一半(更准确:按当前共享数分摊),反映 page cache 中这组 folio 被两个进程同时引用。
把这个观察反推回 rmap:当内核要回收其中任意一页时,必须能找到“目前正映射这一页的两个 VMA”,分别撤销它们的 PTE。address_space->i_mmap 这棵 interval tree 就是这条反查的入口。
如果在一个进程里 munmap 这段映射,再读另一个进程的 smaps:
- 第一个进程的对应段消失。
- 第二个进程的
Pss与Rss接近相等,因为页面不再共享。
整段实验里没有写入。换成 MAP_SHARED 加写权限再写入若干字节,可以观察 Shared_Dirty 增加,以及 writeback 之后的 Shared_Clean 重新出现,这条路径接的就是 page cache 篇里讲过的 page_mkwrite 与 writepages。
模式提炼:维护点的复杂度由反向结构决定
1 | |
回收一页的代价不只是“清空 PTE 那几条指令”。它由三部分构成:找到所有映射方(反向索引开销)、对每个映射方做撤销(per-VMA 操作)、所有这些动作下的锁竞争(rmap 锁 + 页表锁)。
| 场景 | 反向结构开销 |
|---|---|
| 匿名页只被一个进程映射 | 单 anon_vma,rb tree 节点很少 |
| 匿名页经过多次 fork 后被多进程映射 | anon_vma 链/树扩张,撤销代价升高 |
| 文件页被许多 mmap 共享 | i_mmap interval tree 覆盖更多 VMA |
| KSM 合并的同内容页 | 还要叠加 KSM 自身的反向结构 |
理解了这套代价构成,就能解释一些“看似简单的回收动作慢得离谱”的现象:往往不是回收逻辑本身重,而是反向结构上挂的映射方太多。
核心结构:anon_vma
struct anon_vma 的定义(include/linux/rmap.h,精简):
1 | |
rmap_walk_anon 遍历 rb_root 找到所有映射了目标页面的 VMA,对每个执行回调(如 try_to_unmap_one)。
源码锚点
| 入口 | 读它的目的 |
|---|---|
mm/rmap.c |
rmap_walk()、try_to_unmap()、page_referenced()、folio_mkclean() |
include/linux/rmap.h |
rmap 接口与遍历控制结构 |
mm/mmap.c / mm/vma.c |
VMA 加入/移除 i_mmap、anon_vma 的路径 |
kernel/fork.c |
anon_vma_fork()、anon_vma 链扩张 |
Documentation/mm/process_addrs.rst |
rmap 锁与 VMA 锁的关系 |
读 try_to_unmap 时可以带着一个问题:在回收路径上,撤销 PTE 后页面的状态如何标记?这条问题会把第 9 篇的回收主题与本篇连起来。
研究生迁移表
| Linux 概念 | 一般系统设计 |
|---|---|
| 正向页表 | 主索引(高频查询) |
| anon_vma / i_mmap | 反向索引(事件驱动维护) |
rmap_walk |
反向遍历框架 |
try_to_unmap |
撤销引用的统一入口 |
| anon_vma 链 | 写时复制场景下的引用追踪 |
| rmap 锁 | 反向索引的一致性边界 |
数据库的二级索引、引用计数的辅助表、分布式系统里的反向链接表、垃圾回收的引用追踪图,都共享同一个思路:高频路径只读主索引,低频维护路径走反向索引。
常见误解
第一个误解是反向映射只服务回收。事实上 COW 判定、迁移、KSM、大页拆分、NUMA balancing、内存热移除都依赖反向映射。回收只是出现频率最高的那一个。
第二个误解是匿名页和文件页 rmap 走同一棵树。它们走的是不同结构和不同锁:匿名页走 anon_vma->rb_root,文件页走 address_space->i_mmap interval tree。
第三个误解是 fork 多次后 anon_vma 链可以无限扩张而不影响性能。anon_vma 链膨胀会让反向遍历变慢,从而拖慢回收。极端场景下这是真实的性能问题,内核里也持续有 anon_vma 相关的复杂度改进。
第四个误解是 KSM 之后所有同内容页面都被反向映射统一管理。KSM 有自己的反向结构(rmap_item),与普通 anon_vma 不同。读源码时不要把它们混为一谈。
第五个误解是 /proc/<pid>/maps 能看出 rmap。maps 是正向视图,列的是该进程的 VMA。要从反向角度看“一页被谁映射”,需要 page_owner、kpagecount、kpageflags 这一类接口,并且通常需要 root 权限。
练习
第一,按文中实验运行两个进程同时 mmap 同一文件,记录两个 PID 的 Pss、Shared_Clean、Rss,再启动第三个进程做同样的事,比较 Pss 的变化。
第二,把测试文件改成 O_RDWR + MAP_SHARED 写入若干页,观察 Shared_Dirty 与 Shared_Clean 的变化。sync 一次后再看 dirty 是否被清。
第三,写一段 fork 的小程序:父进程分配 256MB 匿名内存并全写过,再 fork 三次。比较父子四个进程 Anonymous 段的 Pss,验证它们如何分摊同一组匿名页。
第四,阅读 mm/rmap.c 中的 rmap_walk_anon 与 rmap_walk_file,画一张流程图,标出两条路径在“找到 VMA → 对 VMA 操作 → 解锁”三个阶段的差异。
第五,查阅 Documentation/mm/process_addrs.rst 中关于 rmap 锁与 VMA 锁互相穿插的描述,列出在回收一页时按顺序需要拿到哪些锁、为什么这个顺序不能颠倒。
系列导航
- 上一篇:Folio:为什么内核重新组织 page 抽象
- 本文:反向映射:从物理页找回虚拟地址
- 下一篇:LRU 和 workingset:内核如何近似”最近使用”
