背景
回收时扫描多少匿名页、多少文件页,由 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 主路径。