LRU 不只是最近最少使用:active/inactive、anon/file、folio 和分代 LRU
番外一讲到,内存压力上来以后,kswapd 会从 balance_pgdat 走到 shrink_node、shrink_lruvec,开始扫描可回收页。
这一篇就进入 kswapd 真正要扫描的核心结构:LRU。
这里最容易误解的一点是:Linux 的 LRU 不是教科书里那个"把所有页严格排成一条最近最少使用队列"的算法。真实内核要在多核、NUMA、memcg、匿名页、文件页、硬件访问位、反向映射、IO 成本之间折中,所以它维护的是一套近似冷热分层。
这一篇回答四个问题:
- 为什么 LRU 要分
active和inactive? - 为什么匿名页和文件页要分开?
folio出现后,LRU 上到底挂的是页还是 folio?- Multi-Gen LRU 又是在传统 active/inactive 模型上解决什么问题?
先把结论放前面:
text
Linux 不维护一个精确的全局 LRU 队列。
它把可回收内存按 lruvec 管起来:
lruvec = 一个 NUMA node + 一个 memory cgroup 的回收视角
传统视角下,每个 lruvec 有几类 LRU:
inactive_anon / active_anon
inactive_file / active_file
unevictable
active/inactive 表达冷热层次。
anon/file 表达回收成本差异。
folio 表达一次 LRU 管理单位可能覆盖多个 base page。
Multi-Gen LRU 用 generation 把冷热从"两层"扩展成"多代"。
一、LRU 的管理单位不是全局一条链
如果把系统所有物理页塞进一条全局 LRU 链表,问题会很快出现:
- 每次访问都移动页,锁竞争会很重。
- 匿名页和文件页回收成本不同,不能只看"多久没访问"。
- NUMA node 和 memcg 都要求局部回收,不能只看全局。
- 硬件通常只给访问位、脏位这类粗粒度信号,不会替内核维护访问时间戳。
这里的 memcg 是 memory cgroup。可以把它先理解成 Linux 给"一组进程"建立的内存账本和限制边界:
text
Docker container / Kubernetes Pod / systemd service
│
▼
memory cgroup
│
├─ 统计这组进程用了多少匿名页
├─ 统计这组进程用了多少 page cache
├─ 用 memory.max 等参数限制上限
└─ 超过限制时,只在这组进程的账本里回收或 OOM
所以内存回收不总是全局行为。宿主机可能还有空闲内存,但某个容器自己的 memory.max 快满了,这时内核要优先在这个容器所属的 memcg 里找可回收页。这就是后面说 lruvec = node + memcg 的原因:同一个 NUMA node 上,不同 memcg 有各自的 LRU 回收视角。
所以 Linux 的回收视角更接近这样:
text
system
│
├─ node 0
│ ├─ root memcg 的 lruvec
│ ├─ cgroup A 的 lruvec
│ └─ cgroup B 的 lruvec
│
└─ node 1
├─ root memcg 的 lruvec
├─ cgroup A 的 lruvec
└─ cgroup B 的 lruvec
lruvec 可以先理解成:
text
某个 node 上、某个 memcg 视角下,
可被页面回收框架扫描的一组 LRU 状态。
传统 LRU 视角里,一个 lruvec 里面最重要的是这几类:
text
lruvec
├─ inactive_anon -> 冷一点的匿名页,匿名页回收候选
├─ active_anon -> 热一点的匿名页,暂时保护
├─ inactive_file -> 冷一点的文件页,文件页回收候选
├─ active_file -> 热一点的文件页,暂时保护
└─ unevictable -> mlock、特殊映射等不可普通回收的页
画成二维表会更清楚:
| 维度 | anon | file |
|---|---|---|
inactive |
冷匿名页,候选但不一定能回收 | 冷文件页,干净时通常容易回收 |
active |
热匿名页,先保护 | 热文件页,先保护 |
unevictable |
不在普通回收候选里 | 不在普通回收候选里 |
这张表比"最近最少使用"更接近 Linux 的真实心智模型:
text
回收不是问:
谁是全系统最久没访问的那一页?
回收更像问:
当前 node/memcg 里,
anon 和 file 各有多少冷热页?
哪一类扫描更可能释放内存?
扫描成本、IO 成本、refault(页被回收后又很快被访问,重新缺页进入内存)风险能不能接受?
二、active 和 inactive 不是"安全"和"马上回收"
active 容易被误解成"不会回收",inactive 容易被误解成"马上释放"。这两个理解都太硬。
更准确的理解是:
text
active:
最近更像工作集,先给一次保护。
inactive:
更像冷页,优先作为扫描和回收候选。
页面会在两类状态之间流动:
text
新页 / refault 页 / 被判断为热的页
│
▼
active
│
│ 压力下老化,或者访问证据不足
▼
inactive
│
├─ 扫描时发现最近又被访问
│ │
│ └─ 重新激活 / 继续保护
│
└─ 扫描时仍然冷
│
├─ 文件页:clean 可丢,dirty 要 writeback
└─ 匿名页:通常要 swap,不能随便丢
用流程图看:
所以 active/inactive 的本质不是二元裁决,而是"冷热概率"的分层。Linux 通过硬件访问位、PG_referenced、反向映射扫描、workingset/refault 统计等信号,去近似判断一个页是不是工作集的一部分。
三、anon 和 file 分开,是因为回收成本不同
匿名页和文件页最大的差异是后盾不同。
匿名页来自:
- 堆。
- 用户栈。
- 匿名
mmap。 - 写时复制后的私有页。
这些页没有一个普通文件可以重新读取。回收匿名页时,内核通常要把内容写到 swap,然后把页表项从 present PTE 改成 swap entry:
text
anonymous page reclaim
│
▼
rmap 找到映射它的 PTE
│
▼
分配 swap slot
│
▼
写出页内容
│
▼
PTE: present PFN -> not-present swap entry
文件页来自:
read()/write()背后的 page cache。- 文件
mmap。 - 可执行文件、动态库的文件映射。
干净文件页有文件作为后盾,丢掉以后需要时可以从磁盘再读:
text
clean file page reclaim
│
├─ 如果被映射,先 unmap PTE
└─ 从 page cache 移除,释放物理页
脏文件页不能直接丢:
text
dirty file page reclaim
│
├─ 发起或等待 writeback
└─ 写回完成变 clean 后,才能真正回收
这就是为什么回收代码要分 anon 和 file:它们不是同一种成本模型。
但也不能把它简化成"永远先回收文件页"。真实选择还会受这些因素影响:
swappiness。- 文件页是否干净。
- 匿名页是否有 swap。
- 最近 refault 的文件页是不是工作集。
- memcg 限制。
- 当前回收优先级。
- direct reclaim 还是 kswapd。
一张图压缩这个差异:
text
┌───────────────────────────────┬──────────────────────────────────┐
│ anon │ file │
├───────────────────────────────┼──────────────────────────────────┤
│ 堆、栈、匿名 mmap、COW 私有页 │ page cache、文件 mmap、可执行映射 │
│ 没有普通文件后盾 │ 有文件后盾 │
│ 回收通常需要 swap │ clean page 可以直接丢弃 │
│ 无 swap 时常常很难回收 │ dirty page 需要 writeback │
└───────────────────────────────┴──────────────────────────────────┘
四、folio:LRU 上不一定只是一张 4KB 页
前面一直说"页",但现代内核里你会越来越多看到 folio。
可以先这样理解:
text
page:
传统 struct page 视角,常被读者理解成一个 base page,例如 4KB。
folio:
一组属于同一个内存对象、同一类用途的连续 base pages。
小 folio 可以只有 1 个 base page。
大 folio 可以覆盖多个 base pages。
为什么要有 folio?核心原因是内核不想永远以 4KB 为粒度管理 page cache、匿名大页、THP(Transparent Huge Pages,透明大页,内核自动把连续小页合成更大的页映射,常见是 2MB,以减少 TLB 压力)或其他大块映射。一个 folio 可以让内核把"这一组页是一个整体"表达得更清楚,减少重复元数据和重复操作。
在 LRU 语境里要注意两点:
- 内核实现里很多地方已经以
folio为单位加入、隔离、移动、回收。 /proc/vmstat、/proc/meminfo里的许多统计仍按 base page 或 KiB 展示。
所以正文里说"LRU 上的页"时,读者心里要保留这个翻译:
text
讲概念时说 page。
看现代源码时常常是 folio。
看 /proc 统计时很多值仍折算成 base pages。
示意图:
text
LRU list
│
├─ folio A: 1 个 4KB page
│ └─ page
│
├─ folio B: 16 个 4KB pages
│ ├─ page
│ ├─ page
│ └─ ...
│
└─ folio C: 512 个 4KB pages,例如 2MB THP
├─ page
├─ page
└─ ...
这也是为什么实验输出里看到的是:
text
nr_active_file 338224 pages
它的单位是 base page,不代表 LRU 链表上真的挂了 338224 个独立链表节点。
五、传统 LRU 回收大概怎么扫
把番外一的 kswapd 路径接进来:
text
kswapd / direct reclaim
│
▼
shrink_node
│
▼
shrink_lruvec
│
▼
选择 anon/file、active/inactive 的扫描比例
│
▼
isolate 一批 folio/page
│
├─ 还热 -> 放回或激活
├─ clean file -> unmap/drop
├─ dirty file -> writeback 相关路径
├─ anon -> swap 相关路径
└─ unevictable -> 移到 unevictable 或跳过
流程图:
这里的 get_scan_count 不是简单地说"文件页永远比匿名页优先"。它会综合当前压力、匿名/文件的 LRU 大小、是否有 swap、swappiness、refault/工作集迹象等,决定各类页扫描多少。
六、Multi-Gen LRU:把两层冷热扩展成多代冷热
传统 active/inactive 模型只有两层:
text
active -> 更热
inactive -> 更冷
但真实工作集不是只有"热"和"冷"两档。一个页面可能是:
- 刚刚访问过。
- 100ms 内访问过。
- 1s 内访问过。
- 很久没访问,但还没被扫描到。
- 曾经被回收,马上 refault,说明可能误杀了工作集。
Multi-Gen LRU 的思路是:用 generation 表达更细的时间/冷热层次。
text
older generation
│
│ 更冷,更可能被 eviction 选择
▼
gen N
gen N+1
gen N+2
gen N+3
▲
│ 更年轻,最近更可能被访问
younger generation
它的核心动作可以压缩成两个词:
text
aging:
创建新 generation,把最近访问过的 folio 归入更年轻的代。
eviction:
从较老 generation 里选择回收候选。
这里要注意两个细节。
第一,aging 不是给每个被访问的 folio 单独创建一个 generation。更准确地说,是 aging 推进全局的年轻代序号,创建一个新的 youngest generation,然后把这一轮扫描中发现"最近访问过"的 folio 归入这个新代或更年轻的代。
举个抽象例子:
text
aging 前:
gen 10 老
gen 11
gen 12
gen 13 新,max_seq
aging 发生:
创建 gen 14,max_seq 前进
最近访问过的 folio -> 移到 gen 14 或更年轻的位置
没有访问证据的 folio -> 大多留在原来的 gen 10/11/12/13
所以从概念上看:
text
generation 数字越大,越新。
generation 数字越小,越老。
但看源码时不能把内部数组下标直接当成真实年龄。Multi-Gen LRU 用循环数组承载 generation,数组槽位会复用。真实的年龄边界要看单调推进的序号:
text
max_seq:
当前 youngest generation。
min_seq:
当前 oldest generation。
第二,eviction 不是从某个代开始,把它之前所有代一次性全部回收。它通常从 min_seq 指向的最老可回收 generation 开始扫描。
仍然用上面的例子:
text
gen 10 最老,min_seq <- 优先扫描
gen 11
gen 12
gen 13
gen 14 最新,max_seq
回收器先看 gen 10。扫描 gen 10 时,每个 folio 还要再判断:
text
folio 仍然冷?
-> 尝试回收。
folio 最近其实访问过?
-> 保护,提升到更年轻的 generation。
folio 暂时不能回收?
-> 跳过或放回。
等 gen 10 被处理到可以推进时,min_seq 才往前走:
text
min_seq: 10 -> 11
这里的"可以推进"不是说 gen 10 里的物理页必须全部成功释放。更准确地说,是 gen 10 这个最老 generation 里,已经没有需要继续留在 gen 10 的可扫描候选。
扫描最老代时,里面的 folio 可能有几种去向:
text
gen 10 里的 folio
│
├─ 确实冷,而且能释放
│ └─ isolate 后回收掉
│
├─ 扫描时发现其实还热
│ └─ 提升到更年轻的 generation
│
├─ 暂时不适合回收,例如 dirty / writeback / busy
│ └─ 跳过、放回,或推迟到后续路径处理
│
└─ unevictable,或者不符合当前 reclaim 条件
└─ 移出普通回收候选,或暂时不作为这轮目标
所以 min_seq 推进的含义更接近:
text
gen 10 不再是一个还有可继续扫描候选的最老代。
而不是:
text
gen 10 里的每一页都已经释放回 buddy。
如果 gen 10 已经空了,或者里面的候选都被处理到不再阻塞最老代推进,min_seq 就可以变成 11。如果后面的某些老 generation 本来也是空的,内核还可以继续跳过空代;但它仍然要保留最少数量的 generation,最新的若干代通常不会直接变成 eviction 目标。
下一轮最老代才变成 gen 11。所以更准确的心智模型是:
text
aging:
推进 max_seq,制造新的年轻代,把热 folio 往新代搬。
eviction:
从 min_seq 指向的最老代开始扫描。
老代被消耗或推进后,min_seq 才继续往前移动。
Multi-Gen LRU 还会保护最新的若干 generation。它们更接近传统 LRU 里的 active 区域,不会轻易作为 eviction 目标。一个简化图是:
text
gen 10 old, min_seq <- 优先回收
gen 11 old/cold <- gen 10 推进后才轮到它
gen 12 inactive-ish
gen 13 protected-ish <- 通常先保护
gen 14 youngest, max_seq <- 通常先保护
更完整一点:
这和 active/inactive 并不是两套完全无关的概念。Multi-Gen LRU 仍然要兼容很多现有统计和语义,例如 /proc/vmstat 里依然能看到 nr_active_file、nr_inactive_file 这类计数。
可以这样建立对应关系:
text
传统 LRU:
active / inactive 是两层冷热表达。
Multi-Gen LRU:
generation 是多层冷热表达。
最年轻的若干代更像 active。
更老的代更像 inactive。
现代内核如果启用 Multi-Gen LRU,常见观察入口是:
text
/sys/kernel/mm/lru_gen/enabled
/sys/kernel/mm/lru_gen/min_ttl_ms
/sys/kernel/debug/lru_gen
/sys/kernel/debug/lru_gen_full
但这些入口依赖内核配置、权限和 debugfs 是否挂载。容器里尤其要小心:能读 /proc/vmstat 不代表一定能读 debugfs。
七、完整实验代码
下面这份代码做几件事:
- 打印
uname、cgroup 内存限制、RLIMIT_MEMLOCK。 - 探测 Multi-Gen LRU 的 sysfs/debugfs 入口。
- 读取
/proc/meminfo和/proc/vmstat里的 LRU 相关字段。 - 创建 512 MiB 匿名映射并逐页写入,让匿名页真实分配。
mlock其中 16 页,观察unevictable/mlock统计。- 创建 512 MiB 文件,
fsync后读入 page cache,观察文件页统计。 - 清理映射后再次观察统计回落。
同一份代码也保存为 assets/lru-active-inactive-demo.c。
c
#define _GNU_SOURCE
#include <errno.h>
#include <fcntl.h>
#include <inttypes.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <sys/resource.h>
#include <sys/stat.h>
#include <sys/utsname.h>
#include <time.h>
#include <unistd.h>
struct vm_counter {
const char *name;
unsigned long long value;
int found;
};
static const char *const vmstat_keys[] = {
"nr_inactive_anon",
"nr_active_anon",
"nr_inactive_file",
"nr_active_file",
"nr_unevictable",
"nr_mlock",
"nr_anon_pages",
"nr_file_pages",
"nr_mapped",
"nr_dirty",
"nr_writeback",
"workingset_refault_anon",
"workingset_refault_file",
"workingset_activate_anon",
"workingset_activate_file",
"workingset_restore_anon",
"workingset_restore_file",
"pgactivate",
"pgdeactivate",
"pgrefill",
"pgscan_kswapd",
"pgscan_direct",
"pgsteal_kswapd",
"pgsteal_direct",
"pgscan_anon",
"pgscan_file",
"pgsteal_anon",
"pgsteal_file",
};
static volatile unsigned long long sink;
static void die(const char *msg)
{
perror(msg);
exit(1);
}
static void sleep_seconds(unsigned int seconds)
{
struct timespec req;
req.tv_sec = seconds;
req.tv_nsec = 0;
while (nanosleep(&req, &req) != 0) {
if (errno != EINTR)
die("nanosleep");
}
}
static void print_cgroup_memory(void)
{
const char *paths[] = {
"/sys/fs/cgroup/memory.current",
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory.swap.current",
"/sys/fs/cgroup/memory.swap.max",
};
size_t i;
printf("cgroup memory:\n");
for (i = 0; i < sizeof(paths) / sizeof(paths[0]); i++) {
FILE *fp = fopen(paths[i], "r");
char buf[128];
if (!fp)
continue;
if (fgets(buf, sizeof(buf), fp)) {
buf[strcspn(buf, "\n")] = '\0';
printf(" %s = %s\n", paths[i], buf);
}
fclose(fp);
}
}
static void print_meminfo(const char *tag)
{
const char *wanted[] = {
"MemTotal:",
"MemAvailable:",
"Cached:",
"Active:",
"Inactive:",
"Active(anon):",
"Inactive(anon):",
"Active(file):",
"Inactive(file):",
"Unevictable:",
"Mlocked:",
"AnonPages:",
"Mapped:",
"Dirty:",
"Writeback:",
"SwapTotal:",
"SwapFree:",
};
FILE *fp = fopen("/proc/meminfo", "r");
char line[256];
size_t i;
if (!fp)
die("fopen /proc/meminfo");
printf("[%s] /proc/meminfo selected fields\n", tag);
while (fgets(line, sizeof(line), fp)) {
for (i = 0; i < sizeof(wanted) / sizeof(wanted[0]); i++) {
if (strncmp(line, wanted[i], strlen(wanted[i])) == 0) {
fputs(line, stdout);
break;
}
}
}
fclose(fp);
}
static int read_first_line(const char *path, char *buf, size_t len)
{
FILE *fp = fopen(path, "r");
if (!fp)
return -1;
if (!fgets(buf, len, fp)) {
fclose(fp);
return -1;
}
buf[strcspn(buf, "\n")] = '\0';
fclose(fp);
return 0;
}
static void print_lru_gen_status(void)
{
char buf[256];
FILE *fp;
int lines = 0;
printf("multi-gen LRU interfaces:\n");
if (read_first_line("/sys/kernel/mm/lru_gen/enabled",
buf, sizeof(buf)) == 0)
printf(" /sys/kernel/mm/lru_gen/enabled = %s\n", buf);
else
printf(" /sys/kernel/mm/lru_gen/enabled not readable: %s\n",
strerror(errno));
if (read_first_line("/sys/kernel/mm/lru_gen/min_ttl_ms",
buf, sizeof(buf)) == 0)
printf(" /sys/kernel/mm/lru_gen/min_ttl_ms = %s\n", buf);
else
printf(" /sys/kernel/mm/lru_gen/min_ttl_ms not readable: %s\n",
strerror(errno));
fp = fopen("/sys/kernel/debug/lru_gen", "r");
if (!fp) {
printf(" /sys/kernel/debug/lru_gen not readable: %s\n",
strerror(errno));
return;
}
printf(" /sys/kernel/debug/lru_gen first lines:\n");
while (lines < 12 && fgets(buf, sizeof(buf), fp)) {
buf[strcspn(buf, "\n")] = '\0';
printf(" %s\n", buf);
lines++;
}
fclose(fp);
}
static void read_vmstat(struct vm_counter *counters, size_t nr)
{
FILE *fp = fopen("/proc/vmstat", "r");
char name[128];
unsigned long long value;
size_t i;
if (!fp)
die("fopen /proc/vmstat");
for (i = 0; i < nr; i++) {
counters[i].value = 0;
counters[i].found = 0;
}
while (fscanf(fp, "%127s %llu", name, &value) == 2) {
for (i = 0; i < nr; i++) {
if (strcmp(name, counters[i].name) == 0) {
counters[i].value = value;
counters[i].found = 1;
break;
}
}
}
fclose(fp);
}
static const struct vm_counter *find_counter(const struct vm_counter *counters,
size_t nr,
const char *name)
{
size_t i;
for (i = 0; i < nr; i++) {
if (strcmp(counters[i].name, name) == 0)
return &counters[i];
}
return NULL;
}
static double pages_to_mib(unsigned long long pages, size_t page_size)
{
return (double)pages * (double)page_size / 1024.0 / 1024.0;
}
static void print_lru_summary(const char *tag,
const struct vm_counter *counters,
size_t nr,
size_t page_size)
{
const char *names[] = {
"nr_inactive_anon",
"nr_active_anon",
"nr_inactive_file",
"nr_active_file",
"nr_unevictable",
"nr_mlock",
};
size_t i;
printf("[%s] LRU size summary from /proc/vmstat\n", tag);
for (i = 0; i < sizeof(names) / sizeof(names[0]); i++) {
const struct vm_counter *c = find_counter(counters, nr, names[i]);
if (!c || !c->found) {
printf(" %-20s not-found\n", names[i]);
continue;
}
printf(" %-20s %12llu pages %10.1f MiB\n",
names[i], c->value, pages_to_mib(c->value, page_size));
}
}
static void print_vmstat_delta(const char *tag,
const struct vm_counter *base,
const struct vm_counter *now,
size_t nr)
{
size_t i;
printf("[%s] /proc/vmstat selected counters\n", tag);
for (i = 0; i < nr; i++) {
if (!now[i].found) {
printf("%-28s not-found\n", now[i].name);
continue;
}
printf("%-28s %llu", now[i].name, now[i].value);
if (base && base[i].found)
printf(" delta=%+lld",
(long long)(now[i].value - base[i].value));
putchar('\n');
}
}
static void init_counters(struct vm_counter *counters, size_t nr)
{
size_t i;
for (i = 0; i < nr; i++) {
counters[i].name = vmstat_keys[i];
counters[i].value = 0;
counters[i].found = 0;
}
}
static void *map_anon(size_t len, size_t page_size, size_t *locked_len)
{
unsigned char *p;
size_t i;
size_t lock_len;
p = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED)
die("mmap anonymous");
printf("touching anonymous mapping: %zu pages\n", len / page_size);
for (i = 0; i < len; i += page_size)
p[i] = (unsigned char)(i / page_size);
lock_len = 16 * page_size;
if (lock_len > len)
lock_len = len;
*locked_len = 0;
if (lock_len > 0) {
if (mlock(p, lock_len) == 0) {
*locked_len = lock_len;
printf("mlock succeeded: %zu bytes\n", lock_len);
} else {
printf("mlock skipped: %s\n", strerror(errno));
}
}
return p;
}
static int create_file(char *path, size_t path_len, size_t len)
{
char template[] = "/tmp/lru-file-demo-XXXXXX";
unsigned char *buf;
size_t chunk = 1024 * 1024;
size_t written = 0;
int fd;
fd = mkstemp(template);
if (fd < 0)
die("mkstemp");
snprintf(path, path_len, "%s", template);
buf = malloc(chunk);
if (!buf)
die("malloc file buffer");
memset(buf, 0x5a, chunk);
printf("creating clean file cache source: %s (%zu MiB)\n",
path, len / 1024 / 1024);
while (written < len) {
size_t todo = len - written;
ssize_t ret;
if (todo > chunk)
todo = chunk;
ret = write(fd, buf, todo);
if (ret < 0)
die("write test file");
if (ret == 0) {
fprintf(stderr, "short write creating test file\n");
exit(1);
}
written += (size_t)ret;
}
free(buf);
if (fsync(fd) != 0)
die("fsync test file");
#ifdef __linux__
{
int ret = posix_fadvise(fd, 0, 0, POSIX_FADV_DONTNEED);
if (ret != 0)
printf("posix_fadvise(DONTNEED) was ignored or unsupported: %s\n",
strerror(ret));
}
#endif
if (lseek(fd, 0, SEEK_SET) < 0)
die("lseek test file");
return fd;
}
static void *map_file_and_touch(int fd,
size_t len,
size_t page_size,
unsigned int hot_rounds)
{
unsigned char *p;
size_t pages = len / page_size;
size_t hot_pages = pages;
unsigned long long local = 0;
unsigned int round;
size_t i;
p = mmap(NULL, len, PROT_READ, MAP_SHARED, fd, 0);
if (p == MAP_FAILED)
die("mmap file");
printf("reading file mapping once: %zu pages\n", pages);
for (i = 0; i < len; i += page_size)
local += p[i];
if (hot_pages > 16384)
hot_pages = 16384;
printf("re-reading hot file subset: %zu pages, %u rounds\n",
hot_pages, hot_rounds);
for (round = 0; round < hot_rounds; round++) {
for (i = 0; i < hot_pages * page_size; i += page_size)
local += p[i];
}
sink += local;
return p;
}
static size_t mib_to_len(unsigned long long mib)
{
return (size_t)mib * 1024UL * 1024UL;
}
int main(int argc, char **argv)
{
unsigned long long anon_mib = 512;
unsigned long long file_mib = 512;
unsigned int hot_rounds = 4;
unsigned int hold_seconds = 1;
size_t page_size;
size_t anon_len;
size_t file_len;
size_t locked_len = 0;
unsigned char *anon = NULL;
unsigned char *file_map = NULL;
int file_fd = -1;
char file_path[PATH_MAX] = {0};
struct utsname uts;
struct rlimit rlim;
size_t nr_counters = sizeof(vmstat_keys) / sizeof(vmstat_keys[0]);
struct vm_counter before[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
struct vm_counter after_anon[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
struct vm_counter after_file[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
struct vm_counter after_sleep[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
struct vm_counter after_cleanup[sizeof(vmstat_keys) / sizeof(vmstat_keys[0])];
if (argc >= 2)
anon_mib = strtoull(argv[1], NULL, 10);
if (argc >= 3)
file_mib = strtoull(argv[2], NULL, 10);
if (argc >= 4)
hot_rounds = (unsigned int)strtoul(argv[3], NULL, 10);
if (argc >= 5)
hold_seconds = (unsigned int)strtoul(argv[4], NULL, 10);
if (anon_mib == 0 && file_mib == 0) {
fprintf(stderr,
"usage: %s [anon_mib] [file_mib] [hot_rounds] [hold_seconds]\n",
argv[0]);
return 2;
}
init_counters(before, nr_counters);
init_counters(after_anon, nr_counters);
init_counters(after_file, nr_counters);
init_counters(after_sleep, nr_counters);
init_counters(after_cleanup, nr_counters);
if (uname(&uts) != 0)
die("uname");
page_size = (size_t)sysconf(_SC_PAGESIZE);
anon_len = mib_to_len(anon_mib);
file_len = mib_to_len(file_mib);
printf("machine=%s sysname=%s release=%s\n",
uts.machine, uts.sysname, uts.release);
printf("page_size=%zu bytes\n", page_size);
printf("anon=%llu MiB, file=%llu MiB, hot_rounds=%u, hold_seconds=%u\n",
anon_mib, file_mib, hot_rounds, hold_seconds);
if (getrlimit(RLIMIT_MEMLOCK, &rlim) == 0) {
printf("RLIMIT_MEMLOCK soft=%llu hard=%llu bytes\n",
(unsigned long long)rlim.rlim_cur,
(unsigned long long)rlim.rlim_max);
}
print_cgroup_memory();
print_lru_gen_status();
read_vmstat(before, nr_counters);
print_meminfo("before");
print_lru_summary("before", before, nr_counters, page_size);
print_vmstat_delta("before", NULL, before, nr_counters);
if (anon_len > 0) {
anon = map_anon(anon_len, page_size, &locked_len);
read_vmstat(after_anon, nr_counters);
print_meminfo("after anonymous touch");
print_lru_summary("after anonymous touch", after_anon,
nr_counters, page_size);
print_vmstat_delta("after anonymous touch", before,
after_anon, nr_counters);
}
if (file_len > 0) {
file_fd = create_file(file_path, sizeof(file_path), file_len);
file_map = map_file_and_touch(file_fd, file_len, page_size,
hot_rounds);
read_vmstat(after_file, nr_counters);
print_meminfo("after file cache touch");
print_lru_summary("after file cache touch", after_file,
nr_counters, page_size);
print_vmstat_delta("after file cache touch", before,
after_file, nr_counters);
}
if (hold_seconds > 0) {
printf("sleeping %u seconds before cleanup...\n", hold_seconds);
sleep_seconds(hold_seconds);
}
read_vmstat(after_sleep, nr_counters);
print_meminfo("after sleep");
print_lru_summary("after sleep", after_sleep, nr_counters, page_size);
print_vmstat_delta("after sleep", before, after_sleep, nr_counters);
if (file_map && munmap(file_map, file_len) != 0)
die("munmap file");
if (file_fd >= 0) {
close(file_fd);
if (file_path[0] != '\0')
unlink(file_path);
}
if (anon) {
if (locked_len > 0 && munlock(anon, locked_len) != 0)
die("munlock");
if (munmap(anon, anon_len) != 0)
die("munmap anonymous");
}
sleep_seconds(1);
read_vmstat(after_cleanup, nr_counters);
print_meminfo("after cleanup");
print_lru_summary("after cleanup", after_cleanup, nr_counters,
page_size);
print_vmstat_delta("after cleanup", before, after_cleanup, nr_counters);
printf("sink=%llu\n", sink);
return 0;
}
编译和运行命令:
bash
docker run --rm --platform linux/amd64 \
-v "$PWD/os/memory/assets:/src:ro" -w /tmp \
gcc:13 \
bash -lc 'gcc -O2 -Wall -Wextra -std=c11 /src/lru-active-inactive-demo.c -o /tmp/lru-demo && /tmp/lru-demo 512 512 4 1'
这里的参数含义是:
text
512 -> 触摸 512 MiB 匿名页
512 -> 创建并读取 512 MiB 文件页
4 -> 对文件映射的热子集重复读取 4 轮
1 -> 清理前保持 1 秒
八、一次真实 x86_64 容器输出
实验环境:
text
Docker --platform linux/amd64
container uname -m: x86_64
kernel: Linux 6.12.65-linuxkit
page size: 4096 bytes
关键输出如下。为了聚焦,这里只保留和 LRU、匿名页、文件页、Multi-Gen LRU 有关的行:
text
machine=x86_64 sysname=Linux release=6.12.65-linuxkit
page_size=4096 bytes
anon=512 MiB, file=512 MiB, hot_rounds=4, hold_seconds=1
RLIMIT_MEMLOCK soft=18446744073709551615 hard=18446744073709551615 bytes
cgroup memory:
/sys/fs/cgroup/memory.current = 4857856
/sys/fs/cgroup/memory.max = max
/sys/fs/cgroup/memory.swap.current = 0
/sys/fs/cgroup/memory.swap.max = max
multi-gen LRU interfaces:
/sys/kernel/mm/lru_gen/enabled = 0x0001
/sys/kernel/mm/lru_gen/min_ttl_ms = 0
/sys/kernel/debug/lru_gen not readable: No such file or directory
[before] LRU size summary from /proc/vmstat
nr_inactive_anon 19464 pages 76.0 MiB
nr_active_anon 121016 pages 472.7 MiB
nr_inactive_file 370170 pages 1446.0 MiB
nr_active_file 207156 pages 809.2 MiB
nr_unevictable 0 pages 0.0 MiB
nr_mlock 0 pages 0.0 MiB
touching anonymous mapping: 131072 pages
mlock succeeded: 65536 bytes
[after anonymous touch] LRU size summary from /proc/vmstat
nr_inactive_anon 19464 pages 76.0 MiB
nr_active_anon 252126 pages 984.9 MiB
nr_inactive_file 370170 pages 1446.0 MiB
nr_active_file 207156 pages 809.2 MiB
nr_unevictable 0 pages 0.0 MiB
nr_mlock 0 pages 0.0 MiB
[after anonymous touch] /proc/vmstat selected counters
nr_active_anon 252126 delta=+131110
nr_anon_pages 262540 delta=+131109
pgscan_kswapd 1163138 delta=+0
pgscan_direct 0 delta=+0
creating clean file cache source: /tmp/lru-file-demo-CgqVVI (512 MiB)
reading file mapping once: 131072 pages
re-reading hot file subset: 16384 pages, 4 rounds
[after file cache touch] LRU size summary from /proc/vmstat
nr_inactive_anon 19464 pages 76.0 MiB
nr_active_anon 252134 pages 984.9 MiB
nr_inactive_file 370174 pages 1446.0 MiB
nr_active_file 338224 pages 1321.2 MiB
nr_unevictable 16 pages 0.1 MiB
nr_mlock 16 pages 0.1 MiB
[after file cache touch] /proc/vmstat selected counters
nr_active_anon 252134 delta=+131118
nr_inactive_file 370174 delta=+4
nr_active_file 338224 delta=+131068
nr_unevictable 16 delta=+16
nr_mlock 16 delta=+16
nr_anon_pages 262564 delta=+131133
nr_file_pages 708608 delta=+131081
nr_mapped 191426 delta=+131086
workingset_refault_anon 0 delta=+0
workingset_refault_file 8981 delta=+0
workingset_activate_anon 0 delta=+0
workingset_activate_file 8772 delta=+0
pgactivate 390 delta=+0
pgdeactivate 0 delta=+0
pgscan_kswapd 1163138 delta=+0
pgscan_direct 0 delta=+0
[after cleanup] LRU size summary from /proc/vmstat
nr_inactive_anon 19464 pages 76.0 MiB
nr_active_anon 121079 pages 473.0 MiB
nr_inactive_file 370174 pages 1446.0 MiB
nr_active_file 207156 pages 809.2 MiB
nr_unevictable 0 pages 0.0 MiB
nr_mlock 0 pages 0.0 MiB
这次输出可以读出几个事实。
第一,容器确实是 x86_64 用户态:
text
machine=x86_64
第二,这台内核暴露了 Multi-Gen LRU 的 sysfs 接口:
text
/sys/kernel/mm/lru_gen/enabled = 0x0001
/sys/kernel/mm/lru_gen/min_ttl_ms = 0
0x0001 表示 Multi-Gen LRU 主开关位打开。不同硬件和内核配置下,还可能看到访问位批量清理等附加位。debugfs 没有挂载或没有暴露,所以容器里读不到 /sys/kernel/debug/lru_gen。这不影响用 /proc/vmstat 观察传统兼容统计。
第三,触摸 512 MiB 匿名映射后,匿名页统计按预期增长:
text
nr_active_anon delta=+131110 pages
nr_anon_pages delta=+131109 pages
4KB 页下:
text
131110 pages * 4096 bytes ≈ 512 MiB
这和程序逐页写入 512 MiB 匿名内存对得上。注意这里不是单纯 mmap 的效果,而是第一次写触发缺页,分配真实物理页后才出现。
第四,读入 512 MiB 文件映射后,文件页统计也按预期增长:
text
nr_active_file delta=+131068 pages
nr_file_pages delta=+131081 pages
nr_mapped delta=+131086 pages
这说明文件内容进入了 page cache,并且这次通过 mmap 映射到了进程页表里。
第五,mlock 的 16 页进入了不可回收统计:
text
nr_unevictable delta=+16
nr_mlock delta=+16
这 16 页对应:
text
16 pages * 4096 bytes = 65536 bytes
也就是程序里 mlock 的 64 KiB。unevictable 不是"冷页",而是普通页面回收不应该随便拿走的页。
第六,这次实验没有制造强内存压力,所以这些回收路径计数没有增长:
text
pgscan_kswapd delta=+0
pgscan_direct delta=+0
pgactivate delta=+0
pgdeactivate delta=+0
workingset_* delta=+0
这很重要。它说明这次实验主要观察的是"页进入哪类 LRU 统计",不是观察"回收器如何扫描、激活、降级、refault"。要看 pgscan_*、pgsteal_* 明显增长,需要像番外一那样制造水位线压力。要看 workingset_refault_*,需要让文件页或匿名页先被回收,再很快重新访问。
第七,清理映射后,统计基本回落:
text
after cleanup:
nr_active_anon 121079 pages
nr_active_file 207156 pages
nr_unevictable 0 pages
nr_mlock 0 pages
这说明程序制造的匿名页、文件映射和 mlock 页都已经释放或解除。
九、把实验放回 LRU 模型里
这次实验可以对应到三条路径。
匿名页路径:
text
mmap anonymous
│
└─ 只创建 VMA,不立刻分配全部物理页
逐页写入
│
▼
anonymous page fault
│
▼
分配物理页,建立 PTE
│
▼
anon LRU 统计增长
文件页路径:
text
创建文件并 fsync
│
▼
mmap file
│
▼
逐页读取
│
▼
filemap fault / page cache
│
▼
file LRU 统计增长
不可回收路径:
text
mlock 一小段匿名内存
│
▼
页被移到 unevictable 语义下
│
▼
nr_unevictable / nr_mlock 增长
三条路径的共同点是:它们都占用物理页。
三条路径的差异是:回收框架对它们的处理完全不同。
text
anon:
需要考虑 swap。
clean file:
可以考虑直接丢弃 page cache。
mlocked / unevictable:
普通 reclaim 不应该把它当作候选页。
十、容器里观察 LRU 要小心
容器实验有几个边界必须写清楚。
第一,/proc/vmstat 和 /proc/meminfo 往往是宿主内核视角,不是单个容器的完整私有世界。容器里跑程序时,其他宿主活动也可能让计数变化。
第二,memcg 限制会改变回收行为。如果容器设置了 --memory,你看到的可能是 memcg reclaim,而不是全局 node 水位压力。
第三,Multi-Gen LRU 的 debugfs 入口依赖权限和挂载:
text
/sys/kernel/mm/lru_gen/enabled 常见 sysfs 入口
/sys/kernel/debug/lru_gen 需要 debugfs 和权限
/sys/kernel/debug/lru_gen_full 需要额外统计配置
第四,active_* / inactive_* 统计在启用 Multi-Gen LRU 的内核上仍然存在,但它们是兼容统计视角。读源码时可能看到的是 generation、min_seq、max_seq、lrugen、folio,读 /proc 时看到的仍然是 active/inactive 这些老名字。
十一、常见误解
误解一:LRU 就是严格淘汰最久没访问的页。
不是。Linux 维护的是近似冷热分层,依赖访问位、引用位、refault、扫描反馈等信号。精确全局 LRU 的维护成本太高。
误解二:inactive 页一定会马上被释放。
不是。inactive 只是更靠近回收候选。扫描时如果发现它最近又被访问,可能重新保护;如果是匿名页还要考虑 swap;如果是脏文件页还要考虑 writeback。
误解三:active 页不会被回收。
不是。压力持续上升时,active 页也会被老化、降级,然后进入回收候选。
误解四:文件页一定比匿名页先回收。
不绝对。干净文件页成本低,所以常常更容易回收;但如果文件页是热工作集,频繁 refault,内核会倾向保护它。匿名/文件的扫描比例还受 swappiness、swap 可用性和回收优先级影响。
误解五:folio 只是把 page 换了个名字。
不只是改名。folio 表达的是"这一组 base pages 作为同一个内存对象被管理"。它让 page cache、大页和 LRU 操作可以用更合适的粒度表达。
十二、收束
这一篇需要记住的主线是:
text
LRU 是页面回收的冷热近似结构。
active/inactive:
表达冷热层次,不是绝对生死线。
anon/file:
表达回收成本差异,匿名页通常要 swap,干净文件页可以丢。
unevictable:
表达普通回收不该扫描的页。
folio:
表达现代内核里的 LRU 管理单位可能大于一个 base page。
Multi-Gen LRU:
用多个 generation 替代只有 active/inactive 两层的粗粒度冷热表达。
压成最后一张图:
text
shrink_lruvec
│
▼
lruvec: node + memcg
│
├─ anon
│ ├─ active / young generation
│ └─ inactive / old generation
│ └─ 回收通常需要 swap
│
├─ file
│ ├─ active / young generation
│ └─ inactive / old generation
│ ├─ clean -> drop
│ └─ dirty -> writeback
│
└─ unevictable
└─ mlock 等页,普通 reclaim 跳过
下一篇可以继续拆匿名页回收:匿名页没有文件后盾,Linux 如何通过反向映射找到 PTE,如何把 present PTE 变成 swap entry,以及 swap cache 为什么存在。