背景

MGLRU 把 folio 的 generation 编号与访问次数 refs 编码在 folio->flags 的同一字里:PG_referenced 打头,LRU_REFS 位跟在后面记录访问次数,LRU_GEN 位记录所在 generation,PG_swapbacked 决定 anon 或 file 归属。各路径读写这些字段时各写各的位操作:set_mask_bits 按掩码改、cmpxchg 循环里手工拼新值、再从 flags 里移位解码。同一套编码约定散落在十几处调用点里,每处都要自行保证不越界、不串位。

每个 generation 的 anon/file folio 数(lrugen->nr_pages)由页表 walker 批量记账:walker 不持 lruvec 锁也不持 folio 锁,先用 cmpxchg 把 folio 晋升到最年轻 generation,再由 update_batch_size() 重新读一次 live flags,按当时的 PG_swapbacked 决定这批 delta 记进 anon 列还是 file 列。memcg 一侧的 lru_zone_size 计数器也有类似的紧张:它是 unsigned long,下溢会 wrap 成巨大值,memcg shrinker 据此算出的回收量随之巨大,几乎空转不停;为此 sanity check 被放在更新路径上,正负 delta 要分开处理,还得随时准备 WARN 并纠正。

walker 晋升与记账之间的竞态窗口
┌──────────────────────────────────────────────┐
│ 页表 walker 用 cmpxchg 晋升 folio 的         │
│ generation                                   │
└──────────────────────┬───────────────────────┘
                       ▼
┌──────────────────────────────────────────────┐
│ 并发路径翻转 type,lazyfree 清 PG_swapbacked │
│ 或回收置回                                   │
└──────────────────────┬───────────────────────┘
                       ▼
┌──────────────────────────────────────────────┐
│ update_batch_size 再读 live flags 决         │
│ 定 anon 或 file 列                           │
└──────────────────────┬───────────────────────┘
                       ▼
┌──────────────────────────────────────────────┐
│ 批量 delta 记错列且无机制对账,generation    │
│ 计数永久歪斜                                 │
└──────────────────────────────────────────────┘

问题

  • gen/refs 位操作散落各处,每处 open-coded,难读难调试
  • lru_zone_size 的 sanity check 落在高频更新路径,低频读侧反而免检
  • walker 晋升后再读 live flags 记账,type 可被并发翻转,记错列且永久无法对账

方案

把散落的位操作收编成语义明确的 helper,加固几处计数路径,并修复收编过程暴露的一处 generation 计数泄漏。

收编落到两对 flag helper:lru_get_gen_flags()/lru_set_gen_flags() 读写 generation,lru_get_refs_flags()/lru_set_refs_flags() 读写 refs,外加 folio 层封装 folio_set_lru_refs(),全部带 kdoc 与 VM_WARN 越界检查;散落各处的 open-coded 位操作逐处替换。一些原本合并的原子操作拆成两步,只产生无害瞬态;其中一处顺序有讲究:置 PG_workingset 必须先于清 refs 位,否则读 tier 的一方会在两步之间看到偏低的结果。

几处顺手加固:

  • 迁移时的 referenced 状态(PG_referenced 与 refs 位在 MGLRU 下本是一体)合并到 folio_migrate_lru_refs() 一处拷贝,并赶在目标 folio 标记 uptodate 之前转移,并发 lockless reader 的更新不会被拷贝覆盖。
  • max_seq 的读取挪进 walk_update_folio() 内部,从每个 walk range 一次变为每个 folio 一次,晋升目标始终是当前最年轻 generation。
  • tier 统计的 read_ctrl_pos() 改用显式 (tier_min, tier_max) 闭区间,替换原先单参数编码范围的写法。

memcg 的 lru_zone_size 从 unsigned long 改为带符号 long,下溢检查挪到读侧:更新路径只剩一次普通加法;读侧调用频率低得多,负值在那里 WARN 并按 0 返回。更新侧原有的下溢纠正一并删掉:计数器大规模泄漏说明别处出了严重问题,该修的是泄漏点;记账落后于插入时负值只是延迟而非泄漏,强修归零反而出错;更新侧的 WARN 也往往抓不到真凶,若 folio 被摘除时漏更新计数而 LRU 上还有其他 folio,WARN 要到很久以后才在某个无辜调用点触发。

真正的收获是收编过程中暴露的一个并发泄漏。walker 的 cmpxchg 与 update_batch_size() 的读 flags 之间,type 可能翻转:lazyfree 路径清掉 PG_swapbacked,回收路径又会在 dirty lazyfree folio 上把它置回。两次访问之间翻一次,批量 delta 就记进了错误的 type 列,而事后没有任何机制对账,lrugen->nr_pages 与由它派生的回收预算永久歪斜。

修法是让记账与晋升共用同一份 flags 快照:folio_update_gen() 带出 cmpxchg 线性化时快照里的 type,update_batch_size() 按它记账。快照够用,是因为 folio 的 type 只在离开 LRU 时变化,发生在 lruvec 锁内的 del/add 对之间,期间 gen 位被清空;generation 与 PG_swapbacked 又同处一个 flags 字,cmpxchg 快照把它们一起捕获。CAS 的落点只有三种,逐一核对账实都相符:

  • del 之前:快照是 anon/G,批量记 anon G→G',del 随后把它从 anon 计数移除
  • del 与 add 之间:gen 为 -1,晋升直接放弃,不记批量,迁移由 del/add 对自己记账
  • add 之后:快照是 file/新 generation,批量记 file 该 gen→G',与 add 时的记账衔接

这类幻影 delta 与晋升滞后造成的漂移不同,后者 sort_folio() 会在 lruvec 锁内修复;幻影 delta 落在 folio 再也不会占据的列里,没有任何东西能把它搬回来。该泄漏自 2022 年引入 walker 批量记账起潜伏至今。

收编位操作暴露并修复计数泄漏
┌───────────────────────────────────────┐
│ 散落的 open-coded 位操作收编为带 kdoc │
│ 与 sanity check 的 helper             │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│ 梳理记账路径发现 cmpxchg 与读 flags   │
│ 之间的竞态                            │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│ folio_update_gen 带出 cmpxchg 快照里  │
│ 的 type                               │
└───────────────────┬───────────────────┘
                    ▼
┌───────────────────────────────────────┐
│ update_batch_size 按快照记账,type    │
│ 迁移由 del/add 对自行记录             │
└───────────────────────────────────────┘

收益

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

  • lru_zone_size 更新热路径只剩一次普通加法,分支与纠正逻辑消失
  • 页表 walker 的晋升目标不再滞后于 walk range 起点,热页更快进入最年轻 generation
  • generation 的 anon/file 计数不再永久歪斜,回收预算与扫描目标保持准确
  • flag helper 自带越界检查,后续改动 MGLRU 位编码时更早暴露错误