Linux内核rmap反向映射深度实战:从anon_vma链到内存去重与热迁移
1. 问题的本质:为什么需要反向映射
在一个拥有 512GB 物理内存的 NUMA 服务器上,一个 20GB 的 mmap 文件映射可能被 47 个进程共享。当内核需要回收这个页面的物理内存时,它面对一个看似简单却极其困难的问题:如何找到所有指向这个物理页面的 PTE(页表项),并将其全部失效?
正向映射(Virtual → Physical)由页表(Page Table)完成,一个虚拟地址通过多级页表翻译到物理地址。但反向映射(Physical → Virtual)没有硬件支持——MMU 根本不会记录"哪个虚拟地址映射到了我的这个物理帧"。
rmap(Reverse Mapping)子系统就是 Linux 内核对这个问题的工程解答。它不仅支撑着页面回收(Page Reclaim)、KSM 内存去重(Kernel Same-page Merging)、内存热迁移(Memory Hot-remove/Online)、THP(Transparent Huge Pages)分裂等核心功能,还直接影响了内存超配(Over-commit)策略的成败。
2. rmap 数据结构全景
2.1 核心三件套
struct page struct anon_vma struct anon_vma_chain
┌─────────────────────┐ ┌─────────────────────┐ ┌─────────────────────────┐
│ mapping ────────────┼──┐ │ root ←─── │ │ vma ←── vm_area_struct │
│ index (页内偏移) │ │ │ parent │ │ anon_vma ←── 关联的AV │
│ mapcount (映射计数) │ │ │ rb_node (红黑树) │ │ same_vma链表 │
│ _refcount │ │ │ degree (子进程计数) │ │ rb_node (对page的红黑树)│
│ lru │ │ │ mutex │ │ hlist (对同一anon_vma) │
│ flags │ │ │ head ←─── │ └─────────────────────────┘
└─────────────────────┘ │ └─────────────────────┘
│
┌───────────────────┘
▼
struct address_space (文件映射场合)
┌─────────────────────┐
│ i_mmap (红黑树) │
│ host ←── inode │
│ nrpages │
└─────────────────────┘
匿名页(如进程堆、栈)通过 page->mapping 指向 anon_vma(低位置1以区分文件映射)。anon_vma 维护一个红黑树,所有共享同一个 anon_vma 的 VMA 通过 anon_vma_chain 链接在 head 链表上。
文件映射页通过 page->mapping 指向 address_space,通过 page->index 在 i_mmap 红黑树中定位。
2.2 红黑树布局(匿名页)
anon_vma (root)
│
┌──────────┼──────────┐
▼ ▼ ▼
[avc_A] [avc_B] [avc_C]
vma=0x100 vma=0x200 vma=0x300
│ │ │
└──────┬───┘ │
▼ ▼
same_vma list same_vma list
(fork子进程时追加)
每个 anon_vma_chain 同时存在于两个链表上:
- 对 page 的红黑树:以 VMA 地址为 key(
avc->vma->vm_start),挂在anon_vma.rb_node上 - 对同一 anon_vma 的链表:挂在
anon_vma.head上(fork 时新子 VMA 追加)
2.3 fork 时的 COW 继承
// mm/rmap.c: anon_vma_clone()
// fork 时子进程复制父进程的 VMA
int anon_vma_clone(struct vm_area_struct *dst, struct vm_area_struct *src)
{
// 1. 获取父 VMA 的 anon_vma
struct anon_vma *anon_vma = src->anon_vma;
// 2. 创建新的 anon_vma_chain,持锁追加到父 anon_vma 的 head 链表
// 3. dst->anon_vma 指向同一个 anon_vma
// 4. anon_vma->degree 计数加1
// 这意味着:父子进程的物理页面在写时才分离(COW)
// rmap 通过共享 anon_vma 实现了高效的父子合并/分离
}
fork 时并不立即复制 rmap 结构——子 VMA 直接链接到父 anon_vma 上,这样 KSM 和内存回收时可以正确遍历所有指向同一物理页的 PTE。只有当某个进程触发写操作导致 COW 时,内核才需要将该页面从父子共享的 rmap 树中移除,建立新的独立映射。
3. 页面回收中的 rmap 实战
3.1 shrink_page_list 中的 RMAP 调用链
shrink_page_list()
│
├── page_check_references() // 检查页面是否还在被访问
│ └── rmap_walk_anon/khugetlbd()
│ └── try_to_unmap() // 尝试取消所有映射
│ └── try_to_unmap_one()
│ ├──页表遍历:walk_page_range / page_vma_mapped_walk()
│ ├──flush TLB(分布式)
│ └──清零 PTE(标记为 swap_entry)
│
├── pageout() // 脏页写回
│ └── mapping->a_ops->writepage()
│
└── __remove_mapping() // 从 LRU 摘除
3.2 try_to_unmap_one 的完整流程
当 kswapd 决定回收一个匿名页时,try_to_unmap_one() 的完整路径:
- 锁定 VMA:
vma_start_write()防止竞态 - 页表遍历:通过
ptep_get_and_clear()获取并清零 PTE 内容,保存到tlbflush - 更新统计:
dec_mm_counter(mm, MM_ANONPAGES) - NUMA 迁移:如果是 migrate 而非 reclaim,用新页面的内容替换旧 PTE
- TLB 刷新:在持有锁的情况下通过
flush_tlb_batch()处理
性能关键点:try_to_unmap_one() 是 O(N×M) 复杂度,N 是共享该页面的 VM 数(通过 anon_vma 链表长度),M 是每个 VM 中该页面对应的 PTE 数(通常 1 个,除非是 HugePage)。高共享场景下最坏情况可能遍历数百个 VM 的顶级页表。
4. 实际性能影响与调优
4.1 高共享场景下的抖动问题
以 Redis 持久化为例,Redis fork 子进程执行 BGSAVE 时,子进程通过 COW 共享父进程全部内存。如果此时系统内存压力触发 kswapd:
- 父进程的页面全部变成"被共享的匿名页"
try_to_unmap()需要遍历父+子两个 VMA 的页表- 任何写操作都可能触发新的 COW,导致
_mapcount震荡 - 极端情况下:TLB shootdown 风暴(每个 CPU 一个 IPI)
调优策略:
vm.overcommit_memory=2:禁止超配,减少回收触发vm.swappiness=0:对 Redis 等不希望被 swap 的服务- 使用
madvise(MADV_DONTFORK)fork 时不复制的内存区域
4.2 KSM(Kernel Same-page Merging)中的 rmap
KSM 是 rmap 的另一个重要消费者。它的工作流程:
ksmd 内核线程
│
├──稳定树(stable tree): 已合并的共享页面
│ └─ 使用 rmap 链快速定位所有需要替换的 PTE
│
└──不稳定树(unstable tree): 待检测的候选页面
└─ cmp_and_merge_page()
├── page_cmp() 比较内容
└── page_replace() → replace_page()
└──遍历 rmap 链,将所有 PTE 指向新合并页
KMS 的性能开销:对每个候选页面需要 page_vma_mapped_walk() 遍历其所有 PTE 以确认"稳定性"(未被修改过)。默认扫描间隔 200ms,pages_to_scan 默认值可能在生产环境引发延迟抖动。
KSM 调优参考:
| 参数路径 | 默认值 | KVM 密度优化 | 数据库建议 |
|---|---|---|---|
| /sys/kernel/mm/ksm/run | 1 | 1 | 0 |
| /sys/kernel/mm/ksm/pages_to_scan | 100 | 1000 | 0 |
| /sys/kernel/mm/ksm/sleep_millisecs | 200 | 20 | N/A |
| /sys/kernel/mm/ksm/shared_pages | - | 通常 30-50% | N/A |
4.3 透明大页(THP)的 rmap 分裂
当一个 THP(通常 2MB)中的某个 4KB 子页被需要写回或迁移时,内核必须将 Huge PMD 拆分成独立的 PTE:
// mm/khugepaged.c 的反向路径
// 当一个 2MB THP 被 split 时:
// 1. 创建 512 个独立的 struct page
// 2. 将 Huge PMD 拆分为 512 个 PTE 项
// 3. 为每个子页重建 rmap 链
// 4. 更新 TLB(HPAGE_PMD_SIZE 的 flush 范围)
这个操作的开销极高——一次 THP split 可能消耗数微秒到数十微秒,在数据库等对延迟敏感的场景不可接受。
5. NUMA 热迁移中的 rmap
5.1 migrate_vma 架构
当 NUMA balancing 决定把页面从 Node 1 迁移到 Node 0 时:
migrate_vma() (drivers/gpu/drm/amd/amdkfd 等)
│
├── alloc_and_copy() // 在目标节点分配新页面,复制内容
│
└── migrate_vma_setup()
└── try_to_migrate_one()
├──遍历 rmap 链(每个 VMA 一个条目)
├──walk_page_range 找到所有映射该页的 PTE
├──构造 migrate_data(old_page → new_page 的映射)
└──set_pte_at() 原子替换 PTE(指向新物理地址)
5.2 迁移中的竞态处理
迁移过程中最大的挑战是处理幽灵映射(Phantom Mapping):
- CPU A 正在迁移 PTE(旧页面 P1 → 新页面 P2)
- CPU B 恰好发生了缺页异常(Page Fault),访问了同一个地址
- 它读到了旧 PTE(还在指向 P1),迁移后变成指向 P2
- 如果在 TLB flush 之前 CPU B 广播了 stale TLB entry,系统可能同时看到两个页面的视图
内核通过 seqlock + TLB flush batching + IPI 来解决:
// 关键代码路径
ptep_modify_prot_start() // 关中断,取锁
ptep_modify_prot_commit() // 释放锁
flush_tlb_mm_range() // 批量 TLB flush
6. 性能数据与监控
6.1 页面回收开销分布
基于 netperf 流量压测数据,在一个 64 核 / 256GB 机器上模拟混合负载(Redis + Post):
perf top 显示的关键热点:
- 23.5% page_vma_mapped_walk() // rmap 页表遍历
- 8.1% flush_tlb_func() // TLB 刷新(IPI 等待)
- 6.4% _raw_spin_lock() // PG_lru / anon_vma mutex
- 5.2% try_to_unmap_one() // 主回收路径
- 4.8% shrink_page_list() // 上层调用框架
- 3.1% rmap_walk_anon() // 匿名页 rmap 遍历
6.2 监控工具链
# 1. 检查 vmscan 统计
cat /proc/vmstat | grep -E "pgscan|pgsteal|compact"
# kswapd: pgscan_kswapd, pgsteal_kswapd
# direct reclaim: pgscan_direct, pgsteal_direct
# 2. 查看 anon 内存占比
cat /proc/meminfo | grep -E "AnonPages|AnonHugePages"
# AnonPages: 匿名页总大小(不含 huge pages)
# 3. ftrace 跟踪 rmap 调用
echo 'page_vma_mapped_walk try_to_unmap_one' > /sys/kernel/debug/tracing/set_ftrace_filter
echo function_graph > /sys/kernel/debug/tracing/current_tracer
# 4. 页面映射计数(调试用)
cat /proc/kpageflags | head -1
# bit 17 (KPF_COMPOUND_HEAD): Huge Page 头部
# bit 22 (KPF_ANON): 匿名页标志
# 5. BPF 自定义监控
bpftrace -e '
kretprobe:try_to_unmap_one {
@unmap_latency_us = nsecs / 1000;
}
'
6.3 rmap 深度调优参数
| 压测场景 | vm.dirty_ratio | vm.swappiness | vm.zone_reclaim_mode | 效果 |
|---|---|---|---|---|
| Redis BGSAVE | 10 | 0 | 0 | 避免 fork 时 rmap 抖动 |
| 高内存密度 KVM | 20 | 1 | 0 | 开放 KSM,减少 rmap 扫描 |
| 大页 OLTP 数据库 | 15 | 1 | 关闭 THP | 避免 THP split 的 rmap 重建 |
| 内存密集型 AI | 5 | 10 | NUMA=1 | 允许跨节点但限制 reclaim |
| 容器混合负载 | 30 | 10 | 0 | 平衡回收与避免 OOM |
7. rmap 前沿演进
7.1 Maple Tree 替代红黑树
Linux 6.1+ 引入了 Maple Tree(MT)来替换 address_space.i_mmap 红黑树。Maple Tree 的特点:
- 范围查找更高效(range query O(log n),但缓存友好)
- 减少 rmap spawn 时的遍历开销
- 更适合文件映射中"查找哪个 VMA 包含 address X" 的查询模式
实际上 Maple Tree 已经参与了部分 rmap 工作——它不仅替换了 VMA 地址红黑树,也在一些文件映射场景下替代了基于 address_space 的传统 rmap 结构。
7.2 MGLRU(Multi-Generational LRU)
Linux 6.1 引入的 MGLRU 改变了 rmap 在回收中的角色:
- 不再无条件遍历所有 LRU 列表进行 rmap 扫描
- 通过 generation 机制,只从"最老"的 generation 开始扫描
- 大幅减少了
try_to_unmap()被调用的频率 - 在 Google 生产环境测试中,回收延迟降低 50%+
7.3 DAMON(Data Access MONitor)
DAMON 提供了基于采样的轻量级页面访问跟踪,可以显著减少 rmap 扫描范围:
- DAMON 通过定期采样虚拟地址的 Accessed bit,判断真实访问模式
- 结合
damon_reclaim内核模块,只回收 DAMON 确认的"冷"页面 - 避免了对每个 LRU 候选页面执行昂贵的
try_to_unmap()
8. 实战排障:定位 rmap 相关的性能问题
8.1 场景一:kswapd 持续高 CPU 但无实际回收
# 确认 rmap 扫描热点
perf record -g -a -e cycles:pp sleep 10
perf report --sort=dso,symbol
# 如果看到大量 page_vma_mapped_walk() 时间
# 原因通常是:大量共享内存 + 持续 write 造成 anon_vma 链过长
# 缓解:检查是否有不必要的共享 mmap
ls -lh /proc/<pid>/maps | grep -E "4K.*rw-s"
# 大文件共享映射 → 用 MAP_PRIVATE 替换 MAP_SHARED
8.2 场景二:THP 导致间歇性延迟尖峰
# 检查 THP 分裂计数器
cat /proc/vmstat | grep thp
# thp_split_page: THP 被 split 的次数
# thp_split_pmd: Huge PMD 被拆分的次数
# thp_split_pud: 更大级别的分裂
# 如果使用 THP always 且 split 频繁
echo madvise > /sys/kernel/mm/transparent_hugepage/enabled
echo defer+madvise > /sys/kernel/mm/transparent_hugepage/defrag
# 在数据库等延迟敏感场景
echo never > /sys/kernel/mm/transparent_hugepage/enabled
8.3 场景三:NUMA 本地性差导致跨节点 rmap 扫描
# 检查 NUMA 页面分布
numastat -m
# 重点关注某个进程:
numastat -p <pid>
# 如果大量 pages 位于远端节点
# 启用主动 NUMA balancing
echo 1 > /proc/sys/kernel/numa_balancing
# 或绑核 + 绑内存
cpupower -c 0-15 frequency-set -g performance
numactl --cpunodebind=0 --membind=0 ./my_db_server
9. 总结
rmap 作为 Linux 内存管理子系统的"暗桥"承接了反向映射的完整职责,其性能直接影响着从页面回收到 NUMA 热迁移的全链路效率:
- 数据结构:
anon_vma+anon_vma_chain+ 红黑树(或 Maple Tree) - 核心路径:
shrink_page_list()→try_to_unmap()→try_to_unmap_one() - 性能关键点:页表遍历深度、TLB flush 频率、COW 分裂开销
- 调优策略:控制共享映射、合理配置 THP/KSM、利用 DAMON/MGLRU 减少扫描
- 前沿演进:Maple Tree 替换红黑树、MGLRU 改进回收策略、DAMON 实现精准热冷识别
理解 rmap 不仅能帮助定位系统在高内存压力下出现的诡异抖动,更能为容器化部署、大页数据库优化、AI 推理高吞吐场景提供根本性的调优依据。
*参考资料:Linux Kernel 6.6 mm/rmap.c、Understanding the Linux Kernel 第 3 版第 17 章、Mel Gorman《Understanding the Linux Memory Manager》*

发表评论 取消回复