背景

workingset shadow node 是 folio 被驱逐时留下的记录,refault 时靠它算出驱逐距离,既是 thrashing 检测的依据,也是 MGLRU 恢复热页 workingset 状态的凭据。shadow node 本身占内存,shrinker 按预算回收:预算是可驱逐 LRU 页数折成 1/8,再加 slab 部分,由 count_shadow_nodes() 算出。可驱逐页数经 lruvec_lru_size() 读取,开启 memcg 时它读的是 memcg 的 lru_zone_size 数组。

MGLRU 的记账不维护这个数组。lru_gen_update_size()、inc_max_seq() 等所有计数都走 __update_lru_size(),绕过 lru_zone_size,各 generation 的页数记在 lrugen->nr_pages[] 里;classical 的 lruvec_add/del_folio() 路径在 lru_gen_add/del_folio() 成功后也提前返回,不再碰数组。

预算漏算的由来
┌──────────────────────────────────────┐
│ shadow node 预算按可驱逐 LRU 页数折  │
│ 算                                   │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ 页数经 lruvec_lru_size() 读 memcg 的 │
│ lru_zone_size 数组                   │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐  1 GiB page cache 加 64 MiB slab 的 memcg,
│ MGLRU 记账走 __update_lru_size(),数 │  预算从约 3.5 万节点跌到约 2 千
│ 组不更新,可驱逐页被算成 0           │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ shadow shrinker 随建随清,thrashing  │
│ 保护失效                             │
└──────────────────────────────────────┘

问题

  • MGLRU 不更新 lru_zone_size,可驱逐 LRU 链表在预算里永远为零
  • 预算只剩 slab 与 unevictable 零头,从约 3.5 万节点跌到约 2 千
  • shadow entry 随建随清,refault 无法恢复热页 workingset 状态
  • thrashing 保护失效,波及每次 memcg reclaim 与全局 reclaim

方案

把可驱逐页统计换回 classical LRU 与 MGLRU 都维护的 lruvec_page_state_local(),并把它当年被换掉时回避的 reparenting 竞态关掉。

lruvec_page_state_local() 读的是 lruvec_stats->state_local 计数器,classical LRU 与 MGLRU 的记账路径都会更新它,统计源换回来后两种 LRU 下预算都完整。它当年被换成 lruvec_lru_size(),是因为 state_local 属非层级统计:memcg offline 过程中统计先 reparent 到父级,若此时仍有更新记到 dying memcg 自身,账就对不上。cgroup v1 早有应对:get_non_dying_memcg_start() 把 dying memcg 的统计更新重定向到最近的存活祖先,但这个重定向只在 v1 生效,v2 下统计照旧记到 dying memcg。统计源换回 state_local 后 v2 也读它,重定向随之扩到所有层级。offline 是稀有操作,fast path 上新增的代价只有一次 rcu_read_lock() 与 dying 检查。

换统计源并关闭 reparenting 竞态
┌────────────────────────────────────────┐
│ 统计源换回 lruvec_page_state_local(), │
│ classical LRU 与 MGLRU 都维护          │
└───────────────────┬────────────────────┘
                    ▼
┌────────────────────────────────────────┐
│ dying memcg 的统计重定向到最近存活祖   │
│ 先,扩到所有层级                       │  关闭 state_local 的 reparenting 竞态
└───────────────────┬────────────────────┘
                    ▼
┌────────────────────────────────────────┐
│ dying 检查移到 RCU 锁外,fast path     │
│ 不拿锁                                 │
└────────────────────────────────────────┘

最后把这点开销也削掉:dying 检查只读调用方已持有引用的 memcg 的 CSS_DYING 标志,不需要锁保护,RCU 锁真正保护的只是向存活祖先的上行遍历。检查移到锁外后,fast path 上不拿锁直接返回,只有 dying 路径才进 RCU 临界区。

收益

修复的功能收益:

  • shadow node 预算恢复完整,shadow entry 不再随建随清
  • refault 重新命中 shadow entry,lru_gen_refault() 能恢复热页的 workingset 状态
  • thrashing 保护在 memcg reclaim 与全局 reclaim 下恢复生效

性能回归测试环境为 10-vCPU QEMU、8 GiB RAM、cgroup v2,每种配置 7 轮取中位数:

负载 LRU 类型 base patches 1-2 patches 1-3
w1-anon-churn(pages/s) classical LRU 4393028 4385996(-0.2%) 4381832(-0.3%)
w1-anon-churn(pages/s) MGLRU 4377122 4352887(-0.6%) 4377053(+0.0%)
w2-file-churn(MB/s) classical LRU 8277 8226(-0.6%) 8157(-1.4%)
w2-file-churn(MB/s) MGLRU 8226 8123(-1.3%) 8294(+0.8%)

patch 2 引入的无条件 RCU 锁是 patches 1-2 列开销的来源,patch 3 把 dying 检查移出锁外后回落到 base 水平,残余差异在轮次噪声内;reparent 记账健全性检查在全部配置通过。