背景

回收一个 anon folio 先要把它写进 swap,这是 IO。GFP_NOIO 分配路径禁止启动 IO,这条路径上能回收的 anon folio 只剩已经在 swapcache 里的:内容与 swap 一致,可以直接释放。

vmscan 决定要不要扫 anon LRU 时并不看这一点。can_reclaim_anon_pages() 只查 swap 空间余量,余量大于零就放行扫描。zRAM 系统的 swapcache 本来就稀少,GFP_NOIO 回收于是常常把整条 anon LRU 扫一遍,却一个 folio 也收不回。

具体场景来自 Android:dm-verity 校验块读取经 dm-bufio 以 GFP_NOIO 分配内存,触发回收时撞上 inactive_anon 约 335MB、inactive_file 约 4MB 的 memcg。单次 shrink_lruvec() 调用约 2400 次 shrink_folio_list()、扫描约 93000 个 anon folio,耗时 150 ms 以上却零回收,实际进展全靠 file 侧。

能扫的与能收的对不上
┌──────────────────────────────────────────────┐
│ 是否扫 anon 只看 swap 空间余量,与 GFP       │
│ 掩码无关                                     │
└──────────────────────┬───────────────────────┘
                       ▼
┌──────────────────────────────────────────────┐
│ GFP_NOIO 禁止启动 IO,能收回的只有 swapcache │
│ 里的 anon folio                              │
└──────────────────────┬───────────────────────┘
                       ▼
┌──────────────────────────────────────────────┐
│ swapcache 稀少时整条 anon LRU 扫下来         │
│ 颗粒无收                                     │
└──────────────────────────────────────────────┘

问题

  • GFP_NOIO 禁止启动 IO,可回收的 anon folio 只剩 swapcache 里的
  • 是否扫描 anon 只看 swap 空间余量,不看 swapcache 是否值得扫
  • zRAM 系统 swapcache 稀少,整条 anon LRU 扫描几乎零产出
  • shrink_lruvec() 扫 93000 个 anon folio,150 ms 零回收

方案

给 can_reclaim_anon_pages() 加上 swapcache 水位判定,水位太低就整轮跳过 anon。

新增的 reclaimable_anon_is_low() 分几种情况:

  • GFP 掩码带 __GFP_IO:直接放行,回收能力不受 swapcache 限制
  • MGLRU 启用:同样直接放行,原因在文末说明
  • 其余情况:比较 swapcache 页数与 anon 总页数的 1/64,低于即判定水位过低

统计来源随回收范围区分:memcg 回收读该 memcg 的 lruvec 统计;全局回收没有 memcg,读整 node 的统计。这里不能图省事用根 memcg 的 lruvec,它不含子 memcg 的计数,而 anon 实际都住在子 memcg 里,读它会把 anon 总量算小,阈值跟着缩水,判定形同虚设。

判定成立后 can_reclaim_anon_pages() 返回 false,get_scan_count() 走 SCAN_FILE,anon LRU 整轮不扫。swapcache 连 anon 总量 1/64 的下限都不到,扫完整条列表也找不出几个能收的。

swapcache 水位决定是否扫 anon
                                  ┌────────────────────────────────────────────┐
┌────────────────────────┐   ┌───▶│   带 __GFP_IO 或水位达标,anon 照旧扫描    │
│ can_reclaim_anon_pages │   │    └────────────────────────────────────────────┘
│ 在原有 swap 空间检     ├───┤
│ 查上加水位判定         │   │    ┌────────────────────────────────────────────┐
└────────────────────────┘   └───▶│ 无 __GFP_IO 且水位低于阈值,只扫 file 列表 │
                                  └────────────────────────────────────────────┘

代价是这条路径不再顺带 aging anon 列表,但 aging 只是延迟而非丢失:kswapd 与其他带 __GFP_IO 的回收器仍会扫描并 aging anon。花上百 ms 给本路径收不回的内存做 aging,在延迟敏感路径上并不划算。另一种思路是在 shrink_folio_list() 里扫到不可回收的 anon folio 再逐个绕过,但那样 folio 留在列表里又不刷新访问状态,反而显得比实际更热,从扫描入口整轮跳过更干净。整个判定只覆盖传统 active/inactive LRU:MGLRU 在自己的路径里选择扫 anon 还是 file,并不查询 can_reclaim_anon_pages(),水位判定返回过低对它跳不过 anon,却仍会拉低 set_initial_priority() 算出的初始 priority,反而可能扫得更多;所以 MGLRU 启用时判定直接放行、整体不生效,MGLRU 场景留作后续处理。

收益

作者未提供性能数据,从代码逻辑推断的预期收益:

  • GFP_NOIO 回收在 swapcache 稀少时不再空扫整条 anon LRU,观测到的超过 150 ms 空转随之消除
  • 判定只在传统 LRU、无 __GFP_IO 且水位低于阈值时生效,其余回收路径行为不变
  • swapcache 回升到阈值之上后 anon 扫描自动恢复