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() 的完整路径:

  1. 锁定 VMA:vma_start_write() 防止竞态
  2. 页表遍历:通过 ptep_get_and_clear() 获取并清零 PTE 内容,保存到 tlbflush
  3. 更新统计:dec_mm_counter(mm, MM_ANONPAGES)
  4. NUMA 迁移:如果是 migrate 而非 reclaim,用新页面的内容替换旧 PTE
  5. 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》*

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部
/* 跳过导航链接 (无障碍) */ position: absolute; top: -100px; left: 15px; z-index: 99999; padding: 8px 16px; background: #007bff; color: #fff; font-size: 14px; border-radius: 0 0 4px 4px; text-decoration: none; transition: top 0.2s; } top: 0; outline: 3px solid #0056b3; }