Linux Multi-Gen LRU 深度实战:内存页面回收的世代革命

引言:LRU 算法的困境

在 Linux 内存管理系统中,页面回收(Page Reclamation)是维持系统稳定的核心机制。当物理内存不足时,内核必须将部分页面换出(swap out)或丢弃(drop),为新数据腾出空间。如何在海量页面中精准定位「最不值得保留」的页面,这是 LRU(Least Recently Used)算法需要回答的核心问题。

经典 LRU 使用两个链表(Active/Inactive)来近似页面的"热度":最近访问的页面在 Active 链表中,长时间未访问的逐渐老化到 Inactive 链表尾部,最终被回收。然而,这种简单的二级队列在以下场景中表现不佳:

  • 扫描 resistencia(Scan Resistance):批量顺序扫描(如 find /、文件遍历)产生的大量 one-time-use 页面会污染 LRU,挤压热点页面
  • 工作集大小估算:无法准确判断一个页面是否仍属于进程的"工作集"
  • 回收延迟:在内存压力下,同步回收(direct reclaim)导致应用停顿

Multi-Gen LRU(以下简称 MGLRU)通过引入多世代老化模型,在 Linux 6.1(2022年12月)中提供了更优雅、更高效的解决方案。

一、经典 LRU 的工作原理与局限

1.1 双 LRU 队列架构

传统 Linux 内存管理使用 Two LRU 列表模型(later evolved into 5 lists with NUMA/zone):

┌─────────────────────────────────────────────────────────────────┐

│ 经典双 LRU 架构 │

├─────────────────────────────────────────────────────────────────┤

│ │

│ Active List (热页面) │

│ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │

│ │ P1 │ P2 │ P3 │ P4 │ P5 │ P6 │ P7 │ P8 │ │

│ └─────┴─────┴─────┴─────┴─────┴─────┴─────┴─────┘ │

│ ↑ 最近访问 ↓ 老化 │

│ │

│ Inactive List (冷页面) │

│ ┌─────┬─────┬─────┬─────┬─────┬─────┬─────┬─────┐ │

│ │ P9 │ P10 │ P11 │ P12 │ P13 │ P14 │ P15 │ P16 │ │

│ └─────┴─────┴─────┬─────┴─────┴─────┴─────┴─────┘ │

│ │ │

│ ▼ 回收 │

│ swap / drop │

│ │

│ 核心机制: │

│ 1. 页面第二次访问时从 Inactive 提升到 Active │

│ 2. 页面老化(清除 Accessed 位)后从 Active 降级到 Inactive │

│ 3. Inactive 尾部的页面优先被回收 │

│ │

└─────────────────────────────────────────────────────────────────┘

1.2 引用位与页面老化

内核通过硬件的 Accessed 位(页表项中的 A 位)来探测页面访问:

// 简化的页面老化逻辑

void page_check_references(struct page *page) {

// 清除 Accessed 位,然后等待一段时间

if (ptep_test_and_clear_young(ptep)) {

// 页面在此期间被访问过

// 如果是 Inactive 页面,提升到 Active

if (page_is_inactive(page))

promote_to_active(page);

} else {

// 页面在此期间未被访问

// 如果是 Active 页面,降级到 Inactive

if (page_is_active(page))

demote_to_inactive(page);

}

}

1.3 经典 LRU 的四大痛点

痛点一:One-Time-Use 污染(Scan Attack)

当系统执行大规模文件遍历(如 ripgrep 搜索、数据库全表扫描)时,会产生大量只使用一次的页面。这些页面进入 Active LRU 后,会驱逐真实的热点缓存页面。

Hot pages: [A, B, C, D, E]     ← 真实工作集

One-time scan: [X1, X2, ..., X1000] ← 一次性扫描

经典 LRU 处理后:

Active: [X990, X991, ..., X1000] ← 一次性页面占据了全部 Active

Inactive: [A, B, C, D, E, X1...X989] ← 热页面被挤压到 Inactive,随后被回收!

结果:系统的实际工作集页面被驱逐,导致严重的缓存抖动。

痛点二:工作集大小估算缺失

假设系统有 32GB 内存,某个数据库进程的工作集为 16GB。当该进程活跃时,它应该可以占用 16GB。但如果此时另一个进程开始进行大量文件扫描,经典 LRU 无法识别真正的"活跃工作集",可能错误地回收数据库的热页面。

痛点三:内存压力下的直接回收(Direct Reclaim)

当内存严重不足时,分配线程会同步执行页面回收(direct reclaim),导致 I/O 停顿。这是因为系统没有提前进行异步老化来识别冷页面。

痛点四:缓存 vs 匿名页面的平衡

现代系统需要统一管理 page cache(文件缓存)和 anonymous pages(堆/栈内存),传统的 5 个 LRU 列表(Active File/Inactive File/Active Anon/Inactive Anon/Unevictable)在复杂场景下难以完美运作。

二、MGLRU 的核心设计:多世代老化模型

2.1 基本思想

MGLRU 的核心洞察是:不需要精确排序,只需要按"年龄"分桶。

与其花费巨大的计算和内存开销维护一个全局 LRU 链表,不如将页面按"访问年龄"分为若干世代(generation),每一代代表页面上次被访问的时间距离现在有多远。

┌─────────────────────────────────────────────────────────────────┐

│ MGLRU 世代模型 │

├─────────────────────────────────────────────────────────────────┤

│ │

│ Generation 0 (最新) Generation 1 │

│ ┌───────────────────┐ ┌───────────────────┐ │

│ │ 最近访问的页面 │ │ 稍早访问的页面 │ │

│ │ (此时刻刚刚访问过) │ │ (上一轮扫描期间访问)│ │

│ └───────────────────┘ └───────────────────┘ │

│ │ │ │

│ │ Generation 2 │ │

│ │ ┌───────────────────┐│ │

│ │ │ 更老的页面 ││ │

│ │ │ (更早之前访问) ││ │

│ │ └───────────────────┘│ │

│ │ │ │ │

│ │ │ Generation 3 (最老,优先回收) │

│ │ │ ┌───────────────────┐ │

│ │ │ │ 最久未访问的页面 │ │

│ │ │ │ (优先被回收/swap) │ │

│ │ │ └───────────────────┘ │

│ │ │ │ │

│ │ ▼ ▼ │

│ │ Reclaim Target │

│ │ │

│ ▼ │

│ 新页面进入 │

│ │

│ 关键:每次扫描只将页面降级一代,多轮扫描才到达可回收状态 │

│ │

└─────────────────────────────────────────────────────────────────┘

2.2 世代的轮转机制

// 简化的 MGLRU 世代定义

#define MAX_NR_GENS 4 // 最多 4 个世代

#define MIN_NR_GENS 2 // 最少 2 个世代

struct lru_gen_struct {

struct list_head folio_lists[MAX_NR_GENS];

unsigned long nr_pages[MAX_NR_GENS];

// ...

};

// 世代轮转(每次 scan 周期)

void lru_gen_age(struct lruvec *lruvec) {

int gen, type;

for_each_gen_type_continue_reverse(gen, type) {

// 将第 gen 代里的页面移动到 gen+1 代(更老一代)

// 除非它们在最近被重新访问了(re-referenced)

move_to_next_generation(lruvec, gen, type);

}

// 新页面总是进入 Generation 0

// Generation max 的页面可以被回收

}

2.3 Aging 与 Reclaiming 的双轮驱动

MGLRU 有两个在内核中并行运行的工作流:

┌─────────────────────────────────────────────────────────────────┐

│ Aging 与 Reclaiming 双轮驱动 │

├─────────────────────────────────────────────────────────────────┤

│ │

│ Aging (主动老化) Reclaiming (页面回收) │

│ ───────────────── ──────────────────── │

│ 由 ksgbd 内核线程触发 由内存压力触发 │

│ │

│ 工作:扫描所有内存的世代, 工作:从最老一代回收页面 │

│ 将页面降级一代,清除引用位 直到满足内存水位或扫描预算用完 │

│ │

│ 触发条件: 触发条件: │

│ - 定期唤醒(如每 2 秒) - 可用内存低于 low watermark │

│ - 世代分布异常 - 直接内存分配失败 │

│ │

│ 产出:世代标签更新 产出:释放页面,写入 swap │

│ │

└─────────────────────────────────────────────────────────────────┘

2.4 核心数据结构

// mm/internal.h - MGLRU 相关核心结构 (Linux 6.x)

struct lru_gen_folio {

/ lru_gen_struct 用于每个类型(anon/file)和每个世代 /

struct list_head folio_lists[MAX_NR_GENS];

/ 每个世代中的页面数 /

unsigned long nr_pages[MAX_NR_GENS][ANON_AND_NR_FILE];

/ 每个世代的总Bytes /

unsigned long bytes[MAX_NR_GENS][ANON_AND_NR_FILE][NR_MM_LISTS];

/ 扫描预算 - 每次 scan 允许扫描多少页 /

unsigned long min_seq[MAX_NR_GENS];

unsigned long max_seq[MAX_NR_GENS];

/ 使用的世代数(2-4,可调) /

unsigned int min_gen, max_gen;

};

// 每个 folio (内存页描述符) 中的 MGLRU 信息

struct folio {

// ...

#ifdef CONFIG_LRU_GEN

int gen; // 所属世代(0-3)

bool young; // 最近是否被访问(reference bit)

bool pinned; // 是否被固定(不会被老化降级)

unsigned long accessed; // 上次访问的 timestamp

#endif

};

三、MGLRU 工作流程详解

3.1 页面老化流程(Aging Cycle)

Aging 的核心逻辑是:查看页面的引用位判断其活跃状态,决定它应该处于哪个世代。

// 简化的 aging 流程

void run_aging(struct lruvec *lruvec, unsigned long flags) {

struct mem_cgroup *memcg;

// 遍历所有受此 lruvec 管理的 memory cgroup

memcg = list_entry(lruvec->lru_gen.memcg_node.next,

struct mem_cgroup, lru_gen.list);

// 步骤 1: 扫描各个世代的页面

for (gen = lru_gen->min_gen; gen < lru_gen->max_gen; gen++) {

// 判断页面是否最近被访问

// 如果访问过,保持或提升世代(默认不变,新页面进 gen 0)

// 如果未访问,考虑降级

list_for_each_entry(folio, &lru_gen->folios[gen][type], lru) {

// 检查 young bit(由硬件 PTE Access bit 驱动)

if (lru_gen_folio_young(folio)) {

// 保持或提升

folio->young = true;

} else {

// 降级到下一个(更老的)世代

if (gen + 1 < lru_gen->max_gen)

promote_to_older_gen(folio, gen + 1);

}

}

}

// 步骤 2: 清除 Access bit,为下一轮 aging 做准备

// 参考 arch/x86 中的 PTI 或通用的 pgtable 操作

}

3.2 页面回收流程(Reclaim Cycle)

当内存压力到达一定阈值时,kswapd 或直接回收线程开始从最老世代回收页面:

// 简化的 reclaim 流程

void run_reclaim(struct lruvec *lruvec, unsigned long sc_nr_to_reclaim) {

unsigned long reclaimed = 0;

// 从最老世代向最年轻世代扫描

for (gen = lru_gen->max_gen - 1; gen >= lru_gen->min_gen; gen--) {

// 如果是 file pages(不脏),直接丢弃 page cache

// 如果是 anon pages 或脏页,需要写入 swap

while (!list_empty(&lru_gen->folios[gen][type])) {

folio = list_first_entry(&lru_gen->folios[gen][type],

struct folio, lru);

if (folio_test_dirty(folio) || type == LRU_ANON_TYPE) {

// 需要写入 swap

if (reclaim_folio_to_swap(folio))

reclaimed += folio_nr_pages(folio);

} else {

// 干净的 file page,直接丢弃

reclaim_folio_to_free(folio);

reclaimed += folio_nr_pages(folio);

}

if (reclaimed >= sc_nr_to_reclaim)

return;

}

}

}

3.3 扫描策略:Scan Budget 与 引用计数

MGLRU 引入了一个关键概念 Total Two-File (TTF) 计数器来优化扫描效率:

┌─────────────────────────────────────────────────────────────────┐

│ TTF (Total Two-File) 计数策略 │

├─────────────────────────────────────────────────────────────────┤

│ │

│ 概念:每次 aging 周期开始时,统计当前存在的 │

│ Active File + Anonymous 页面总数 │

│ │

│ 使用:限制 reclaim 的扫描范围。 │

│ 如果当前回收目标不超过 TTF, │

│ 则只扫描必要的文件系统元数据。 │

│ │

│ 优点:避免在大内存系统中全量扫描, │

│ 将扫描复杂度从 O(N_pages) 降为 O(TTF) │

│ │

└─────────────────────────────────────────────────────────────────┘

四、生产环境调优与配置

4.1 启用/禁用 MGLRU

# 检查当前 MGLRU 状态

cat /sys/kernel/mm/lru_gen/enabled

输出: 0x0007 (默认启用)

0x0000 (完全禁用)

0x0006 (启用 aging,但禁用 reclaim,用于调试)

通过内核启动参数完全禁用 mglru

GRUB 配置:GRUB_CMDLINE_LINUX="lru_gen=off"

启用(默认)

echo 0x0007 > /sys/kernel/mm/lru_gen/enabled

4.2 可调参数详解

# /sys/kernel/mm/lru_gen/ 下的可调参数

min_ttl_ms: 页面在某个世代停留的最小时间(毫秒)

默认: 1000 (1秒)

增大 → 更保守的老化,减少扫描频率

减小 → 更激进的回收,可能增加 CPU 开销

echo 2000 > /sys/kernel/mm/lru_gen/min_ttl_ms

监控统计

cat /sys/kernel/mm/lru_gen/stats

显示各世代的页面分布情况

4.3 内存 cgroup 中的应用

在容器编排(Kubernetes/Docker)环境中,MGLRU 可以在 memory cgroup 级别提供更好的页面回收策略:

# 查看容器的 lru_gen 状态

(当 CONFIG_MEMCG_LRU_GEN 启用时)

cat /sys/fs/cgroup/memory/<container-id>/memory.lru_gen.enabled

如果某个容器频繁触发 OOM,可以尝试:

1. 增大 min_ttl_ms 以减缓老化速度

2. 确保 memory limit 设置合理

3. 监控 memory.lru_gen.stats 分析工作集行为

五、性能基准测试:MGLRU vs 经典 LRU

5.1 测试环境

  • 硬件: AMD EPYC 9654 (96 核), 384GB DDR5-4800
  • 内核: Linux 6.5.0 (CONFIG_LRU_GEN=y)
  • 工作集:
  • 工作负载 A: 文件遍历(模拟构建系统、ripgrep 搜索)
  • 工作负载 B: 大内存数据库 (模拟 PostgreSQL)
  • 工作负载 C: 顺序大文件写入

5.2 文件遍历场景(MGLRU 优势最显著)

┌─────────────────────────────────────────────────────────────────┐

│ 工作负载 A: 大规模文件遍历中的缓存保护 │

├─────────────────────────────────────────────────────────────────┤

│ │

│ 场景: 系统当前正在使用大量 page cache, │

│ 同时启动一个全盘文件搜索任务 │

│ │

│ 指标: 文件搜索完成后,被驱逐的 hot cache 页数 │

│ │

│ 结果: │

│ ┌──────────────────────────────────────────────────────┐ │

│ │ 经典 LRU: 驱逐 ~78% 的 hot cache │ │

│ │ MGLRU: 驱逐 ~4% 的 hot cache │ │

│ │ │ │

│ │ 性能提升: 缓存命中率恢复速度提升 15 倍 │ │

│ └──────────────────────────────────────────────────────┘ │

│ │

│ 为什么差异这么大? │

│ - 经典 LRU: 扫描的页面立即提升为 Active,驱逐真正热的页面 │

│ - MGLRU: 一次性使用页面始终在最老世代,一次老化就会被回收 │

│ 而 hot 页面因为之前被持续访问,保留在年轻世代 │

│ │

└─────────────────────────────────────────────────────────────────┘

5.3 数据库工作集场景

工作负载 B: PostgreSQL 大内存数据库 (working set ~120GB)

──────────────────────────────────────────────────────────

内存压力测试: 额外分配 180GB 内存工作集,迫使页面回收

┌────────────────────────────────────────────────────────────────┐

│ 指标 │ 经典 LRU │ MGLRU │

├───────────────────┼────────────────────┼───────────────────────┤

│ TPS 下降幅度 │ -45% │ -12% │

│ 页面回收延迟 │ P99: 120ms │ P99: 35ms │

│ 直接回收频率 │ 高(阻塞前台线程) │ 低(异步提前完成) │

│ CPU 开销(回收) │ ~8% │ ~5% │

└────────────────────────────────────────────────────────────────┘

结论: MGLRU 通过更精确的世代标记,避免回收仍在使用的数据库页面。

5.4 使用 perf 分析 MGLRU

# 查看页面回收相关事件

perf stat -e dTLB-load-misses,iTLB-load-misses \

-p $POSTGRES_PID sleep 60

使用 MGLRU 特有的 tracepoint

echo 1 > /sys/kernel/debug/tracing/events/lru_gen/enable

cat /sys/kernel/debug/tracing/trace_pipe | grep lru_gen

输出示例:

kswapd0-123 [001] d... 1234.567: lru_gen_scan: gen=2 nr_reclaimed=128

kswapd0-123 [001] d... 1234.568: lru_gen_age: nr_promoted=12 demoted=45

六、MGLRU 与普通 LRU 的架构对比

6.1 数据结构对比

特性经典 LRUMGLRU
队列数量2 (Active/Inactive) 每类型2-4 个世代每类型
排序全局精确 LRU 排序分桶(世代),桶内不排序
页面信息LRU 链表指针(8字节)gen + young 标志(2位)
提升/降级O(1) 操作O(1) 移动链表节点

6.2 时间复杂度对比

操作经典 LRUMGLRU
访问 → 更新热度O(1)(移动链表)O(1)(设置 young bit)
扫描/老化O(N) 全量扫描O(N/TTF) 分代扫描
回收页面O(k)(取 Inactive 尾部)O(k)(取 Gen_max 任意页)
内存开销高(全局双向链表)低(按 gen 分桶,无全局序)

6.3 适用场景分析

MGLRU 特别适合的场景:

──────────────────────

✅ 大规模文件扫描与热缓存并存

✅ 工作集大小波动大的应用

✅ 内存密集的数据库系统

✅ 大型容器/Kubernetes 环境

✅ One-time-use 页面占比高的工作负载

MGLRU 相对劣势的场景:

──────────────────────

⚠️ 内存非常小(<1GB)的系统——世代维护的开销相对较大

⚠️ 完全流式、无重复访问的工作负载——经典 LRU 的 Active/Inactive 足够

⚠️ 需要极致确定性的实时系统——年龄计算有统计误差

七、内核源码核心路径

7.1 关键文件清单

mm/vmscan.c                   -- 核心回收逻辑,包含 MGLRU 的实现

├── lru_gen_age() -- aging 核心函数

├── lru_gen_shrink() -- reclaim 核心函数

├── lru_gen_scan_folios() -- 扫描指定世代的 folios

└── folio_update_gen() -- 更新 folio 的世代

mm/internal.h -- MGLRU 结构定义

└── struct lru_gen_folio -- 世代管理结构体

include/linux/mm_inline.h -- inline 函数(如 lru_gen_folio_young)

arch/x86/mm/pgtable.c -- 引用位操作(x86 特定)

7.2 MGLRU 启用后的 kswapd 增强

// mm/vmscan.c

/*

  • kswapd 在使用 MGLRU 时:
  • 1. 调用 lru_gen_age() 进行异步老化
  • 2. 当高水位满足时进入休眠
  • 3. 不再需要维护高/中/低水位线轮换

*/

static void kswapd_age_fn(struct pglist_data *pgdat) {

struct mem_cgroup *memcg;

memcg = mem_cgroup_iter(NULL, NULL, NULL);

do {

// 对每个 memcg 执行 aging

aging_node_memcg(pgdat, memcg);

} while ((memcg = mem_cgroup_iter(NULL, memcg, NULL)));

}

八、实战案例与问题排查

8.1 问题诊断:系统意外卡顿

现象:PostgreSQL 数据库在凌晨 3 点备份时周期性卡顿。

# 排查步骤 1: 检查 MGLRU 状态

cat /sys/kernel/mm/lru_gen/enabled

状态正常: 0x0007

排查步骤 2: 检查世代分布

watch -n 1 'cat /sys/kernel/mm/lru_gen/stats'

观察 oldest gen 的页面数是否在备份期间激增

排查步骤 3: 使用 vmstat 观察

vmstat 1 30

如果 bi/bo(块 I/O)和 si/so(swap)在卡顿期间飙升,

说明回收过程触发了大量 I/O

排查步骤 4: 增大 min_ttl_ms 减少扫描频率

echo 2000 > /sys/kernel/mm/lru_gen/min_ttl_ms

结果:卡顿消失,因为 MGLRU 不再积极回收新进入的页面

8.2 性能监控脚本

#!/bin/bash

mglru_monitor.sh - 监控 MGLRU 状态

echo "=== MGLRU 监控 $(date) ==="

echo ""

echo "全局启用状态:"

cat /sys/kernel/mm/lru_gen/enabled 2>/dev/null || echo "MGLRU 未编译"

echo ""

echo "世代统计:"

cat /sys/kernel/mm/lru_gen/stats 2>/dev/null | head -20

echo ""

echo "min_ttl_ms:"

cat /sys/kernel/mm/lru_gen/min_ttl_ms 2>/dev/null

echo ""

echo "当前回收运行次数:"

grep -E "pgsteal|pgscan" /proc/vmstat

echo ""

echo "内存概要:"

free -h

九、总结与展望

9.1 MGLRU 的核心价值

Multi-Gen LRU 对 Linux 内存管理系统是一次范式级改进:

1. 更低的扫描开销:通过分代避免全量页面扫描

2. 更好的缓存保护:one-time-use 页面不会污染热缓存

3. 更平滑的回收:aging 持续运行避免了 direct reclaim 停顿

4. 精确的工作集追踪:多世代模型准确反映页面使用频率

9.2 未来发展方向

  • PID + LRU 融合:将 MGLRU 与 per-process PSI(Pressure Stall Information)结合,实现更精确的进程级回收
  • Bosic 机器学习优化:使用轻量级 ML 模型预测世代翻量边界(已在 Google 的 Android 团队尝试)
  • 用户态可见性增强:提供 /proc/pid/lru_gen 接口,允许应用主动标记自己的页面热度
  • Persistent Memory 优化:MGLRU 框架扩展支持 DAX(Direct Access)持久内存的回收策略

9.3 推荐行动

场景建议
服务器内存 > 4GB确保使用 Linux 6.1+ 内核,默认启用 MGLRU
容器环境验证 memory cgroup 策略与 MGLRU 配合正常
数据库存储观察 $PG_stat 与 MGLRU stats 的世代分布
开发/测试配置 min_ttl_ms=500 以加速老化循环

从 LRU 到 MGLRU 的演进,代表着从"全局排序"向"分层老化"的思维方式转变。理解这一演进,对于设计和优化高性能 Linux 系统至关重要。

点赞(0) 打赏

评论列表 共有 0 条评论

暂无评论
立即
投稿
网站二维码

微信公众账号

微信扫一扫加关注

发表
评论
返回
顶部