背景

回收时扫描多少匿名页、多少文件页,由 get_scan_count() 的均衡启发式决定。这个启发式靠 lruvec 里的 2 个标量(anon_cost 与 file_cost)驱动,二者由每个回收生产者在 lru_lock 下更新。更新本身代价不大,却既竞争 lru_lock,又加重了它本已沉重的竞争。lru_lock 是内存压力工作负载上常见的瓶颈点。

┌──────────────────────────────────────┐
│ 均衡代价由回收生产者在 lru_lock 下记 │
│ 录                                   │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│    记代价的出口散落在回收路径各处    │
└──────────────────┬───────────────────┘
                   ▼
┌──────────────────────────────────────┐
│ 每次记录还沿 memcg 层级向上,逐个祖  │
│ 先取锁                               │  lru_lock 本就是内存压力下的常见竞争点
└──────────────────────────────────────┘

代价记录散落在回收路径的多个出口。回收完不活跃页之后、活跃页轮转之后、缺页重新引用之后,都要重新获取 lru_lock,只为给这 2 个标量记账;每轮扫描开始前,还要在锁下把它们快照进扫描控制。而记录代价这一步本身会沿 memcg 层级向上传播,在每个祖先 lruvec 上重新取锁。于是每次代价更新的开销,都按 memcg 层级深度成倍放大。

问题

  • 均衡代价由回收生产者在 lru_lock 下记录,与真正的 LRU 操作争锁
  • 记代价的出口散落在回收路径多处,每个出口都要重新取锁
  • 记代价时沿 memcg 层级向上传播,开销按层级深度倍增
  • lru_lock 是内存压力工作负载的常见竞争点

方案

整套修复的核心很直接:代价不再由生产者写,而由消费端从 vmstat 读

旧:生产者在 lru_lock 下累计代价
┌─────────────────────────────────────┐
│    生产者在 lru_lock 下累计代价     │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│         每次记账都重新取锁          │
└──────────────────┬──────────────────┘
                   ▼
┌─────────────────────────────────────┐
│ 代价逐层向上同步,按 memcg 深度倍增 │
└─────────────────────────────────────┘

新:代价从 vmstat 派生,读侧无锁
┌─────────────────────────────────────────┐
│    回收事件记成 per-LRU vmstat 计数     │
└────────────────────┬────────────────────┘
                     ▼
┌─────────────────────────────────────────┐
│     扫描前无锁读 vmstat 折叠成代价      │
└────────────────────┬────────────────────┘
                     ▼
┌─────────────────────────────────────────┐
│ 专用 cost_lock 序列化累加,层级随 rstat │
│ 传播                                    │
└─────────────────────────────────────────┘

代价记账搬到读侧

生产者记录代价的几个输入,改为 per-LRU 的 vmstat 计数器。换出代价直接复用已有的 NR_VMSCAN_WRITE。它在 writeout() 成功时已经累加,正好对应要记的换出结果,经 lruvec_stat_mod_folio() 计入 lruvec 与 memcg 统计后,便可按 lruvec 采样、沿 memcg 层级聚合。代价是回收换出这条热路径因此新增了 memcg stat 记账,不过原有的 node 级总量仍予保留。由于这条回收路径不再提交文件系统 folio 写回,换出只贡献匿名代价,文件端没有换出项。轮转用新增的 PGROTATE_ANON 与 PGROTATE_FILE 计数器,再加上本就存在的重引用计数 WORKINGSET_RESTORE。

消费端在每轮扫描开始前无锁算出均衡代价。对每个 LRU,分别采样轮转(PGROTATE_ANON 或 PGROTATE_FILE)与 io(重引用 WORKINGSET_RESTORE,匿名端再补上换出 NR_VMSCAN_WRITE)这 2 个单调计数。各自求出相对上次快照的增量后,io 增量乘以 SWAP_CLUSTER_MAX、与轮转增量相加,折入该侧累加器,作为 anon 或 file 的均衡代价。生产者从此不再为记代价获取任何锁。

PGROTATE_ANON 与 PGROTATE_FILE 在 classic LRU 与 MGLRU 这 2 种回收实现里都会更新,所以无论启用哪种,这套代价信号都保持有意义;不过 MGLRU 走 generation 老化模型,本身并不消费这个 cost 信号。

这里的差是 2 次单调计数之差,必须走非钳位的 _monotonic 读法。现有读法在底层 signed long 越过 LONG_MAX 时会把读数钳到 0,于是 32 位上无符号减法会下溢,吐出约 2^31 的伪差。新读法直接返回底层值的 unsigned 形式:只要 2 次采样间的真实增长不超过 unsigned long(32 位上小于 2^32),模运算减法就始终正确。

把记账和衰减一起搬到回收侧,还顺带修正了代价模型在回收间隔期的老化。原先生产者侧的衰减,会让回收空闲期间发生的事件互相挤占。先 refault 大量匿名页,再访问少量文件页,后者就能把前者挤出代价模型。新方案观察 2 次回收间的完整差,按比例衰减匿名与文件,使扫描均衡的历史更贴近真实情况。

专用锁与层级传播

累加器与 lrusize/4 减半检查的读改写,由专用的 per-lruvec cost_lock 序列化。这把锁不被隔离、搬移、新增 LRU 页的路径触碰,所以即便在持续回收的热路径上,也不会和真正的 LRU 操作抢锁。

层级聚合也随之免费获得。代价一旦表示成 vmstat 计数器,rstat 会沿 memcg 层级自然传播;于是原本为同步私有字段而存在的逐层取锁,以及 reparent 时的代价拼接,都不再需要。只是消费端读到的代价是 rstat 聚合值,要等周期性或读触发的 flush 才更新,看到的并非实时值。不过这套采样本就与周围回收启发式共用同一份 ratelimit 后的 rstat 视图,这点滞后不致引入新的不一致。此外每个 lruvec 的固定占用也略有增长:每侧新增 1 个 struct lru_cost(count、last_rotated、last_io)并配 cost_lock,per-lruvec 净增 4 个 unsigned long 加 1 个 spinlock,属小额开销,与降低锁竞争的机制本身正交。

收益

平台:176 核、256 GB 主机。基准:usemem -n 16(256 MB × 16 线程)在 memory.max=512M 的 leaf cgroup 内运行(4 GB 匿名工作集挤进 512 MB,持续触发不活跃页回收与重引用),16 GB swap。测量窗口为 30 秒 perf lock record -a。工作负载速率不变:pgscan_direct 约 172K/s、pgsteal_direct 约 67K/s、workingset_refault_anon 约 40K/s,基准版与补丁版均约 0% 偏差。

LRU 锁竞争(30 秒窗口内总等待时间):

锁位置 Before After 改善
shrink_lruvec(lru_note_cost_unlock_irq) 722.84 ms 0 -100%(消除)
workingset_refault(lru_note_cost_refault) 385.26 ms 0 -100%(消除)
shrink_node 689.43 ms 26.95 ms -96%
shrink_active_list 208.34 ms 15.97 ms -92%
lru_add_drain_cpu 1.96 s 917.71 ms -53%
Total LRU lock wait ~4.23 s ~1.66 s -61%

2 个被消除的竞争点(lru_note_cost_unlock_irq、lru_note_cost_refault)在补丁版的 perf-lock-contention 输出中完全消失。shrink_node、shrink_active_list、lru_add_drain_cpu 的减少是连带效果:去掉了记代价的开销与 parent_lruvec 逐层取锁。剩余约 1.66 s 主要来自 per-CPU pagevec 排放与 shrink_lruvec 主路径。