背景
内存回收、迁移、碎片整理等场景经常需要把一个高阶 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% |