上一篇把 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
2
3
4
5
6
7
forward (page table):
virtual address -> PGD/P4D/PUD/PMD/PTE -> PFN

reverse (rmap):
folio -> anon_vma rb tree or address_space i_mmap interval tree
-> set of VMAs
-> for each VMA: locate the PTE pointing to this folio

正向路径由 MMU 在访问时走。反向路径由内核在维护时走,典型入口是 rmap_walktry_to_unmapfolio_referencedpage_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_space or struct anon_vma object …
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() or anon_vma_[try]lock_write() for anonymous memory.
i_mmap_[try]lock_read() or i_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_vmaanon_vma 内部维护一棵 red-black tree,记录所有当前可能映射其页面的 VMA。fork 时不复制页面,但要把子进程的新 VMA 接入这棵树,使得后续从原页反查仍能找到子进程那一份 VMA。

官方文档对匿名 VMA 与 anon_vma 的关系给了简短描述:

anon_vma object used by anonymous folios mapped exclusively to this VMA.
Initially set by anon_vma_prepare() serialised by the page_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
2
3
4
VMA (child) --[avc]--> anon_vma (parent)
|
+--> 同时挂在 VMA 的 anon_vma_chain 链表
+--> 同时挂在 anon_vma 的 rb_root 红黑树

fork 时的链接过程(anon_vma_forkanon_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
forward index for lookup, reverse index for maintenance
维度 正向页表 反向映射
谁查 MMU(每次访问) 内核(回收、迁移、COW 判定、KSM、splittling)
输入 虚拟地址 物理页 / folio
输出 PFN + 权限 一组 VMA + 对应 PTE 位置
触发频率 极高,硬件路径 较低,事件驱动
失败语义 缺页异常 维护操作回退或重试

很多系统都有类似的“正反向索引”分工:数据库里的主索引与二级索引、对象存储里的 object key 与反向引用表、文件系统里的 inode 与 dentry。共同点是高频查询路径要简洁,低频维护路径要可达。

一个最小实验:两个进程映射同一文件

下面这个实验让两个进程同时 mmap 同一个文件,观察 smaps 中的 shared/private 统计与 Pss,验证文件页在多个地址空间出现。

准备文件与脚本:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
# 准备一个 4MB 测试文件
dd if=/dev/urandom of=/tmp/rmap_demo.bin bs=1M count=4

# 写一个最小程序:mmap 文件并空转
cat > /tmp/rmap_holder.c <<'EOF'
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/stat.h>
#include <unistd.h>

int main(int argc, char **argv) {
if (argc != 2) { fprintf(stderr, "usage: %s <file>\n", argv[0]); return 1; }
int fd = open(argv[1], O_RDONLY);
if (fd < 0) { perror("open"); return 1; }
struct stat st;
fstat(fd, &st);
void *p = mmap(NULL, st.st_size, PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }

/* 触摸第一页和最后一页,强制建立 PTE。*/
volatile char sink = ((char *)p)[0];
sink += ((char *)p)[st.st_size - 1];
(void)sink;

printf("pid=%d mapped at %p length=%ld\n",
getpid(), p, (long)st.st_size);
printf("inspect: cat /proc/%d/smaps\n", getpid());
fflush(stdout);

pause(); /* 等信号退出 */
munmap(p, st.st_size);
close(fd);
return 0;
}
EOF
cc -Wall -Wextra -O2 /tmp/rmap_holder.c -o /tmp/rmap_holder

# 启动两份
/tmp/rmap_holder /tmp/rmap_demo.bin &
PID_A=$!
/tmp/rmap_holder /tmp/rmap_demo.bin &
PID_B=$!
sleep 1

# 查看各自的 smaps
for pid in $PID_A $PID_B; do
echo "--- pid=$pid ---"
awk -v target="/tmp/rmap_demo.bin" '
/^[0-9a-f]+-[0-9a-f]+/ { in_seg=index($0,target); print; next }
in_seg && /^(Size|Rss|Pss|Shared_Clean|Shared_Dirty|Private_Clean|Private_Dirty|Anonymous|Referenced):/
' /proc/$pid/smaps
done

kill $PID_A $PID_B

完成实验后清理:

1
rm -f /tmp/rmap_holder.c /tmp/rmap_holder /tmp/rmap_demo.bin

读取实验结果

预期看到的结构:

每个 PID 都有一段对应 /tmp/rmap_demo.bin 的映射,权限 r--sMAP_SHARED 文件映射)。两段 Size 都接近 4MB;Rss 接近“已触摸的页数 × 页大小”,受预读影响可能更大。

关键观察在 Shared_CleanPss

  • 两个 PID 的同段映射 Shared_Clean 都计入相同的一组文件页。这些页只有一份物理拷贝。
  • Pss 大致是 Rss 的一半(更准确:按当前共享数分摊),反映 page cache 中这组 folio 被两个进程同时引用。

把这个观察反推回 rmap:当内核要回收其中任意一页时,必须能找到“目前正映射这一页的两个 VMA”,分别撤销它们的 PTE。address_space->i_mmap 这棵 interval tree 就是这条反查的入口。

如果在一个进程里 munmap 这段映射,再读另一个进程的 smaps

  • 第一个进程的对应段消失。
  • 第二个进程的 PssRss 接近相等,因为页面不再共享。

整段实验里没有写入。换成 MAP_SHARED 加写权限再写入若干字节,可以观察 Shared_Dirty 增加,以及 writeback 之后的 Shared_Clean 重新出现,这条路径接的就是 page cache 篇里讲过的 page_mkwritewritepages

模式提炼:维护点的复杂度由反向结构决定

1
2
3
maintenance cost = walk reverse index for affected page
+ per-VMA work to update PTE
+ locking on rmap / page table

回收一页的代价不只是“清空 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
2
3
4
5
6
7
8
struct anon_vma {
struct anon_vma *root; /* 整条 fork 链的根 anon_vma */
struct rw_semaphore rwsem; /* 保护 rb_root 的读写锁 */
atomic_t refcount; /* 引用计数 */
unsigned long num_children; /* 直接子 anon_vma 数 */
unsigned long num_active_vmas; /* 活跃 VMA 数 */
struct rb_root_cached rb_root; /* 所有关联 VMA 的红黑树 */
};

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_ownerkpagecountkpageflags 这一类接口,并且通常需要 root 权限。

练习

第一,按文中实验运行两个进程同时 mmap 同一文件,记录两个 PID 的 PssShared_CleanRss,再启动第三个进程做同样的事,比较 Pss 的变化。

第二,把测试文件改成 O_RDWR + MAP_SHARED 写入若干页,观察 Shared_DirtyShared_Clean 的变化。sync 一次后再看 dirty 是否被清。

第三,写一段 fork 的小程序:父进程分配 256MB 匿名内存并全写过,再 fork 三次。比较父子四个进程 Anonymous 段的 Pss,验证它们如何分摊同一组匿名页。

第四,阅读 mm/rmap.c 中的 rmap_walk_anonrmap_walk_file,画一张流程图,标出两条路径在“找到 VMA → 对 VMA 操作 → 解锁”三个阶段的差异。

第五,查阅 Documentation/mm/process_addrs.rst 中关于 rmap 锁与 VMA 锁互相穿插的描述,列出在回收一页时按顺序需要拿到哪些锁、为什么这个顺序不能颠倒。

系列导航

参考资料