背景

cgroup v2 的 memory.min 与 memory.low 让用户为 memcg 声明保护水位:min 是硬保护,回收必须跳过;low 是软保护,只要别处还有无保护的页面可扫就尽量不动。延迟敏感负载靠它们在全局内存压力下保住工作集。

声明值不能直接用于回收判断。同一父级下孩子们的声明之和可能超过父级自身拿到的额度,内核要沿层级自顶向下逐级换算,按各 memcg 的实际用量把父级额度分摊成有效保护值 emin 与 elow。换算由 page_counter_calculate_protection() 完成,它不是无状态的:算子节点前父节点的有效值必须先就位,所以只能作为自顶向下树遍历的一部分调用。回收侧的 mem_cgroup_below_min() 与 mem_cgroup_below_low() 消费 emin/elow,分别决定跳过与放缓。

有效保护值的换算与消费
┌─────────────────────────────────────────┐
│   memory.min 与 memory.low 的保护水位   │
└────────────────────┬────────────────────┘
                     ▼
┌─────────────────────────────────────────┐
│ page_counter_calculate_protection()     │
│ 自顶向下逐级换算                        │
└────────────────────┬────────────────────┘
                     ▼
┌─────────────────────────────────────────┐
│  每个 memcg 的有效保护值 emin 与 elow   │
└────────────────────┬────────────────────┘
                     ▼
┌─────────────────────────────────────────┐
│ 回收判断,低于 emin 直接跳过,低于 elow │
│ 放缓扫描                                │
└─────────────────────────────────────────┘

两条回收路径各自安排了换算时机。传统 LRU 的 shrink_node() 自顶向下遍历 memcg 树,每访问一个 memcg 就换算一次。MGLRU 把这步换算放在 lru_gen_age_node() 里,同样走树遍历,而它只在 kswapd 上下文运行。

问题

  • MGLRU 的保护计算只在 kswapd 运行的 lru_gen_age_node() 执行。
  • 直接回收与根级 memory.reclaim 全程无人计算保护值。
  • shrink_one() 消费的 emin/elow 为陈旧值或零,保护判断恒不命中。
  • memory.min/low 因陈旧值判断失效,传统 LRU 路径不受影响。

复现很直接。启用 MGLRU 后建 a/b 两级 memcg,a 与 a/b 各设 memory.min 为 100M,把 shell 迁入 a/b 并写 200M 文件,此时 a/b 的 memory.current 约 212M。在根 memcg 写入 500M 的 memory.reclaim 发起全局主动回收,a/b 被一路回收到 6M,100M 的硬保护完全没起作用。

根源在 30d77b7eef01。它发现 shrink_one() 遍历的是 per-node memcg LRU 而非 memcg 树,直接调 mem_cgroup_calculate_protection() 违反了该函数非无状态的约束,便把计算挪进了做树遍历的 lru_gen_age_node()。这把 kswapd 路径修对了,但直接回收与根级 memory.reclaim 这类非 kswapd 全局回收经 lru_gen_shrink_node()、shrink_many() 到 shrink_one(),全程再没有保护计算,emin/elow 停在陈旧值或零。靠 kswapd 先跑一遍也不行:emin/elow 派生自祖先的 min/low 设置与 children_min_usage,两者随时间变化,kswapd 算过的值到回收时已经过时,必须在回收时点重算。

方案

补齐非 kswapd 回收路径的保护计算,沿祖先链现算保护值。

shrink_one() 面对的不是树遍历上下文,不能回头调 mem_cgroup_calculate_protection()。新引入的 mem_cgroup_calculate_protection_path() 换了个入口:cgroup 核心为每个 cgroup 维护 ancestors[] 数组,从根到目标的祖先链直接可查,沿它自顶向下逐级调 page_counter_calculate_protection(),父先于子,正好满足换算对顺序的要求。每级开销 O(1),总开销 O(depth);memcg 层级通常 3 到 5 层,代价远低于一次全树遍历。祖先数组的访问由 RCU 读锁保护。

shrink_one() 在判断保护之前补上这次换算,kswapd 路径因 lru_gen_age_node() 已算过而跳过:

if (!current_is_kswapd())
    mem_cgroup_calculate_protection_path(NULL, memcg);
修复后两条全局回收路径的保护计算
                          ┌────────────────────────────────────────────────────┐
                     ┌───▶│ kswapd 路径,lru_gen_age_node 全树遍历时逐节点计算 │
┌────────────────┐   │    └────────────────────────────────────────────────────┘
│ MGLRU 全局回收 ├───┤
└────────────────┘   │    ┌────────────────────────────────────────────────────┐
                     └───▶│     非 kswapd 路径,shrink_one 先沿祖先链补算      │
                          └────────────────────────────────────────────────────┘

有效保护值的换算本身还有一个整数溢出得先修掉。effective_protection() 把父级富余额度按用量比例分摊,乘法在 unsigned long 宽度先乘后除,内存不小于 16TB 时页面计数的乘积超出 2^64 回绕,保护值算成错值。补丁改用 mul_u64_u64_div_u64(),以 128 位中间值完成乘除;usage 与 parent_usage 不是原子读取,子先于父计费会让分子短暂大于分母,商溢出 64 位在 x86 上直接触发 #DE,所以再把比例钳到 1 以内。新增的调用路径同样经过 effective_protection(),所以这个溢出修复排在最前。

收益

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

  • 直接回收与根级 memory.reclaim 下 memory.min/low 恢复生效,受保护负载的工作集不再被错误回收。
  • 保护值沿祖先链现算,O(depth) 且层级通常 3 到 5 层,避免全树遍历。
  • 内存不小于 16TB 的机器上,effective_protection() 不再因乘法溢出回绕算出错误保护值。