LRU 不只是最近最少使用:active/inactive、anon/file、folio 和分代 LRU

LRU 不只是最近最少使用:active/inactive、anon/file、folio 和分代 LRU

番外一讲到,内存压力上来以后,kswapd 会从 balance_pgdat 走到 shrink_nodeshrink_lruvec,开始扫描可回收页。

这一篇就进入 kswapd 真正要扫描的核心结构:LRU。

这里最容易误解的一点是:Linux 的 LRU 不是教科书里那个"把所有页严格排成一条最近最少使用队列"的算法。真实内核要在多核、NUMA、memcg、匿名页、文件页、硬件访问位、反向映射、IO 成本之间折中,所以它维护的是一套近似冷热分层。

这一篇回答四个问题:

  1. 为什么 LRU 要分 activeinactive
  2. 为什么匿名页和文件页要分开?
  3. folio 出现后,LRU 上到底挂的是页还是 folio?
  4. 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,不能随便丢

用流程图看:

flowchart TD A["folio/page 在 LRU 上"] --> B{"最近有访问证据?"} B -- "有" --> C["保持或提升到 active"] B -- "没有" --> D["放到 inactive 或继续留在 inactive"] D --> E{"被 reclaim 扫描"} E -- "扫描时发现 accessed/referenced" --> C E -- "仍然冷" --> F{"anon 还是 file?"} F -- "file" --> G{"clean?"} G -- "clean" --> H["unmap 后从 page cache 移除"] G -- "dirty/writeback" --> I["等待或推动 writeback"] F -- "anon" --> J{"有 swap 或其他可换出路径?"} J -- "有" --> K["写入 swap,PTE 留 swap entry"] J -- "没有" --> L["通常不能直接释放"]

所以 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 后,才能真正回收

这就是为什么回收代码要分 anonfile:它们不是同一种成本模型。

但也不能把它简化成"永远先回收文件页"。真实选择还会受这些因素影响:

  • 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 语境里要注意两点:

  1. 内核实现里很多地方已经以 folio 为单位加入、隔离、移动、回收。
  2. /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 或跳过

流程图:

flowchart TD A["shrink_lruvec"] --> B["get_scan_count: 估算 anon/file 扫描比例"] B --> C["shrink_list"] C --> D{"扫描哪类 LRU?"} D -- "inactive_file" --> E["隔离文件页候选"] D -- "inactive_anon" --> F["隔离匿名页候选"] D -- "active_file / active_anon" --> G["压力下老化或 deactivate"] E --> H{"最近被访问?"} F --> H G --> H H -- "是" --> I["放回 active 或继续保护"] H -- "否" --> J{"能否释放?"} J -- "clean file" --> K["释放 page cache 页"] J -- "dirty file" --> L["writeback 后再说"] J -- "anon with swap" --> M["swap out"] J -- "不能释放" --> N["放回或跳过"]

这里的 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 <- 通常先保护

更完整一点:

flowchart TD A[&#34;内存压力或主动 aging&#34;] --> B[&#34;扫描 page table / rmap / 访问位&#34;] B --> C{&#34;folio 最近被访问?&#34;} C -- &#34;是&#34; --> D[&#34;放入年轻 generation&#34;] C -- &#34;否&#34; --> E[&#34;留在较老 generation&#34;] F[&#34;eviction&#34;] --> G[&#34;选择 oldest generation&#34;] G --> H{&#34;anon/file 成本与 swappiness&#34;} H --> I[&#34;隔离并尝试回收冷 folio&#34;] I --> J{&#34;refault/workingset 信号&#34;} J -- &#34;误回收迹象强&#34; --> K[&#34;后续 aging/eviction 更倾向保护&#34;] J -- &#34;冷页成立&#34; --> L[&#34;继续回收&#34;]

这和 active/inactive 并不是两套完全无关的概念。Multi-Gen LRU 仍然要兼容很多现有统计和语义,例如 /proc/vmstat 里依然能看到 nr_active_filenr_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。

七、完整实验代码

下面这份代码做几件事:

  1. 打印 uname、cgroup 内存限制、RLIMIT_MEMLOCK
  2. 探测 Multi-Gen LRU 的 sysfs/debugfs 入口。
  3. 读取 /proc/meminfo/proc/vmstat 里的 LRU 相关字段。
  4. 创建 512 MiB 匿名映射并逐页写入,让匿名页真实分配。
  5. mlock 其中 16 页,观察 unevictable / mlock 统计。
  6. 创建 512 MiB 文件,fsync 后读入 page cache,观察文件页统计。
  7. 清理映射后再次观察统计回落。

同一份代码也保存为 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_seqmax_seqlrugenfolio,读 /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 为什么存在。

相关推荐
凉、介8 小时前
Linux 设备驱动匹配机制
linux·笔记·单片机·学习·操作系统·嵌入式
沅姬1 天前
如何优雅地关闭找不到终端的 roslaunch 进程
操作系统
小宇子2B2 天前
从 malloc 到物理页:把 Linux 内存管理链路串起来
操作系统
无忧.芙桃2 天前
线程同步与线程互斥(上)
linux·运维·操作系统·运维开发
zandy10114 天前
从可用到好用:2026年信创改造建设之邮件系统经验分享
经验分享·中间件·操作系统·信创·国产cpu
岑梓铭5 天前
考研408《操作系统》复习笔记,第四章《4.2.2 文件操作、文件共享、文件保护》
linux·考研·操作系统·文件系统·408
CS创新实验室5 天前
现代操作系统与大学教材的鸿沟:从理论抽象到工程实践的跨越
操作系统
明朝冰红茶6 天前
《30天自制操作系统》 Day1学习笔记
操作系统
小宇子2B6 天前
vm_area_struct:进程地址空间的合法区间表
操作系统