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

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

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

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

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

这一篇回答四个问题:

  1. 为什么 LRU 要分 active 和 inactive?
  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 后,才能真正回收

这就是为什么回收代码要分 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 语境里要注意两点:

  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_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。

七、完整实验代码

下面这份代码做几件事:

  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_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 为什么存在。

相关推荐
小小小小钰儿11 小时前
2.1-Windows本地部署大模型
人工智能·windows·计算机·网络安全·操作系统·编程
流浪0011 天前
Linux系统篇39——线程(四) pthread库的由来和线程的创建与等待
linux·操作系统·线程·pthread库
流浪0011 天前
Linux系统篇42——线程(七) pthread库管理线程的工作流,从创建到回收
linux·操作系统·线程·线程创建到销毁
云计算练习生2 天前
什么是 Linux?从 Unix 到开源生态的完整脉络
linux·开源·操作系统·unix
云计算练习生3 天前
什么是上下文切换?进程切换时 CPU 到底在保存什么
linux·服务器·操作系统·进程切换
CS创新实验室4 天前
为什么三台机器就能选出一个“带头大哥“?——Raft 一致性算法,一次讲透
分布式·操作系统
sweet丶4 天前
iOS 为什么没有内存交换分区(swap)
操作系统
我爱cope10 天前
【操作系统 | 计算机硬件:CPU、内存与 I/O 如何支撑操作系统?】
学习·操作系统
NineData11 天前
NineData 与统信服务器操作系统完成兼容认证
数据库·oracle·操作系统·软件·数据库管理工具·ninedata·统信
DataScience_201911 天前
系统|dup2:文件描述符复制的艺术
操作系统