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 维护两棵树:

  1. 稳定树(Stable Tree):红黑树,键为 page_hash(基于内容的 checksum)。已合并的页在这里,每个节点上有 rmap_item 链表指向合并前的所有虚拟地址。

  2. 不稳定树(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 的设计体现了内核工程的几个经典权衡:

  1. 空间换时间:每个物理页帧维护反向指针,用常驻内存代价换取 O(k) 的回收效率。
  2. 锁粒度细化:从全局 mmap_lock → per-anon_vma rwsem → RCU walk 的持续演进。
  3. 数据结构自适应:红黑树区间搜索 vs 链表,针对不同场景选择最优方案。

在多租户云环境中,精确的 rmap 遍历决定了 memory cgroup 的 OOM 回收效率——如果 rmap 漏掉了某个映射,cgroup 无法真正释放内存,会持续 OOM-kill 直到容器重启。理解并监控 rmap 行为,是 SRE 工作中不可或缺的一环。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部