背景

内存回收、迁移、碎片整理等场景经常需要把一个高阶 folio 拆成若干低阶 folio,这就是大页拆分。拆分前 folio 必须锁定,对仍有映射的 folio 先解除映射:匿名页改装 migration entry,拆完再恢复;file page 直接拆掉映射,之后按需缺页恢复。随后冻结引用计数,把 refcount 原子地清零,此后 folio_try_get 一律失败,没人能在拆分途中拿到新引用,这是拆分期间互斥的根基。真正拆开复合页结构后,再逐个解冻子 folio,挂回 LRU,并把指向旧 folio 的缓存条目改成指向新 folio。

大页拆分的通用骨架
┌────────────┐   ┌────────────────┐   ┌──────────┐   ┌────────────────┐
│ 解除映射, │   │                │   │          │   │                │
│ 匿名页改装 │   │ 冻结引用计数, │   │ 逐阶拆开 │   │ 解冻子 folio, │
│ migration  ├──▶│ 此后谁也拿     ├──▶│ 复合页结 ├──▶│ 按类型回写     │
│ entry 以便 │   │ 不到新引用     │   │ 构       │   │ 缓存条目       │
│ 事后恢复   │   │                │   │          │   │                │
└────────────┘   └────────────────┘   └──────────┘   └────────────────┘

这套流程本来不分匿名还是文件。随着 mTHP 让任意阶数的大页走进通用路径,__folio_freeze_and_split_unmapped 一个函数里同时装着三类 folio 的善后:匿名的 anon_vma 与 migration entry、page cache 的 xarray 更新与 EOF 裁剪、swap cache 的 cluster 加锁与条目替换。两条路径真正共享的只有冻结与解冻的骨架,其余全是交叉的条件分支。

三类 folio 揉在一个函数里
                      ┌─────────────────────────────────────┐
                      │ __folio_freeze_and_split_unmapped, │
                      │ 冻结与解冻的公共骨架                │
                      └──────────────────┬──────────────────┘
          ┌──────────────────────────────┴──────────────────────────────┐
          ▼                             ▼                               ▼
┌────────────────────┐     ┌─────────────────────────┐     ┌──────────────────────────┐
│ 匿名处理,anon_vma │     │ page cache 处理,xarray │     │ swap cache 处理,cluster │
│ 与 migration entry │     │ 与 EOF 裁剪             │     │ 加锁与条目替换           │
└────────────────────┘     └─────────────────────────┘     └──────────────────────────┘

swap cache 拆分的现状是只允许统一拆到 order-0,且不收无 mapping 的 swap cache folio。混杂的结构让这些限制既难放开也难评审:动 swap cache 一侧的逻辑,得在缠在一起的匿名与文件分支里反复确认不伤及对方。

问题

  • 匿名、page cache、swap cache 三类善后逻辑揉在同一函数,分支交叉。
  • swap cache 的拆分限制难以放开,相关改动评审成本高。
  • 带 mapping 的 swap cache folio 冻结后才被拒,卡死且计数失真。
  • 未映射匿名大页因拿不到 anon_vma 一律被拒拆,其实没有反向映射要保护。
  • 拆后释放 swap cache 先解锁再抢锁,抢不到就残留 swap entry。

方案

核心动作是把 __folio_freeze_and_split_unmapped 按类型分家:__folio_freeze_split_anon 收编匿名 folio 的全部处理,包括匿名 swap cache;__folio_freeze_split_file 收编 page cache 与 shmem。分家能成立,靠的是一条不变量:file 助手永远见不到 swap cache folio。前置检查 folio_check_splittable 本就拒绝无 mapping 的非匿名 folio,这次再把带 mapping 的非匿名 swap cache folio,也就是 swap IO 中的 shmem,一并提前拒掉,swap cache 由此只可能出现在匿名一侧。

按类型分家后的结构
                          ┌─────────────────────────────────────┐
                          │ __folio_split,只做前置检查,按类型 │
                          │ 分发,拆后统一解锁                  │
                          └──────────────────┬──────────────────┘
                     ┌───────────────────────┴───────────────────────┐
                     ▼                                               ▼
┌─────────────────────────────────────────┐      ┌───────────────────────────────────────┐
│ __folio_freeze_split_anon,匿名 folio, │      │ __folio_freeze_split_file,page cache │
│ 含匿名 swap cache                       │      │ 与 shmem,见不到 swap cache           │
└─────────────────────────────────────────┘      └───────────────────────────────────────┘

锁也跟着各归各。匿名助手用一对 local_irq_disable 罩住 swap cluster 锁和 LRU 锁等多把普通自旋锁;文件助手只需要 i_pages 的 xarray 锁,改用 xas_lock_irq 一体完成关中断与加锁,顺带消掉了 mm 里对 i_pages 的最后一把裸 xas_lock。原来由 __folio_split 统一管理的关中断区间就此消失,__folio_split 只剩前置检查、按类型分发、拆后统一解锁与统计。

匿名助手的流程
┌─────────────┐   ┌───────────────┐   ┌──────┐   ┌───────────┐   ┌─────────────────┐
│ 已映射才    │   │ 冻结引用      │   │      │   │ 替换 swap │   │                 │
│ 取 anon_vma │   │ 计数,锁      │   │ 拆开 │   │ cache 条  │   │ 已映射才        │
│ 写锁并解    ├──▶│ 住 swap cache ├──▶│ 复合 ├──▶│ 目,解冻  ├──▶│ 恢复映射        │
│ 除映射      │   │ cluster       │   │ 页   │   │ 挂回 LRU  │   │ 并释放 anon_vma │
│             │   │               │   │      │   │           │   │                 │
└─────────────┘   └───────────────┘   └──────┘   └───────────┘   └─────────────────┘

匿名助手把 anon_vma 的获取、解除映射、事后恢复全部收进来,并以 folio_mapped 为闸门:只有真的映射着,才取 anon_vma 写锁并解除映射,拆完再恢复。未映射的匿名大页没有反向映射要遍历,anon_vma 操作整个跳过,直接冻结引用计数完成拆分:

if (folio_mapped(folio)) {
    anon_vma = folio_get_anon_vma(folio);
    if (!anon_vma)
        return -EBUSY;
    anon_vma_lock_write(anon_vma);
    ret = unmap_folio(folio);
    ...
}

这一步改变了未映射匿名大页的命运。典型场景是一个被整体换出的 THP:它还活在 swap cache 里,但 swap PTE 不计入映射计数,内核看它 folio_mapped 为假。旧代码一律先取 anon_vma,取不到就报 -EBUSY 拒绝拆分,而它拿不到 anon_vma 恰恰因为未映射,形成没东西要锁却被锁挡住的怪圈。新代码跳过这层锁,安全性由两处兜底:folio 锁全程持有,并发拆分被排除;冻结引用计数会拒绝拆分窗口内新出现的任何引用、mapping 或 GUP pin。未映射路径本身也早已由 folio_split_unmapped 在跑,并非新踩出来的路。对仍映射的 folio,anon_vma 写锁的持有窗口也收窄了,不再罩着拆后子 folio 的统一解锁循环,窗口内子 folio 仍持锁且引用固定,无并发风险。

未映射匿名大页的命运改变
                       ┌─────────────────────────────────────┐
                       │ 整体换出的 THP,swap PTE 不计入映射 │
                       │ 计数,呈现未映射                    │
                       └──────────────────┬──────────────────┘
                    ┌─────────────────────┴─────────────────────┐
                    ▼                                           ▼
┌───────────────────────────────────────┐     ┌───────────────────────────────────┐
│ 旧路径,取不到 anon_vma 直接报 -EBUSY │     │ 新路径,跳过 anon_vma,直接冻结引 │
│ 拒绝拆分                              │     │ 用计数完成拆分                    │
└───────────────────────────────────────┘     └───────────────────────────────────┘

文件助手同样收齐自己的全套准备:检查 mapping 允许的最小阶数,切到 folio 的 memcg 以便 xarray 节点分配计入正确的账单,预分配拆分所需的 xarray 节点,读 i_mmap 锁,算出 EOF 边界,再解除映射。拆完回写 page cache 时顺手裁掉 EOF 之外的部分,shmem 超额计费就地归还。

文件助手的流程
┌───────────────┐   ┌─────────────┐   ┌────────────┐   ┌───────────────────┐
│ 切到 folio 的 │   │ 算好 EOF 边 │   │ 冻结引用计 │   │ 回写 page cache, │
│ memcg,预分配 ├──▶│ 界后解除映  ├──▶│ 数,拆开复 ├──▶│ 裁掉 EOF 之       │
│ xarray 节点   │   │ 射          │   │ 合页       │   │ 外的部分          │
└───────────────┘   └─────────────┘   └────────────┘   └───────────────────┘

拆后收尾也借分家变干脆。原来对每个子 folio 调 free_folio_and_swap_cache,它先解锁,再靠 trylock 抢回 folio 检查并释放 swap cache 条目,抢不到就把条目留下,释放与否全凭运气。新代码在子 folio 仍持锁且未映射时直接在锁下释放,映射着的子 folio 跳过释放以免白做功,随后才解锁并减引用,释放变得确定:

if (is_swapcache && !folio_mapped(new_folio))
    folio_free_swap(new_folio);
folio_unlock(new_folio);
folio_put(new_folio);

顺带修掉两处错误路径。带 mapping 的 swap cache folio 原本在引用冻结、THP 计数扣减之后才被发现不可拆,直接报错返回,不解冻也不回滚计数,folio 就此卡死,现在这类 folio 在 folio_check_splittable 阶段就被拒绝,任何状态都还没动。另一处是 swap cache 条目替换自检的循环 off-by-one,首条目被跳过、范围外多读一条,修正后与替换循环写法一致,为将来放开多阶 swap cache 拆分铺路。

收益

同一台 x86-64 机器交替重启交错测量,ftrace function_graph 过滤 __folio_split,每轮 135 次拆分调用,共 600 轮:

指标 Before After 改善
__folio_split 单次调用耗时 68.52µs 67.38µs 1.66%
耗时标准差 1.58µs 1.33µs 15.8%