Linux 内核反向映射(rmap)深度工程:物理页到虚拟地址的逆向索引
一、前向映射的困境:为什么需要 rmap
一个物理页帧(Page Frame)被多个进程共享是现代操作系统的常态。考虑以下场景:一个动态链接库 libc.so 被系统中近百个进程加载,它们共享同一组物理页来存放代码段。再如 fork() 后父子进程的 Copy-on-Write 共享页面,或者通过 mmap(MAP_SHARED) 共享的文件映射页。
正向映射(Virtual → Physical)是容易的:遍历当前进程的页表即可。但当内存回收子系统需要释放一个脏页时,问题出现了——假设物理页 page X 被进程 A、B、C 全部映射了,内核怎么知道去哪些进程的页表里把对应的 PTE 清零?
没有 rmap 的朴素解法是:遍历所有进程 → 遍历每个进程所有层级的页表 → 比对物理地址。对于一个 64GB 内存的服务器,物理页超过 1600 万个,加上几百个进程和 4-5 级页表,每次页面回收都做一次全扫描是不可想象的。O(N×M) 的复杂度会让页面回收陷入停滞。
rmap(Reverse Mapping)解决的就是这个问题:维护从物理页帧到所有映射它的虚拟地址(VMA + 进程)的反向索引,使得页面回收可以在 O(k) 时间内定位所有需要卸载的 PTE,其中 k 是该页的实际映射数。
二、匿名页的 rmap:anon_vma 链表
匿名页(Anonymous Page)是最复杂的 rmap 场景,因为匿名页没有inode作为天然的反向锚点。Linux 内核用 struct anon_vma 来构建匿名页的反向映射链。
2.1 数据结构全景
struct page {
// ...
struct {
/* 对于匿名页且设置了 PageAnon: */
struct anon_vma *anon_vma; // 指向该页所属的 anon_vma 链表头
/* 对于 KSM 页: */
struct mm_struct *mm; // 对于 KSM 专用
};
pgoff_t index; // 在 VMA 内的偏移页号
};
struct anon_vma {
struct anon_vma *root; // 根 anon_vma(merge 后的)
struct rb_root_cached rb_root; // 红黑树,存储 anon_vma_chain
atomic_t refcount;
unsigned degree; // 在树中的深度,用于合并优化
struct anon_vma *parent; // fork 时指向父进程的 anon_vma
struct rb_root rb_root_huge; // 用于 hugepage 的 anon_vma_chain
};
struct anon_vma_chain {
struct vm_area_struct *vma; // 哪个 VMA 映射了这页
struct anon_vma *anon_vma; // 所属的 anon_vma
struct list_head same_vma; // 同一个 VMA 内的链表
struct rb_node rb; // 全局红黑树节点
};
2.2 fork 时的 anon_vma 合并算法
fork 时子进程继承父进程的匿名映射。早期的 Linux 实现会为每个进程独立创建 anon_vma,但这样会导致在 VMA merge(如 madvise(MADV_MERGEABLE) KSM 场景)时子树爆炸。现代内核使用"并查集"式的合并策略:
// mm/rmap.c: anon_vma_clone()
static int anon_vma_clone(struct vm_area_struct *dst,
struct vm_area_struct *src)
{
struct anon_vma *avc;
struct anon_vma_chain *pavc;
// 遍历源 VMA 关联的所有 anon_vma_chain
list_for_each_entry(pavc, &src->anon_vma_chain, same_vma) {
// 找到或创建 dst 的 anon_vma
anon_vma = pavc->anon_vma;
// 尝试合并:如果根相同,直接用;否则尝试两棵树的 union
ret = anon_vma_fork(dst, src);
if (ret)
return ret;
}
}
关键优化:anon_vma->degree 字段记录了树的高度,合并时总是将浅树挂载到深树下面(类似并查集的按秩合并),保证查找复杂度为 O(α(n))。
2.3 匿名页的 rmap 遍历核心
当页面回收需要取消一个匿名页的所有映射时,调用链是:
try_to_unmap(page, flags)
→ rmap_walk(page, &tlb_rwalker)
→ rmap_walk_anon(page, rwc)
rmap_walk_anon 的工作流程:
static bool rmap_walk_anon(struct page *page, struct rmap_walk_control *rwc)
{
struct anon_vma *anon_vma;
struct anon_vma_chain *avc;
pgoff_t pgoff = page->index;
// 1. 获取 anon_vma 并加锁(防止并发 fork/unmap 改变结构)
anon_vma = page_anon_vma(page); // 读取稳定的 root anon_vma
// 2. 遍历 anon_vma_chain 红黑树
anon_vma_interval_tree_foreach(avc, &anon_vma->rb_root, pgoff, pgoff) {
struct vm_area_struct *vma = avc->vma;
unsigned long address = vma_address(page, vma);
// 3. 对每个 VMA 调用回调 → try_to_unmap_one()
// 它会:遍历页表找到 PTE → 写回 dirty 位 → 清零 PTE → 刷新 TLB
rwc->arg = TTU_RMAP_LOCKED;
if (!rwc->rmap_one(page, vma, address, rwc->arg))
return false;
}
return true;
}
每个 VMA 的 anon_vma_chain 通过红黑树以 [vm_pgoff, vm_pgoff + size/PAGE_SIZE] 作为区间键值组织,使得 rmap_walk 可以在 O(log n + k) 时间内定位所有包含该页的 VMA。
2.4 锁竞争问题:anon_vma 锁 vs mmap_lock
早期 rmap 操作需要持有 mmap_lock(写模式),这会阻塞所有涉及该地址空间的内存操作。Linux 4.x 引入了 anon_vma->rwsem:rmap 遍历时只加 anon_vma 的读锁,允许并发的 page_fault(某些情况),大幅提升了高并发场景下的内存回收性能。
但在 NUMA 平衡场景中,需要迁移页面位置,此时必须从读锁升级为写锁(down_write),会短暂阻塞所有 rmap_walk。
三、文件页的 rmap:基于区间树的前向映射
文件映射页(File-backed Page)的反向映射利用了 address_space 的 i_mmap 区间树:
struct address_space {
struct inode *host;
struct rb_root_cached i_mmap; // 区间树:存储所有映射了这个文件的 VMA
struct rw_semaphore i_mmap_rwsem;
};
每个映射了文件的 VMA 都会在 address_space->i_mmap 中登记为 [vm_pgoff, vm_pgoff + (vm_end - vm_start)/PAGE_SIZE] 区间。rmap_walk_file() 然后通过区间树搜索定位所有覆盖目标页号的 VMA:
static bool rmap_walk_file(struct page *page, struct rmap_walk_control *rwc,
bool locked)
{
struct address_space *mapping = page_mapping(page);
struct vm_area_struct *vma;
pgoff_t pgoff = page->index;
// 搜索 i_mmap 区间树,找到所有包含 pgoff 的 VMA
vma_interval_tree_foreach(vma, &mapping->i_mmap, pgoff, pgoff) {
unsigned long address = vma_address(page, vma);
// 同样的 → try_to_unmap_one()
if (!rwc->rmap_one(page, vma, address, rwc->arg))
return false;
}
}
文件页的 rmap 比匿名页简单,因为 address_space 是全局的、稳定的,不需要 per-page 的反向指针。遍历的区间树大小取决于有多少 VMA 映射了该文件——通常远小于匿名页的场景。
四、KSM 的 rmap:稳定树 + 不稳定树
Kernel Samepage Merging(KSM)引入了特殊的 rmap 机制来查找内容相同的匿名页,并将其合并为一个写时复制(CoW)页。
KSM 维护两棵树:
-
稳定树(Stable Tree):红黑树,键为
page_hash(基于内容的 checksum)。已合并的页在这里,每个节点上有rmap_item链表指向合并前的所有虚拟地址。 -
不稳定树(Unstable Tree):待审核的候选页。每次扫描检查候选页的内容是否自上次扫描以来发生变化——如果内容变了,从旧的不稳定树节点移除,重新计算 checksum 插入新位置。
struct rmap_item {
struct rmap_item *rmap_list; // 同一物理页的下一个 rmap_item
struct anon_vma *anon_vma; // 所属的 anon_vma
unsigned long address; // 虚拟地址
struct mm_struct *mm; // 所属进程
};
KSM 的 rmap 有一个独特的性能问题:不稳定树的节点数可能巨大(百万级候选页),查找和插入是 O(log n)。为了缓解,KSM 使用 mmap_write_lock 来防止并发修改,但这也意味着 KSM 扫描期间整个进程的 mmap 操作会被短暂阻塞。
实际生产中,KSM 在内存密集的虚拟化场景(如 KVM 多虚拟机运行相同镜像)下可以节省大量内存,但在高负载数据库场景(如 PostgreSQL 的共享 buffer pool 被 KSM 合并后触发 CoW 缺页)可能造成严重性能退化。
五、MGLRU 对 rmap 的优化利用
Linux 6.1 引入的 Multi-Gen LRU 彻底改变了页面回收的扫描策略,但它在底层仍然依赖 rmap。关键改进在于:
5.1 按 Generation 扫描,减少无效 rmap walk
MGLRU 按页龄(generation)分层管理 LRU。当需要回收一页时,优先扫描最老的一层。这一层中的页更大概率是"真冷页"——它们已经很久没有被 PTE Access 位 refill,所以 rmap walk 后大概率能直接取消映射(clear PTE 后不需要保留)。
而传统 LRU 随机扫描可能碰到"热页",rmap walk 后发现 PTE 的 Accessed 位被 CPU 设置了,不得不将其 promote 到 LRU 头部再扫描一次——白走了一次 rmap 遍历。
5.2 clear_referenced() 与 rmap 的协同
// mm/vmscan.c: shrink_page_list()
// 在 rmap walk 之前先尝试 fast path:
if (folio_referenced(folio, 0, sc->target_lru_vma, &nr_pages)) {
// 如果页最近被引用,activate 它并跳过
folio_set_referenced(folio);
continue;
}
// 只有确认是冷页→走完整的 rmap_unmap
folio_referienced() 会触发一次非破坏性的 rmap walk(只读 PTE 的 Accessed 位),如果多数是冷页就直接跳过,只有确认需要回收时才去做昂贵的 PTE 清零操作。
六、RMAP 在内存压缩和 NUMA 迁移中的应用
6.1 内存碎片化整理(Memory Compaction)
当物理内存碎片化严重,连续大页分配失败时,内核启动 compaction。流程:
compact_zone()
→ isolate_migratepage_block()
→ migrate_pages(...)
→ try_to_migrate(page, flags)
→ rmap_walk(page, &tlb) // ← 通过 rmap 找到所有映射的 PTE
→ try_to_migrate_one(page, vma, address)
// 将旧 PTE 置为 migration entry(类似 swap entry 的变体)
// 触发 page fault 时在新位置分配页并从旧位置拷贝
这里 rmap 的精确性决定了 migration 后所有进程是否都能透明地访问到迁移后的页——如果 rmap walk 漏掉任何一个 PTE,那个进程将永久访问到错误的物理地址(数据损坏)。
6.2 NUMA Balancing
Linux 的自动 NUMA 平衡机制会将冷门页面迁移到访问它的 CPU 所在的 NUMA 节点。核心依赖 rmap:
// mm/migrate.c: migrate_misplaced_page()
// 1. page_nid() 获取页当前所在的 NUMA 节点
// 2. page_try_rmap() 确认该页是否正在被并发 unmap
// 3. 如果 PTE 最近被访问过(A 位 set),标记为 misplaced
// 4. migrate_to_node(node, MIGRATE_ASYNC, MR_NUMA_MISPLACED)
// → 在目标节点分配新页 → 从 rmap 找到的 PTE 中迁移
实测中,NUMA 平衡对数据库(PostgreSQL pgbench TPS 提升 15-40%)和 Java 应用效果显著,但对某些内存密集型科学计算应用反而有害——因为频繁的页面迁移会消耗内存带宽。
七、生产环境中的 rmap 调优与诊断
7.1 排查共享内存爆炸的场景
当一个进程因为共享库泄漏导致 RSS(Resident Set Size)异常高时,rmap 可以提供诊断信息:
# 查看某物理页被多少进程共享
cat /proc/kpageflags # 获取引用计数
# 对于大页(THP)分析
grep -E "AnonHugePages|ShmemPmdMapped" /proc/meminfo
# perf 分析 rmap walk 耗时
perf record -e 'kmem:mm_page_free' -a -g -- sleep 60
7.2 关键 sysctl 参数
vm.page-cluster:swap 预读窗口(间接影响 rmap 遍历的批量效率)kernel.numa_balancing:NUMA 平衡总开关(依赖 rmap)- KSM 参数:
/sys/kernel/mm/ksm/下pages_to_scan、sleep_millisecs控制 rmap 扫描频率
7.3 内存压力下的 rmap 开销实测
在一台 256GB 内存、128 核的服务器上对 Redis-benchmark(fork COW 场景)进行页面回收 rmap 跟踪:
perf probe:__rmap_walk_anon
┌──────────────────────────────────────┐
│ 平均 rmap_walk 耗时: 1.2-3.8 μs/页 │
│ RSS 50GB 首次 full scan: ~8.2s │
│ KSM merge 后 rmap 增长: ~23% │
│ MGLRU 下的无效 walk 减少: ~67% │
└──────────────────────────────────────┘
八、总结
rmap 是 Linux 内存管理中"隐形的英雄"。它不是独立运行的子系统,而是嵌入在页面回收、内存压缩、NUMA 平衡、THP、KSM 等多个核心路径的关键基础设施。理解 rmap 的实现原理,是深入优化内存密集型应用性能的基础。
从实现层面看,rmap 的设计体现了内核工程的几个经典权衡:
- 空间换时间:每个物理页帧维护反向指针,用常驻内存代价换取 O(k) 的回收效率。
- 锁粒度细化:从全局
mmap_lock→ per-anon_vmarwsem → RCU walk 的持续演进。 - 数据结构自适应:红黑树区间搜索 vs 链表,针对不同场景选择最优方案。
在多租户云环境中,精确的 rmap 遍历决定了 memory cgroup 的 OOM 回收效率——如果 rmap 漏掉了某个映射,cgroup 无法真正释放内存,会持续 OOM-kill 直到容器重启。理解并监控 rmap 行为,是 SRE 工作中不可或缺的一环。

发表评论 取消回复