Linux 内核页面回收与 LRU 管理机制

目录

1. 概要

Linux 内核的 LRU 页面管理,本质上是在解决一个问题:有限的物理内存如何在众多进程之间高效分配,同时保证热数据尽量留在内存中。

内核的解法是维护一套双时钟(Double clock)链表(active/inactive),通过页面的访问历史来估算其热度,让热页留在内存、冷页被回收。整个机制由两条方向相反的路径共同驱动:用户访问推动页面晋升,内存压力推动页面降级和回收。

理解 LRU 管理需要把握三个核心问题:

  1. 页面如何进出 LRU:新页从哪里来,回收后去哪里
  2. 热度如何感知:内核如何知道一个页面最近是否被访问过
  3. 回收如何决策:在什么压力下回收,回收哪些页面

2. 背景与设计目标

2.1 为什么需要 LRU 管理

物理内存是有限的,而系统中同时运行的进程、文件缓存、内核数据结构都在竞争这块空间。当内存不足时,内核必须决定:把哪些页面换出或丢弃,为新的分配请求腾出空间。

这个决策的核心难题在于:内核无法预知未来,只能根据过去的访问历史来推断一个页面将来是否还会被用到。最朴素的假设是,最近被访问过的页面,将来被访问的概率更高,这就是 LRU(Least Recently Used,最近最少使用)策略的出发点。

然而严格的 LRU 实现代价极高 —— 每次访问都需要更新全局有序链表,在多核系统上锁竞争会成为严重瓶颈。Linux 内核采用的是 LRU 的近似实现,用可接受的精度换取可扩展的性能。

2.2 双时钟链表模型(Double CLOCK Lists)

Linux 的 LRU 实现基于双时钟链表模型,每个 NUMA 节点针对文件页匿名页各维护两条链表:inactive 链表active 链表

其工作方式类似两个时钟指针:

fault ------------------------+
                              |
           +--------------+   |                +-------------+
reclaim <- |   inactive   | <-+-- demotion <-- |    active   | <--+
           +--------------+                    +-------------+    |
                  |                                               |
                  +---------------- promotion --------------------+
  • 新页从 inactive 链表头部进入
  • 回收扫描从 inactive 链表尾部进行
  • 在 inactive 链表上被多次访问的页面晋升到 active 链表
  • active 链表过大时,其尾部的页面降级回 inactive 链表

这个模型的关键特性是:页面需要被访问两次才能进入 active 链表。第一次访问只是进入 inactive 并打上 referenced 标记,第二次访问才触发晋升。这个两次访问门槛有效过滤了只被顺序读一次的流式 I/O 页面,避免它们污染 active 链表、挤占真正的热页。

2.3 active / inactive 的设计意图

active / inactive 两条链表承担不同的职责:

  • inactive 链表是回收页面的候选池。新页默认进入这里,经过一段时间的观察期 —— 如果在被扫描到之前再次被访问,说明它是热页,应当晋升保护;如果一直没有被访问,说明它是冷页,可以回收。inactive 链表的长度决定了这个观察窗口的大小。

  • active 链表是热页的保护区。进入这里的页面已经证明自己被访问过不止一次,受到保护不会被直接回收。但 active 链表不能无限增长,当它相对 inactive 链表过大时,尾部的页面会被降级回 inactive,重新接受观察。

这个设计带来两个重要性质:

  • 避免污染 active 链表:一次性顺序读取的大量页面不会因为单次访问就占据 active 链表,不会挤压真正的热页
  • 自适应平衡:active 和 inactive 的比例不是固定的,而是根据内存压力和访问模式动态调整,系统在不同 workload 下都能找到合适的平衡点

这两条链表共同构成了一个连续的热度谱:从 inactive 尾部(最冷,即将被回收)到 active 头部(最热,刚被访问),页面在这个热度谱上根据访问行为不断流动。

3. 数据结构

3.1 struct lruvec:LRU 管理的基本单元

LRU 管理的核心数据结构是 struct lruvec,定义在 include/linux/mmzone.h

struct lruvec {
    struct list_head        lists[NR_LRU_LISTS];  // 5条 LRU 链表
    struct zone_reclaim_stat reclaim_stat;        // 扫描/旋转统计
    atomic_long_t           inactive_age;         // workingset 计数器,驱逐 和 晋升 的 inactive file page 总数
    unsigned long           refaults;             // 上次回收周期的 refault 数
#ifdef CONFIG_MEMCG
    struct pglist_data      *pgdat;
#endif
};

lruvec 挂在 struct pglist_data(NUMA 节点管理结构)上:

// include/linux/mmzone.h

typedef struct pglist_data {
	...
	spinlock_t   lru_lock;   // 保护该节点所有 LRU 链表操作的自旋锁
	struct lruvec lruvec;    // 该节点的 LRU 向量
	...
};

组织层次如下:

系统
└── NUMA node(struct pglist_data)
    ├── lru_lock          ← 保护以下所有链表
    └── lruvec
        ├── lists[0]  LRU_INACTIVE_ANON
        ├── lists[1]  LRU_ACTIVE_ANON
        ├── lists[2]  LRU_INACTIVE_FILE
        ├── lists[3]  LRU_ACTIVE_FILE
        └── lists[4]  LRU_UNEVICTABLE

如果开启了 CONFIG_MEMCG,每个 NUMA 节点的每个 memcg 有自己独立的 lruvec,形成 per-node × per-memcg 的矩阵结构。

zone_reclaim_stat 记录了最近扫描和晋升的页面数,用于 get_scan_count() 决策 anon/file 页的扫描比例:

struct zone_reclaim_stat {
    unsigned long recent_rotated[2];  // [0]=anon, [1]=file,最近晋升数
    unsigned long recent_scanned[2];  // [0]=anon, [1]=file,最近扫描数
};

3.2 五条链表:类型划分与含义

enum lru_list 定义了五种链表类型:

enum lru_list {
    LRU_INACTIVE_ANON = 0,  // 匿名页 inactive
    LRU_ACTIVE_ANON   = 1,  // 匿名页 active
    LRU_INACTIVE_FILE = 2,  // 文件页 inactive
    LRU_ACTIVE_FILE   = 3,  // 文件页 active
    LRU_UNEVICTABLE   = 4,  // 不可回收页
    NR_LRU_LISTS      = 5
};
  • 匿名页(anon):进程的堆、栈、私有匿名 mmap 等,没有对应的磁盘文件,回收时需要 swap out 到交换分区。
  • 文件页(file):文件 page cache、可执行文件的代码段等,有对应的磁盘文件,回收时直接丢弃(脏页需先写回)。
  • unevictable:被 mlock() 锁定的页面,或其他不可回收的特殊页面。这条链表存在的意义是让回收扫描跳过这些页面,避免无效扫描。

回收扫描只遍历前四条链表(for_each_evictable_lru),unevictable 链表不参与回收。

3.3 struct page 中的关键 flags

每个物理页面对应一个 struct page,其 flags 字段中有几个与 LRU 管理直接相关的标记位(定义在 include/linux/page-flags.h):

flag 含义 相关操作
PG_lru page 当前在某条 LRU 链表上 SetPageLRU() / ClearPageLRU() / PageLRU()
PG_active page 在 active 链表上(否则在 inactive) SetPageActive() / ClearPageActive() / PageActive()
PG_referenced page 最近被访问过一次(软件标记) SetPageReferenced() / ClearPageReferenced() / TestClearPageReferenced()
PG_unevictable page 不可回收 SetPageUnevictable() / PageUnevictable()
PG_reclaim page 已被标记为待回收(writeback 路径) SetPageReclaim() / PageReclaim()

这几个 flag 共同描述了一个 page 在 LRU 体系中的完整状态:

PG_lru=0                      → 不在任何 LRU 链表上(刚分配或已释放)
PG_lru=1, PG_active=0         → 在 inactive 链表上
PG_lru=1, PG_active=1         → 在 active 链表上
PG_lru=1, PG_unevictable=1    → 在 unevictable 链表上

PG_referenced 是 inactive 链表上的第一次访问标记,与 PG_active 共同实现两次访问晋升机制:

inactive + PG_referenced=0  →(第一次访问)→  inactive + PG_referenced=1
inactive + PG_referenced=1  →(第二次访问)→  active   + PG_referenced=0
active   + PG_referenced=0  →(访问)      →  active   + PG_referenced=1

3.4 per-cpu pagevec:批量操作的缓冲层

直接操作 LRU 链表需要持有 lru_lock,在多核系统上频繁加锁会造成严重竞争。为此内核引入了 per-cpu 的 pagevec 作为缓冲层。

struct pagevec 是一个简单的定长数组,每个 CPU 独立持有(即 per-cpu 数据),无需加锁:

#define PAGEVEC_SIZE  14

struct pagevec {
    unsigned long nr;                   // 当前存放的 page 数
    unsigned long cold;                 // 是否为冷页(释放时用)
    struct page  *pages[PAGEVEC_SIZE];  // page 指针数组
};

mm/swap.c 中定义了 5per-cpu pagevec,每个对应一种 LRU 操作:

static DEFINE_PER_CPU(struct pagevec, lru_add_pvec);           // 新增入 LRU
static DEFINE_PER_CPU(struct pagevec, lru_rotate_pvecs);       // 移到 inactive 尾部
static DEFINE_PER_CPU(struct pagevec, lru_deactivate_file_pvecs); // 文件页降级
static DEFINE_PER_CPU(struct pagevec, lru_lazyfree_pvecs);     // 匿名页 lazyfree
#ifdef CONFIG_SMP
static DEFINE_PER_CPU(struct pagevec, activate_page_pvecs);    // 晋升到 active
#endif

工作方式:page 先被加入当前 CPU 的 pagevec,当 pagevec 满(14个)或遇到复合页时,批量刷入 NUMA 节点的 LRU 链表(此时才加锁)。lru_add_drain() 可以强制将当前 CPU 的所有 pagevec 刷入 LRU回收路径扫描前会调用它以确保 LRU 状态是最新的。

这一层设计将锁竞争从每 page 操作降低到每 14 个 page 操作,在高并发场景下效果显著,代价是 LRU 链表的状态存在短暂的不一致窗口 —— pagevec 里的 page 还没有反映到 LRU 链表上

4. 页面进入 LRU(新增页面到 LRU 的路径)

4.1 文件页:add_to_page_cache_lru() 路径

文件页加入 page cache radix tree 的同时进入 LRU,入口是 add_to_page_cache_lru()

// mm/filemap.c

int add_to_page_cache_lru(struct page *page, struct address_space *mapping,
                          pgoff_t offset, gfp_t gfp_mask)
{
    void *shadow = NULL;

    // 1. 加入 page cache radix tree
    ret = __add_to_page_cache_locked(page, mapping, offset, gfp_mask, &shadow);

    // 2. 决定进入 active 还是 inactive
    if (!(gfp_mask & __GFP_WRITE) &&
        shadow && workingset_refault(shadow)) {
        // 该页曾被驱逐,且 refault 距离满足条件 → 直接进 active
        SetPageActive(page);
        workingset_activation(page);
    } else {
        ClearPageActive(page);  // 进入 file inactive LRU
    }

    // 3. 加入 LRU
    lru_cache_add(page);
}

这里有一个重要细节:绝大多数文件页进入 inactive (ClearPageActive()),但如果该页之前被驱逐过且 refault 距离满足条件(workingset 机制),则直接进入 active,跳过 inactive 的观察期。

调用 add_to_page_cache_lru() 的典型场景:

  • do_sync_mmap_readahead() / __do_page_cache_readahead() — 预读(read ahead)
  • page_cache_read() — page fault 时同步读入 (file_fault() -> page_cache_read())
  • generic_perform_write() — 写文件时分配新 page

4.2 匿名页:fault 路径

匿名页没有对应的文件,在 page fault 时由内核分配并加入 LRU。以 do_anonymous_page() 为例(简化代码,只保留主干逻辑):

// mm/memory.c

static int do_anonymous_page(struct vm_fault *vmf)
{
    // 1. 从 buddy 分配物理页
    page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

    // 2. 建立 rmap
    page_add_new_anon_rmap(page, vma, vmf->address, false);

    // 3. 加入 LRU(active 或 unevictable)
    lru_cache_add_active_or_unevictable(page, vma);

    // 4. 建立 PTE 映射
    set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
}

注意匿名页直接进入 active 链表(通过 lru_cache_add_active_or_unevictable() 中的 SetPageActive()),而不是 inactive。这与文件页的默认行为不同,原因是匿名页没有磁盘备份,回收代价更高(需要 swap),因此给予更强的保护。

类似路径还有:

  • do_wp_page() — COW 写时复制,新分配的页进 active
  • do_swap_page() — swap in,从交换分区读回的页通过 lru_cache_add_anon() 进 inactive

4.3 特殊情况:直接进入 active 的场景

并非所有新页都从 inactive 开始,以下情况会直接进入 active

场景 函数 原因
匿名页 fault(非 mlock) lru_cache_add_active_or_unevictable() 匿名页回收代价高,给予保护
文件页 workingset refault add_to_page_cache_lru() + SetPageActive()
mlock 区域的页 add_page_to_unevictable_list() 进 unevictable,不参与回收

4.4 __lru_cache_add() 与 pagevec 的刷入

无论哪条路径,最终都通过 __lru_cache_add() 进入 per-cpu pagevec 缓冲:

static void __lru_cache_add(struct page *page)
{
    struct pagevec *pvec = &get_cpu_var(lru_add_pvec);

    get_page(page);  // 增加引用计数
    if (!pagevec_add(pvec, page) || PageCompound(page))
        __pagevec_lru_add(pvec);  // pagevec 满或复合页,立即刷入
    put_cpu_var(lru_add_pvec);
}

pagevec 满(14个)时,__pagevec_lru_add() 批量将 page 刷入 NUMA 节点的 LRU 链表,核心逻辑在 __pagevec_lru_add_fn() 中(保留核心逻辑的简化版):

static void __pagevec_lru_add_fn(struct page *page, struct lruvec *lruvec, ...)
{
    enum lru_list lru = page_lru(page);  // 根据 PG_active、PG_unevictable 决定目标 LRU 链表

    SetPageLRU(page);                    // 设置 PG_lru 标记
    add_page_to_lru_list(page, lruvec, lru);  // 插入链表头部
}

page_lru() 的决策逻辑:

PG_unevictable=1          → LRU_UNEVICTABLE
PG_unevictable=0, file 页
  PG_active=1             → LRU_ACTIVE_FILE
  PG_active=0             → LRU_INACTIVE_FILE
PG_unevictable=0, anon 页
  PG_active=1             → LRU_ACTIVE_ANON
  PG_active=0             → LRU_INACTIVE_ANON

所以 PG_active 标记在 page 进入 pagevec 时就已经决定了它最终落入哪条链表,__pagevec_lru_add_fn() 只是执行这个决定。

pagevec强制刷入时机除了满之外,还有:

  • lru_add_drain() — 回收路径扫描前主动调用,确保 LRU 状态最新
  • lru_add_drain_all() — 刷入所有 CPU 的 pagevec
  • 进程调度或睡眠时,内核会自动 drain plug(block 层),类似机制也适用于 pagevec

5. 页面热度感知机制

5.1 页面热度两种标记方式的分工

内核标记页面访问热度有两种方式,分工明确:

  • 软件显式标记:在已知的 I/O 路径上主动调用 mark_page_accessed(),适用于文件页通过 read()/write() 系统调用访问的场景。内核完全掌握这些路径,可以精确标记。

  • PTE accessed flag bit:CPU 通过 MMU 访问页面时,在 page fault 中通过软件、或硬件自动将 PTE 中 accessed flag 标记为 1。内核定期扫描 PTE accessed flag bit 标记来判断页面是否被访问过。适用于所有通过 PTE 映射访问的场景(mmap 文件页、匿名页)。

两者互补:

访问场景 标记方式
文件页,read()/write() 路径 软件显式标记:mark_page_accessed()
文件页,mmap 映射访问 软件 或 硬件 设置 PTE accessed flag bit
匿名页,用户态指针访问 软件 或 硬件 设置 PTE accessed flag bit

5.2 软件显式标记:mark_page_accessed()

mark_page_accessed()(调整过的版本)实现了两次访问晋升的状态机:

// mm/swap.c

void mark_page_accessed(struct page *page)
{
    if (!PageActive(page) && !PageUnevictable(page) && PageReferenced(page)) {
        // inactive + referenced → 第二次访问 → 晋升到 active
        activate_page(page);
        ClearPageReferenced(page);
        if (page_is_file_cache(page))
            workingset_activation(page);
    } else if (!PageReferenced(page)) {
        // 第一次访问 → 打上 referenced 标记
        SetPageReferenced(page);
    }
}

状态转换:

inactive, unreferenced  →(第一次)→  inactive, referenced
inactive, referenced    →(第二次)→  active,   unreferenced
active,   unreferenced  →(访问)  →  active,   referenced

典型调用场景:

  • filemap_fault():mmap 文件页 fault 时
  • do_generic_file_read():read() 系统调用读文件时
  • mm/gup.c:get_user_pages() 函数 pin 住页面时

5.3 PTE accessed bit 的通用原理

page_referenced() 通过 rmap 遍历页面的所有 PTE,检查并清除 accessed flag bit,返回发现的引用数

// mm/rmap.c

int page_referenced(struct page *page, int is_locked,
                    struct mem_cgroup *memcg, unsigned long *vm_flags)
{
	...
	struct page_referenced_arg pra = {
		.mapcount = total_mapcount(page),
		.memcg = memcg,
	};
	struct rmap_walk_control rwc = {
		.rmap_one = page_referenced_one,
		.arg = (void *)&pra,
		.anon_lock = page_lock_anon_vma_read,
	};
	...
    // 遍历所有映射该 page 的 PTE
    // 对每个 PTE 调用 ptep_clear_flush_young()
    // 返回 young bit 为 1 的 PTE 数量
	rmap_walk(page, &rwc);
	...
	return pra.referenced;
}

page_referenced_one()
	...
	ptep_clear_flush_young()

ptep_clear_flush_young() 的实现:

// mm/pgtable-generic.c

int ptep_clear_flush_young(struct vm_area_struct *vma,
                           unsigned long address, pte_t *ptep)
{
    int young = ptep_test_and_clear_young(vma, address, ptep);
    if (young)
        flush_tlb_page(vma, address);  // 清除 TLB 中的缓存
    return young;
}

page_referenced() 在两个地方被调用:

  • shrink_inactive_list() -> page_check_references():决定 inactive 页是否晋升或回收
  • shrink_active_list():决定 active 页是否降级

5.4 ARM32 Short Descriptor(2级分页)

5.4.1 Linux PTE 与硬件 PTE 的分离设计

ARM32 short descriptor 格式的硬件 PTE 没有 accessed flag bit,Linux 内核通过维护两套 PTE 来解决这个问题:

同一物理页(4KB)内:
  [0    ~ 2047]  Linux PTE 表(512项 × 4字节)     ← 软件专用
  [2048 ~ 4095]  ARM 硬件 PTE 表(512项 × 4字节)  ← ARM MMU 使用

5.4.2 L_PTE_YOUNG:纯软件标记

// arch/arm/include/asm/pgtable-2level.h
#define L_PTE_YOUNG  (_AT(pteval_t, 1) << 1)  // Linux PTE bit 1

这是纯软件定义的标记,ARM MMU 硬件不认识它。内核用它来模拟 accessed flag bit 的语义。

5.4.3 cpu_v7_set_pte_ext:一次调用同时写两张表(Linux PTE + ARM PTE)

set_pte_at() 最终调用 cpu_v7_set_pte_ext()

// arch/arm/mm/proc-v7-2level.S

ENTRY(cpu_v7_set_pte_ext)
    str  r1, [r0]           @ 写 Linux PTE(含 L_PTE_YOUNG 等软件标记)

    @ 将 Linux PTE bits 转换为硬件 PTE 格式...
    @ 关键:检查 L_PTE_YOUNG
    tst  r1, #L_PTE_YOUNG
    ...
    moveq r3, #0            @ 如果 Linux PTE 的 L_PTE_YOUNG=0 时,ARM MMU 硬件 PTE 清零(页面不可访问)

    str  r3, [r0, #2048]    @ 写硬件 PTE
	...
ENDPROC(cpu_v7_set_pte_ext)

核心设计:Linux PTE 的 L_PTE_YOUNG=0 时,ARM MMU 硬件 PTE 的 access flag bit 被清零,CPU 访问该页会触发 page fault。

d9f758d7-5f8f-43e5-a107-f244e415c136

5.4.4 软件模拟 accessed flag bit 的完整流程

1. 建立映射时
   L_PTE_YOUNG=1 → ARM MMU 硬件 PTE 有效 → 页面可正常访问

2. 内存回收扫描过程调用 page_referenced() 清除 young bit (access flag bit, L_PTE_YOUNG)
   ptep_clear_flush_young()
     → ptep_test_and_clear_young()
         → 清除 Linux PTE 的 L_PTE_YOUNG
         → cpu_v7_set_pte_ext() 将 ARM MMU 硬件 PTE 清零
     → flush_tlb_page()  ← 使 TLB 中的旧映射失效

3. 用户进程再次访问
   → 页面的 ARM MMU 硬件 PTE 为 0 → CPU 触发 Data Abort(page fault)
   → handle_pte_fault()
       → 发现 Linux PTE 存在(L_PTE_PRESENT=1)但 ARM MMU 硬件 PTE 无效(为 0)
       → ptep_set_access_flags()
           → pte_mkyoung()  ← 重新设置 L_PTE_YOUNG
           → set_pte_at()   ← 恢复硬件 PTE
   → 内核记录:此页最近被访问过

这个机制的代价是:每次 page_referenced() 清除 young bit (access flag bit, L_PTE_YOUNG) 后,下一次访问都会产生一次额外的 page fault,这是用 fault 开销换取页面访问感知能力的权衡。

5.5 ARM32 LPAE / Long Descriptor(3级分页)

5.5.1 Linux PTE 与硬件 PTE 合并

LPAE 使用 64 位 PTE,有足够的 bit 空间,Linux PTE 和硬件 PTE 合并为同一张表,不再需要分离设计。

5.5.2 L_PTE_YOUNG = AF bit

// arch/arm/include/asm/pgtable-3level.h
#define L_PTE_YOUNG  (_AT(pteval_t, 1) << 10)  // 硬件 AF(Access Flag) bit

L_PTE_YOUNG 直接映射到 ARM 架构定义的 Access Flag(bit 10),不再是纯软件标记。

5.5.3 与 Short Descriptor 的异同

机制本质相同:清除 AF → 触发 fault → fault 处理中重置 AF。区别在于:

  • Short descriptor:L_PTE_YOUNG 是软件专用 bit,通过清零整个硬件 PTE 来触发 fault,走 do_page_fault() 处理
  • LPAE:L_PTE_YOUNG 就是硬件 AF bit,清除后 ARM 硬件会因 AF=0 产生 Access Flag Fault,对 Access Flag Fault 的处理也是走 do_page_fault()

5.6 ARM64

5.6.1 硬件 AF bit 的原生支持

ARM64 使用 LPAE 格式的 PTE,PTE_AF(bit 10)是硬件定义的 Access Flag:

// arch/arm64/include/asm/pgtable-hwdef.h
#define PTE_AF  (_AT(pteval_t, 1) << 10)  // Access Flag

// arch/arm64/include/asm/pgtable.h
#define pte_young(pte)   (!!(pte_val(pte) & PTE_AF))

static inline pte_t pte_mkold(pte_t pte)
{
    return clear_pte_bit(pte, __pgprot(PTE_AF));
}

static inline pte_t pte_mkyoung(pte_t pte)
{
    return set_pte_bit(pte, __pgprot(PTE_AF));
}

ARM64 没有分离的 Linux PTE 和硬件 PTE,pte_young() 直接读取硬件 PTE 的 AF bit,实现简洁。

5.6.2 ptep_set_access_flags() 的原子操作

ARM64 的 ptep_set_access_flags() 使用 cmpxchg_relaxed() 原子操作更新 PTE,原因是 ARM64 支持硬件自动更新 AF(HAFDBS 特性),需要防止软件更新与硬件更新之间的竞争:

// arch/arm64/mm/fault.c

int ptep_set_access_flags(struct vm_area_struct *vma,
                          unsigned long address, pte_t *ptep,
                          pte_t entry, int dirty)
{
	...
    // 使用 cmpxchg 原子更新,防止与硬件 AF 更新竞争
    pteval = READ_ONCE(pte_val(*ptep));
    do {
        old_pteval = pteval;
        pteval |= pte_val(entry);  // 设置 AF 等标记
        pteval = cmpxchg_relaxed(&pte_val(*ptep), old_pteval, pteval);
    } while (pteval != old_pteval);
	...
}

5.6.3 FEAT_HAFDBS:硬件自动更新 AF

ARMv8.1 引入了 FEAT_HAFDBS(Hardware management of the Access Flag and Dirty state),通过 TCR_EL1.HA(bit 39)启用。启用后,CPU 在首次访问页面时自动将 AF 置 1,不再需要触发 fault 来感知访问。

// arch/arm64/include/asm/pgtable-hwdef.h
#define TCR_HA  (UL(1) << 39)  // Hardware Access flag update
#define TCR_HD  (UL(1) << 40)  // Hardware management of Dirty state

在笔者分析的 Linux 4.14 代码中,内核尚未启用 TCR_HA,ARM64 仍采用与 ARM32 LPAE 相同的 fault 模拟机制。

架构 accessed bit 来源 配置方式 额外 fault 开销
ARM32 Short Descriptor 纯软件 L_PTE_YOUNG 清零硬件 PTE → fault → 重置
ARM32 LPAE 硬件 AF bit(bit 10) 清除 AF → fault → 重置
ARM64(无 HAFDBS) 硬件 AF bit PTE_AF 清除 AF → fault → 重置
ARM64(有 HAFDBS) 硬件自动置位 AF 直接读取/清除

6. 页面在 LRU 链表间的迁移

6.1 晋升(inactive → active):activate_page()

晋升由 mark_page_accessed() 触发(见第 5 章),最终调用 activate_page()

// mm/swap.c
void mark_page_accessed(struct page *page)
{
	page = compound_head(page);
	if (!PageActive(page) && !PageUnevictable(page) &&
			PageReferenced(page)) {
		/* inactive + referenced → 第二次访问 → 晋升到 active */
		if (PageLRU(page))
			activate_page(page); /* 页面经 activate_page_pvecs 晋升到 active LRU */
		else
			__lru_cache_activate_page(page);
		ClearPageReferenced(page); /* 清除 PG_referenced */
		if (page_is_file_cache(page))
			workingset_activation(page);
	} else if (...) {
		...
	}
	...
}

activate_page()
	...
	__activate_page()

static void __activate_page(struct page *page, struct lruvec *lruvec, void *arg)
{
    if (PageLRU(page) && !PageActive(page) && !PageUnevictable(page)) {
        ...
        int lru = page_lru_base_type(page);  // 确定 anon 还是 file

        del_page_from_lru_list(page, lruvec, lru);       // 从 inactive 移除
        SetPageActive(page);                               // 设置 PG_active
        lru += LRU_ACTIVE;
        add_page_to_lru_list(page, lruvec, lru);          // 加入 active 头部
        ...
    }
}

在 SMP 系统上,activate_page() 先将 page 放入 per-cpuactivate_page_pvecs,批量刷入时才真正执行 __activate_page(),避免频繁加锁。

页面晋升时同时调用 workingset_activation(),递增 lruvec->inactive_age,用于 workingset refault 机制的计数:

void workingset_activation(struct page *page)
{
	...
	atomic_long_inc(&lruvec->inactive_age); // lruvec->inactive_age++
	...
}

6.2 降级(active → inactive):shrink_active_list()

页面降级发生在页面回收路径,核心函数是 shrink_active_list()(简化版,只关注页面降级的相关逻辑)。

完整流程:

// mm/vmscan.c

static void shrink_active_list(unsigned long nr_to_scan,
                               struct lruvec *lruvec,
                               struct scan_control *sc,
                               enum lru_list lru)
{
    ...

    // 1. 从 active 链表尾部 isolate 一批页
    isolate_lru_pages(nr_to_scan, lruvec, &l_hold, ...);

    // 2. 逐页判断
    while (!list_empty(&l_hold)) {
        page = lru_to_page(&l_hold);

        if (page_referenced(page, 0, ...)) {
            // 有 PTE 引用,且是 VM_EXEC 文件页 → 给一次额外机会,留在 active
            if ((vm_flags & VM_EXEC) && page_is_file_cache(page)) {
                list_add(&page->lru, &l_active);
                continue;
            }
        }

        // 其余情况:降级到 inactive
        ClearPageActive(page);
        list_add(&page->lru, &l_inactive);
    }

    // 3. 批量写回 LRU
    move_active_pages_to_lru(lruvec, &l_active,   ..., lru);           // 留在 active
    move_active_pages_to_lru(lruvec, &l_inactive, ..., lru - LRU_ACTIVE); // 降级到 inactive
}

降级决策逻辑:

条件 结果
page_referenced() 返回 true,且是 VM_EXEC 文件页 留在 active(旋转到头部)
page_referenced() 返回 true,其他情况 降级到 inactive
page_referenced() 返回 false 降级到 inactive

注意:

匿名页即使有 PTE 引用也会被降级,只有可执行文件页享有额外保护,原因是匿名页不太可能被 streaming I/O 污染。所谓 streaming I/O 指的是顺序读取大量数据、每个页面只访问一次就不再使用的 I/O 模式。典型场景:

  • cat 一个大文件
  • 视频播放器顺序读取视频文件
  • 数据库做全表扫描
  • 拷贝复制大文件

这类访问的特点是:每个页面被读入 page cache 后,PTE young bit 会被置 1(有访问),但之后再也不会被访问。如果内核把这些页面都晋升到 active 链表,会把真正的热页挤出去,造成 cache 污染。

另外,JVM(Java 虚拟机)的 JIT 编译器会在运行时动态生成机器码,这些代码存放在通过 mmap(PROT_EXEC) 分配的匿名内存区域里,因此这些匿名页带有 VM_EXEC 标记。如果对匿名页也做"VM_EXEC 则保留在 active"的保护,JVM 进程会产生大量受保护的 VM_EXEC 匿名页,占据 active 链表,挤压其他进程的内存。而且这些 JIT 代码页的访问模式和真正的可执行文件代码段不同,不一定值得长期保护。

所以内核的决策是:只保护 VM_EXEC 的文件页(如 .so、可执行文件的代码段),匿名页无论是否 VM_EXEC 都不给额外保护 —— 如果不排除匿名页,JVM 这类应用会让这个保护机制失效。

6.3 其他迁移操作

  • rotate_reclaimable_page()

writeback 完成后,如果页面仍然可回收(未被锁定、未变脏),将其移到 inactive 链表尾部,加速回收

void rotate_reclaimable_page(struct page *page)
{
    // 条件:!PageLocked && !PageDirty && !PageUnevictable && PageLRU
    // 操作:通过 lru_rotate_pvecs 将 page 移到 inactive 尾部
	if (!PageLocked(page) && !PageDirty(page) &&
	    !PageUnevictable(page) && PageLRU(page)) {
		struct pagevec *pvec;
		unsigned long flags;

		get_page(page);
		local_irq_save(flags);
		pvec = this_cpu_ptr(&lru_rotate_pvecs);
		/* 添加 page 导致 per-cpu LRU 向量满了 或 page 为 复合页, 都要将 page 迁移到 NUMA 的 LRU 列表 */
		if (!pagevec_add(pvec, page) || PageCompound(page))
			pagevec_move_tail(pvec);
		local_irq_restore(flags);
	}
}

static void pagevec_move_tail_fn(struct page *page, struct lruvec *lruvec, void *arg)
{
    del_page_from_lru_list(page, lruvec, page_lru(page));
    ClearPageActive(page);
    add_page_to_lru_list_tail(page, lruvec, page_lru(page)); // 插入尾部
}

将页面移到 LRU 链表尾部意味着它会更快被回收扫描到,这是对 writeback 刚完成的干净页的主动加速回收。

  • deactivate_file_page()

强制将文件页降级到 inactive,用于标识页面是良好的候选回收页面的提示场景,例如页面无效化失败时:

static void lru_deactivate_file_fn(struct page *page, struct lruvec *lruvec, void *arg)
{
    // 条件:PageLRU && !PageUnevictable && !page_mapped
    del_page_from_lru_list(page, lruvec, lru + active);
    ClearPageActive(page);
    ClearPageReferenced(page);          // 同时清除 referenced,加速老化
    add_page_to_lru_list(page, lruvec, lru);

    if (PageWriteback(page) || PageDirty(page))
        SetPageReclaim(page);           // 脏页/回写页:标记为待回收
    else
        list_move_tail(&page->lru, &lruvec->lists[lru]); // 干净页:移到 inactive 尾部
}
  • mark_page_lazyfree()

匿名页转换为 lazyfree 状态,移入 inactive file 链表,使其可以像文件页一样被直接丢弃(不需要 swap out):

static void lru_lazyfree_fn(struct page *page, struct lruvec *lruvec, void *arg)
{
    // 条件:PageLRU && PageAnon && PageSwapBacked && !PageSwapCache
    del_page_from_lru_list(page, lruvec, LRU_INACTIVE_ANON + active);
    ClearPageActive(page);
    ClearPageReferenced(page);
    ClearPageSwapBacked(page);          // 清除 swap 标记,变为[类文件页]
    add_page_to_lru_list(page, lruvec, LRU_INACTIVE_FILE); // 进入 file inactive
}

这是 MADV_FREE 的底层实现 —— 用户态告知内核这块内存可以随时丢弃,内核将其标记为 lazyfree,在内存压力时直接释放,无需写入 swap。

6.4 active / inactive 的平衡:inactive_list_is_low()

shrink_list() 在决定是否压缩 active 链表时,调用 inactive_list_is_low() 检查平衡状态:

static unsigned long shrink_list(enum lru_list lru, ...)
{
    if (is_active_lru(lru)) {
        if (inactive_list_is_low(...))
            shrink_active_list(...);  // inactive 不足时才压缩 active
        return 0;
    }
    return shrink_inactive_list(...);
}

inactive_ratio 的计算:

// mm/vmscan.c

static bool inactive_list_is_low(...)
{
	...
	if (file && actual_reclaim && lruvec->refaults != refaults) {
		inactive_ratio = 0;
	} else {
		// inactive * inactive_ratio < active → inactive 不足,需要降级
		gb = (inactive + active) >> (30 - PAGE_SHIFT);  // 总内存换算为 GB
		if (gb)
			inactive_ratio = int_sqrt(10 * gb);  // 随内存增大而增大
		else
			inactive_ratio = 1;
	}
	...
}

对应关系:

总内存 inactive_ratio inactive 目标占比
< 1GB 1 50%
1GB 3 25%
10GB 10 ~9%
100GB 31 ~3%
1TB 101 ~1%

内存越大,inactive 链表相对越小,active 链表占比越高。这是合理的:大内存系统的 working set 更大,需要更多 active 空间来保护热页。

特殊情况:workingset 激活时禁用保护,即 inactive_list_is_low() 里的代码片段:

if (file && actual_reclaim && lruvec->refaults != refaults) {
    inactive_ratio = 0;  // 强制 inactive_list_is_low() 返回 true
}

当检测到 refault 活动(说明有新的 working set 正在建立),强制将 inactive_ratio 设为 0,使 inactive_list_is_low() 始终返回 true,从而持续压缩 active 链表,快速淘汰旧的 working set,为新的 working set 腾出空间。

7. 页面回收

7.1 回收触发机制:watermark 与 kswapd / direct reclaim

内核通过三条水位线(watermark)来决定何时触发回收,定义在每个 内存 zone(struct zone) 上:

struct zone {
	/* Read-mostly fields */

	/* zone watermarks, access with *_wmark_pages(zone) macros */
	unsigned long watermark[NR_WMARK]; /* 内存 zone 的各种水位线 */
	...
};
free pages
    │
    ├── high watermark(WMARK_HIGH)   ← 高于此:内存充裕,不回收
    │
    ├── low watermark(WMARK_LOW)     ← 低于此:唤醒 kswapd 异步回收
    │
    └── min watermark(WMARK_MIN)     ← 低于此:触发 direct reclaim 同步回收

触发路径在页面分配器 __alloc_pages_slowpath() 中:

// mm/page_alloc.c

// 分配失败,进入慢速路径
// 1. 唤醒 kswapd(异步,不阻塞分配者)
wake_all_kswapds(order, &ac);
    → wakeup_kswapd(zone, order, ...)
        → wake_up_interruptible(&pgdat->kswapd_wait)

// 2. 再次尝试分配(使用 WMARK_MIN)
page = get_page_from_freelist(..., ALLOC_WMARK_MIN, ...);

// 3. 仍然失败 → direct reclaim(同步,阻塞分配者)
progress = try_to_free_pages(ac->zonelist, order, gfp_mask, ...);

kswapd 是每个 NUMA 节点一个的内核线程(kswapd<N>),被唤醒后调用 balance_pgdat() 持续回收,直到所有 zone 的 free pages 恢复到 high watermark 以上,然后重新睡眠。

direct reclaim 在分配者的上下文中同步执行,会阻塞分配请求,是内存压力极大时的最后手段。

7.2 shrink_inactive_list():从 inactive 尾部扫描

回收的核心入口是 shrink_inactive_list(),核心流程如下(对源码进行过编辑):

// mm/vmscan.c

shrink_inactive_list(nr_to_scan, lruvec, sc, lru)
{
    lru_add_drain();  // 确保 pagevec 已刷入 LRU

    // 1. 从 inactive 尾部 isolate 一批页
    isolate_lru_pages(nr_to_scan, lruvec, &page_list, ...);

    // 2. 核心回收逻辑
    nr_reclaimed = shrink_page_list(&page_list, pgdat, sc, ...);

    // 3. 未能回收的页放回 LRU
    putback_inactive_pages(lruvec, &page_list);
}

isolate_lru_pages() 从链表尾部取页,清除 PG_lru,将页从 LRU 摘出放入临时列表,此后这些页处于隔离状态,不在任何 LRU 链表上。

7.3 page_check_references():回收决策的核心逻辑

shrink_page_list() 对每个 isolated 页调用 page_check_references() 决定是否要被回收(代码有裁剪):

enum page_references page_check_references(struct page *page, ...)
{
    referenced_ptes = page_referenced(page, ...);  // 扫描 PTE young bit (L_PTE_YOUNG)
    referenced_page = TestClearPageReferenced(page); // 检查并清除 PG_referenced

    if (vm_flags & VM_LOCKED)
        return PAGEREF_RECLAIM;  // mlock 页,走回收流程(try_to_unmap 会移入 unevictable)

    if (referenced_ptes) {
        if (PageSwapBacked(page))
            return PAGEREF_ACTIVATE;  // 匿名页有 PTE 引用 → 晋升

        SetPageReferenced(page);
        if (referenced_page || referenced_ptes > 1)
            return PAGEREF_ACTIVATE;  // 文件页多次引用 → 晋升

        if (vm_flags & VM_EXEC)
            return PAGEREF_ACTIVATE;  // 可执行文件页 → 晋升

        return PAGEREF_KEEP;          // 文件页单次引用 → 留在 inactive 再观察
    }

    if (referenced_page && !PageSwapBacked(page))
        return PAGEREF_RECLAIM_CLEAN; // 有 PG_referenced 但无 PTE 引用的文件页 → 回收

    return PAGEREF_RECLAIM;           // 无任何引用 → 回收
}

结果处理:

PAGEREF_ACTIVATE      → SetPageActive() → 放回 active 链表
PAGEREF_KEEP          → 放回 inactive 链表(不回收)
PAGEREF_RECLAIM       → 继续走回收流程
PAGEREF_RECLAIM_CLEAN → 继续走回收流程(文件页直接丢弃)

7.4 脏页处理:writeback 与回收的协作

shrink_page_list() 遇到脏页时,不能直接回收,需要先写回磁盘:

static unsigned long shrink_page_list(...)
{
	...
	if (PageDirty(page)) {
		if (page_is_file_cache(page) &&
			(!current_is_kswapd() || !PageReclaim(page) || ...)) {
			// 非 kswapd,或 kswapd 首次遇到此脏页:
			// 标记 PG_reclaim,移回 inactive 头部,等 flusher 写回
			SetPageReclaim(page);
			goto activate_locked;
		}
		// kswapd 且页面已标记 PG_reclaim(说明 flusher 已在处理):
		// 调用 pageout() 触发写回
		switch (pageout(page, mapping, sc)) {
		case PAGE_KEEP:    goto keep_locked;
		case PAGE_ACTIVATE: goto activate_locked;
		case PAGE_SUCCESS: ...  // 写回成功,继续回收
		}
	}
	...
}

关键设计:只有 kswapd 才能主动触发文件页的 writeback,避免 direct reclaim 路径上的栈溢出风险。普通进程的 direct reclaim 遇到脏页只能标记 PG_reclaim 后放回,等 kswapd 或 flusher 处理。

rotate_reclaimable_page() 在 writeback 完成后将干净页移到 inactive 尾部,加速其被回收扫描到。

7.5 isolate / putback 模式:并发安全的标准操作

LRU 上的所有操作都遵循 isolate → 处理 → putback 的三步走:

isolate_lru_page(page)
  → spin_lock(lru_lock)
  → ClearPageLRU(page)          // 清除 PG_lru
  → del_page_from_lru_list()    // 从链表摘出
  → get_page()                  // 增加引用计数
  → spin_unlock(lru_lock)
返回:page 不在任何 LRU 上,PG_lru=0

... 处理 page(可能耗时,不持锁)...

putback_lru_page(page)
  → 判断 page_evictable()
  → 若可回收:lru_cache_add()   // 经 pagevec 放回合适的 LRU
  → 若不可回收:add_page_to_unevictable_list()

这个并发安全的标准操作的意义:

  • isolate 后,page 不在 LRU 上,其他路径(如另一个 CPU 的回收扫描)不会并发操作它
  • 处理期间不持 lru_lock,避免长时间持锁
  • putback 时重新判断 evictability,处理 isolate 期间状态变化的情况(如 mlock)

isolate_lru_page()(单页,需要调用者持有引用)isolate_lru_pages()(批量,回收路径内部使用)是两个不同的函数,前者用于外部调用(如 compaction、migration),后者用于 shrink_*() 函数内部。

7.6 扫描压力分配:get_scan_count() 与 anon/file 比例

mm/vmscan.cget_scan_count() 决定每次回收扫描各条 LRU 链表多少页,输出 nr[4](anon inactive、anon active、file inactive、file active 各自的扫描数)。

决策优先级(从高到低):

1. SCAN_FILE:无 swap 空间,或 memcg 禁用 swap
   → 只扫描文件页

2. SCAN_EQUAL:接近 OOM(priority=0)且有 swappiness
   → anon 和 file 等比例扫描

3. SCAN_ANON:文件页 + 空闲页已低于 high watermark,且 inactive anon 充足
   → 只扫描匿名页(防止文件 LRU 过小导致抖动)

4. SCAN_FILE:文件 inactive 链表足够大(> active)
   → 优先扫描文件页

5. SCAN_FRACT:默认情况
   → 按 swappiness 和最近回收效率的加权比例分配

SCAN_FRACT 的计算(代码经过简化):

static void get_scan_count(...)
{
	...
	anon_prio = swappiness;          // 默认 60
	file_prio = 200 - swappiness;    // 默认 140

	// 根据最近扫描/晋升比例调整(扫描多但晋升少 → 该类型页更"冷",扫描压力更大)
	ap = anon_prio * (recent_scanned[anon] + 1) / (recent_rotated[anon] + 1);
	fp = file_prio * (recent_scanned[file] + 1) / (recent_rotated[file] + 1);

	// 各链表扫描数 = 基础扫描数 × 对应比例
	nr[lru] = scan * fraction[file] / (ap + fp + 1);
	...
}

swappiness(/proc/sys/vm/swappiness,默认 60)是用户可调的:

  • 设为 0:完全不扫描匿名页(不使用 swap)
  • 设为 100:anon 和 file 优先级相同
  • 默认 60:偏向扫描文件页,减少 swap 使用

8. workingset 与 refault 机制

8.1 thrashing 问题的定义

当系统内存不足时,会出现一种恶性循环:页面被频繁访问,但每次访问之前它已经被驱逐出内存,导致不断地缺页、读磁盘、再驱逐,这种状态称为 thrashing(抖动)。

抖动的根本原因是:workload 的 working set(活跃使用的页面集合)大于可用内存。但有一种情况可以优化:working set 虽然大于 inactive 链表,却小于整个内存(inactive + active)。此时问题不是内存总量不够,而是 active 链表占用了本可用于 inactive 的空间,而 active 链表中可能存在大量已经不再活跃的页面

     +-memory available to cache-+
     |                           |
     +-inactive------+-active----+
 a b | c d e f g h i | J K L M N |
     +---------------+-----------+

如果能识别出哪些页面是真正的热页(即使被驱逐后很快又被访问),就可以让它们直接进入 active 链表,与现有 active 页面竞争,把真正冷的 active 页面挤出去。这就是 workingset refault 机制要解决的问题。

8.2 inactive_age 计数器:E 与 R 的含义

workingset 机制的核心lruvec->inactive_age,定义在 include/linux/mmzone.h

struct lruvec {
    ...
    atomic_long_t  inactive_age;  // 文件页 inactive 链表的驱逐+晋升事件计数
    ...
};

这是一个单调递增的原子计数器,每次文件页 inactive 链表发生以下两类事件时递增:

  • 驱逐(eviction):页面被回收出内存,workingset_eviction() 中 atomic_long_inc_return()
  • 晋升(activation):页面从 inactive 晋升到 active,workingset_activation() 中 atomic_long_inc()

两类事件都会将 inactive 链表上的其他页面向尾部推移,因此都计入同一个计数器 lruvec->inactive_age

ER 是这个计数器 lruvec->inactive_age 在两个不同时刻的读数:

  • E(Eviction):页面被驱逐时的 inactive_age 快照,存入 shadow entry
  • R(Refault):该页面再次缺页时读取的 inactive_age 当前值

R - E 即为 refault 距离,表示该页面不在缓存期间,inactive 链表上发生的事件总数,也就是这段时间内 inactive 链表被推进了多少的度量。

8.3 shadow entry:驱逐时的记录

(文件)页面被驱逐时,workingset_eviction()page cache radix tree 原来的槽位留下一个 shadow entry

shrink_page_list()
	__remove_mapping()
		workingset_eviction()

// mm/workingset.c
void *workingset_eviction(struct address_space *mapping, struct page *page)
{
	struct mem_cgroup *memcg = page_memcg(page);
	struct pglist_data *pgdat = page_pgdat(page);
	int memcgid = mem_cgroup_id(memcg);
	unsigned long eviction;
	struct lruvec *lruvec;

	...

    lruvec = mem_cgroup_lruvec(pgdat, memcg);
    eviction = atomic_long_inc_return(&lruvec->inactive_age);  // 先递增,取值为 E
    return pack_shadow(memcgid, pgdat, eviction);               // 打包成 shadow entry
}

pack_shadow()memcgid、node id、E 值压缩编码进一个指针大小的值,存入 radix tree 的 exceptional entry 槽位。这个槽位原本存放 page 指针,page 被驱逐后 page 指针消失,shadow entry 占据其位置:

static void *pack_shadow(int memcgid, pg_data_t *pgdat, unsigned long eviction)
{
	eviction >>= bucket_order;
	eviction = (eviction << MEM_CGROUP_ID_SHIFT) | memcgid;
	eviction = (eviction << NODES_SHIFT) | pgdat->node_id;
	eviction = (eviction << RADIX_TREE_EXCEPTIONAL_SHIFT);

	return (void *)(eviction | RADIX_TREE_EXCEPTIONAL_ENTRY);
}

shrink_page_list()
	__remove_mapping()
		shadow = workingset_eviction(mapping, page);
		__delete_from_page_cache(page, shadow);
			page_cache_tree_delete(mapping, page, shadow);
				__radix_tree_replace(&mapping->page_tree, ..., shadow, workingset_update_node, mapping);

shadow entry 的生命周期:随 inode 存在而存在,inode 被回收时一起清除。workingset.c 中有专门的 shrinker(workingset_shadow_shrinker)在内存压力下回收过多的 shadow node。

8.4 refault distance 的计算与激活判断

页面再次缺页时,add_to_page_cache_lru() 检查是否存在 shadow entry,若有则调用 workingset_refault()

int add_to_page_cache_lru(...)
{
	void *shadow = NULL;

	__SetPageLocked(page);
	ret = __add_to_page_cache_locked(page, mapping, offset, gfp_mask, &shadow);
	if (unlikely(ret))
		...
	else {
		if (!(gfp_mask & __GFP_WRITE) &&
		    shadow && workingset_refault(shadow)) { // refault distance 的计算与激活判断
			...
		} else
			...
		lru_cache_add(page); /* 加入 文件页 LRU */
	}
	...
}

// mm/workingset.c
bool workingset_refault(void *shadow)
{
	...
    unpack_shadow(shadow, &memcgid, &pgdat, &eviction);  // 解出 E

	...
    lruvec = mem_cgroup_lruvec(pgdat, memcg);
    refault = atomic_long_read(&lruvec->inactive_age);   // 读取当前值为 R
    active_file = lruvec_lru_size(lruvec, LRU_ACTIVE_FILE, MAX_NR_ZONES);

    refault_distance = (refault - eviction) & EVICTION_MASK;  // R - E

    if (refault_distance <= active_file)  // 判断条件
        return true;   // 应该激活
    return false;
}
  • 判断条件的推导

页面的完整最小访问距离 = 在缓存内的距离 + 在缓存外的距离:

NR_inactive + (R - E)

如果这个距离 ≤ 整个缓存大小(NR_inactive + NR_active),说明该页面本可以在两次访问之间持续驻留:

NR_inactive + (R - E) ≤ NR_inactive + NR_active

化简得:

(R - E) ≤ NR_active

即:refault 距离不超过 active 链表大小时,激活该页面

8.5 乐观激活策略

workingset_refault() 返回 true 时,add_to_page_cache_lru() 直接页面放入 active 链表

// mm/filemap.c

int add_to_page_cache_lru(...)
{
	...
	if (!(gfp_mask & __GFP_WRITE) &&
		shadow && workingset_refault(shadow)) {
		SetPageActive(page);           // 直接进 active
		workingset_activation(page);   // inactive_age++
	} else {
		ClearPageActive(page);         // 正常进 inactive
	}
	lru_cache_add(page);
	...
}

这是一种乐观激活:内核假设 active 链表中有 (R - E) 个页面的访问频率低于这个 refault 页面,将 refault 页面直接激活,让它与现有 active 页面竞争:

  • 如果判断正确:那些真正不活跃的 active 页面在降级后被驱逐,refault 页面得以留在缓存中,thrashing 得到缓解。

  • 如果判断错误:被挤出的 active 页面其实仍然活跃,它们会在降级到 inactive 后很快再次被访问,触发晋升重新回到 active。系统会自我纠正,代价只是多了几次晋升操作。

workingset 机制与 inactive_list_is_low() 的联动:workingset_activation() 在激活时递增 lruvec->inactive_age,lruvec->refaults 记录上次回收周期的激活数。inactive_list_is_low() 检测到 refault 活动时,会将 inactive_ratio 强制设为 0,持续压缩 active 链表,加速淘汰旧 working set:

// mm/vmscan.c

static bool inactive_list_is_low(...)
{
	...
	if (file && actual_reclaim && lruvec->refaults != refaults) { // 检测到 refault 活动
		inactive_ratio = 0;  // 强制返回 true,持续降级 active 页面
	}
	...
}

static unsigned long shrink_list(...)
{
	if (is_active_lru(lru)) {
		if (inactive_list_is_low(lruvec, is_file_lru(lru), memcg, sc, true))
			shrink_active_list(nr_to_scan, lruvec, sc, lru); /* inactive 不足时才压缩 active */
		return 0;
	}
	// return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);
}

static void shrink_node_memcg(...)
{
	...
	if (inactive_list_is_low(lruvec, false, memcg, sc, true))
		shrink_active_list(SWAP_CLUSTER_MAX, lruvec, sc, LRU_ACTIVE_ANON);
}

这形成了一个完整的反馈回路refault 被检测到 → 乐观激活 → 触发 active 链表压缩 → 旧 working set 被淘汰 → 新 working set 建立

9. file page cache 的 I/O 路径(与 LRU 的关联)

9.1 readahead:预读与 LRU 的关系

文件预读(readahead)是内核在用户进程实际访问之前,提前将磁盘数据读入 page cache 的机制,目的是将 major fault(需要等待磁盘 I/O)转化为 minor fault(page cache 命中)

文件预读(readahead)流程:

do_sync_mmap_readahead() / __do_page_cache_readahead()
  → 从 buddy 分配 page,加入临时 page_pool 链表
  → read_pages()
      → ext4_readpages()          // 文件系统层
          → ext4_mpage_readpages()
              → submit_bio()      // 提交异步 I/O
  → 函数返回(不等待 I/O 完成)

预读分配的 page 在 read_pages() 内部通过 add_to_page_cache_lru() 加入 page cache radix tree同时进入 inactive LRU 链表。预读页默认不调用 mark_page_accessed(),因为此时还不确定用户进程是否真的会访问这些页面 —— 只有当用户进程实际触发 fault 访问时,才会在 filemap_fault() 中调用 mark_page_accessed(),完成第一次访问标记。

预读页的特殊标记:

// __do_page_cache_readahead()
if (page_idx == nr_to_read - lookahead_size)
    SetPageReadahead(page);  // 标记为预读触发点

访问到带有 PG_readahead 标记的页面时,内核会触发下一轮预读,形成流水线效果。

9.2 PTE 映射的建立时机:filemap_fault() 与 alloc_set_pte()

预读只负责把数据读入 page cache,不建立任何 PTE 映射PTE 映射的建立发生在用户进程访问 mmap 地址触发 page fault 时:

用户进程访问 mmap 地址
  → Data Abort / Page Fault
      → do_page_fault()
          → handle_mm_fault()
              → handle_pte_fault()
                  → do_fault()
                      → do_read_fault()
                          → __do_fault()
                              → vma->vm_ops->fault() = filemap_fault()
                                  → find_get_page()     // 在 radix tree 找到预读好的 page
                                  → vmf->page = page    // 返回 page,不调用 mark_page_accessed()
                          → finish_fault()
                              → alloc_set_pte()         // 建立 PTE 映射
                                                        // mk_pte() 构造时已含 L_PTE_YOUNG

filemap_fault() 的核心逻辑:

int filemap_fault(struct vm_fault *vmf)
{
    page = find_get_page(mapping, offset);  // 查找 page cache

    if (likely(page)) {
        // page cache 命中(预读成功)
        do_async_mmap_readahead(...);       // 触发下一轮异步预读
    } else {
        // page cache 未命中(预读未覆盖或未预读)
        do_sync_mmap_readahead(...);        // 同步预读,major fault
    }

    // 等待 I/O 完成(如果数据还没读好)
    lock_page_or_retry(page, ...);

    if (!PageUptodate(page))
        goto page_not_uptodate;  // I/O 出错,重试

    vmf->page = page;
    return VM_FAULT_LOCKED;      // 返回 page,由 finish_fault() 建立 PTE
}

三种访问层面的对比:

访问方式 是否经过 MMU 页表类型 是否需要动态建立
DMA 写 page(I/O 完成) 否,物理地址直达
内核访问 page cache 是,内核直接映射 内核页表(boot 时建好) 不需要
用户进程访问 mmap 是,进程用户态页表 进程页表 需要,fault 时按需建立

9.3 读写路径的对称性

文件页的读和写走的是完全对称的块层路径,区别只在触发时机和数据方向:

读路径:
page fault / read() 触发
    → submit_bio(READ)
        → bio → request → I/O 调度器 → 驱动 → DMA(磁盘 → page 物理内存)

写路径:
dirty page 积累后由 flusher 延迟触发
    → submit_bio(WRITE)
        → bio → request → I/O 调度器 → 驱动 → DMA(page 物理内存 → 磁盘)

两者都经过完整的块层路径(bio → request → 调度器 → 驱动),DMA 只是最后一步的数据搬运手段。

9.4 writeback 线程:flusher 与 kswapd 的分工

  • flusher(flush-<dev>)

基于 workqueue 机制,每个 backing_dev_info(块设备)对应一个 delayed work wb->dwork,工作函数为 fs-writeback.c 中的 wb_workfn()

// 

// mm/backing-dev.c — wb_init()
INIT_DELAYED_WORK(&wb->dwork, wb_workfn);

// 全局 writeback workqueue
bdi_wq = alloc_workqueue("writeback", WQ_MEM_RECLAIM | WQ_FREEZABLE | ...);

worker 线程名为 flush-8:0 等(设备号命名)。触发场景:

触发者 场景
balance_dirty_pages() 进程写文件使脏页超过阈值,主动唤醒
wb_wakeup_delayed() 定期唤醒(dirty_writeback_interval,默认 5 秒)
wakeup_flusher_threads() 内存压力、sync 系统调用

flusher 的写回路径:

wb_workfn()
  → wb_do_writeback()
      → writeback_inodes_wb()
          → writeback_sb_inodes()
              → __writepage() / ext4_writepages()
                  → submit_bio()  → 块层 → 设备
  • kswapd 的附带 writeback

kswapd 主职是页面回收,但在 shrink_page_list() 中遇到脏文件页时,会调用 pageout() → mapping->a_ops->writepage() 触发单页写回。这是 kswapd 的附带行为,不是主要 writeback 路径。

分工原则:

  • flusher 负责主动的、批量的 writeback,是正常路径
  • kswapd 的 writeback 是被动的、单页的,只在回收压力下触发
  • direct reclaim 路径不触发文件页 writeback(避免栈溢出),遇到脏页只标记 PG_reclaim 后放回

9.5 块层路径:bio → request → 调度器 → 驱动

  • bio 到 request 的转换

submit_bio() 将 bio 转换为 request,或合并到已有 request:

submit_bio(bio)
  → generic_make_request()
      → blk_queue_bio()
          → 尝试合并到已有 request(地址连续则合并)
          → 合并失败:bio 转为新 request
              → 有 plug:加入 plug->list 缓冲
              → 无 plug:直接插入调度器队列 + 触发 __blk_run_queue()
  • plug 机制

blk_start_plug() / blk_finish_plug() 提供批量提交能力:

blk_start_plug(&plug);          // 开始积攒
ext4_readpages() → submit_bio() // 多个 bio 积攒到 plug->list
blk_finish_plug(&plug);         // 批量排序后插入调度器,触发调度

blk_finish_plug() 内部对 request 按设备队列 + 磁盘扇区排序,有利于顺序 I/O 合并,然后调用 queue_unplugged() → __blk_run_queue() 触发驱动取 request。

__blk_run_queue() 的调用场景:

场景 触发时机
新 request 无 plug 入队 submit_bio() 路径
plug 刷入 blk_finish_plug()
队列重启 I/O 完成后 blk_start_queue(),或延迟重试 blk_delay_work()
电源管理恢复 blk_post_runtime_resume()
  • I/O 完成回调

DMA 完成后,设备驱动触发中断,bio 完成回调执行:

bio 完成回调
  → end_bio_bh_io_sync() / mpage_end_io()
      → SetPageUptodate(page)   // 标记数据已就绪
      → unlock_page(page)       // 唤醒等待该页的进程

此后 filemap_fault() 中等待的进程被唤醒,PageUptodate() 检查通过,alloc_set_pte() 建立 PTE,fault 处理完成。

9.6 与 LRU 的关联总结

file page cache 的 I/O 路径与 LRU 管理的交汇点:

磁盘数据读入
  → add_to_page_cache_lru()
      → 加入 page cache radix tree
      → 检查 shadow entry(workingset refault)
      → 进入 inactive LRU(或直接 active)

用户进程访问
  → filemap_fault() → mark_page_accessed()
      → 第一次:SetPageReferenced
      → 第二次:activate_page() → 晋升到 active

内存压力回收
  → shrink_inactive_list() → page_check_references()
      → 无引用:回收,workingset_eviction() 留 shadow entry
      → 有引用:晋升或保留

脏页写回
  → flusher writeback → 页面变干净
      → rotate_reclaimable_page() → 移到 inactive 尾部,加速回收

10. memcg 对 LRU 的影响

10.1 per-memcg lruvec

  • 数据结构
    没有 memcg 时,整个 NUMA 节点只有一个 lruvec,挂在 pglist_data 上。开启 CONFIG_MEMCG 后,每个 memcg 在每个 NUMA 节点上都有独立的 lruvec,通过 mem_cgroup_per_node 结构承载:
// include/linux/memcontrol.h

struct mem_cgroup_per_node {
    struct lruvec       lruvec; // NUMA per-memcg 独立的 LRU 向量
    unsigned long       lru_zone_size[MAX_NR_ZONES][NR_LRU_LISTS]; // 各链表大小统计
    struct mem_cgroup_reclaim_iter iter[DEF_PRIORITY + 1];  // 回收迭代器
    struct mem_cgroup   *memcg; // 关联的 memcg
    ...
};

struct mem_cgroup {
    ...
    struct mem_cgroup_per_node *nodeinfo[0];  // per-node 信息数组
};

整体组织结构:

系统
└── NUMA node(pglist_data)
    ├── lruvec              ← root memcg 或禁用 memcg 时使用
    └── (各 memcg 的 per-node 信息)
        ├── memcg_A → nodeinfo[node_id] → lruvec_A
        ├── memcg_B → nodeinfo[node_id] → lruvec_B
        └── memcg_C → nodeinfo[node_id] → lruvec_C
  • lruvec 的查找
    每次 LRU 操作都需要找到 page 所属的 lruvec,通过 mem_cgroup_page_lruvec() 实现:
struct lruvec *mem_cgroup_page_lruvec(struct page *page, struct pglist_data *pgdat)
{
    if (mem_cgroup_disabled()) {
        return &pgdat->lruvec;  // 未开启 memcg:直接用节点 lruvec
    }

    memcg = page->mem_cgroup;   // page 所属的 memcg(charge 时绑定)
    if (!memcg)
        memcg = root_mem_cgroup;

    mz = mem_cgroup_page_nodeinfo(memcg, page);  // 找到 per-node 信息
    return &mz->lruvec;
}

page->mem_cgroup 在页面被 charge 时绑定,在页面被 uncharge 时清除。这意味着每个 page 在其生命周期内归属于固定的 memcg,其 LRU 操作始终在该 memcg 的 lruvec 上进行。

  • page 的 charge 时机
    page 进入 LRU 之前必须先完成 charge,以 do_anonymous_page() 为例:
// mm/memory.c
page = alloc_zeroed_user_highpage_movable(vma, vmf->address);

// 1. charge:将 page 计入 memcg 的内存使用量
mem_cgroup_try_charge(page, vma->vm_mm, GFP_KERNEL, &memcg, false);

// 2. 建立 rmap
page_add_new_anon_rmap(page, vma, vmf->address, false);

// 3. commit charge:正式绑定 page->mem_cgroup
mem_cgroup_commit_charge(page, memcg, false, false);

// 4. 加入 LRU(此时 page->mem_cgroup 已设置)
lru_cache_add_active_or_unevictable(page, vma);

charge 分为 try 和 commit 两步,中间如果发生错误可以 cancel,避免 page 在未完成 charge 的情况下进入 LRU。

  • memcg 限制触发的回收
    当 memcg 的内存使用量超过 limit 时,try_charge() 会触发针对该 memcg 的回收:
static int try_charge(struct mem_cgroup *memcg, gfp_t gfp_mask, unsigned int nr_pages)
{
    // 尝试 charge,失败说明超过 limit
    if (!page_counter_try_charge(&memcg->memory, batch, &counter)) {
        mem_over_limit = mem_cgroup_from_counter(counter, memory);

        // 触发针对超限 memcg 的回收
        nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit,
                                                    nr_pages, gfp_mask, may_swap);
        goto retry;
    }
}

try_to_free_mem_cgroup_pages() 最终调用 shrink_node(),但 sc->target_mem_cgroup 被设置为超限的 memcg,回收扫描只针对该 memcg 的 lruvec,不影响其他 memcg。

  • 回收路径中的 memcg 遍历
    全局回收(kswapd / direct reclaim)需要遍历所有 memcg 的 lruvec,在 shrink_node() 中通过 mem_cgroup_iter() 实现:
// mm/vmscan.c — shrink_node()
memcg = mem_cgroup_iter(root, NULL, &reclaim);
do {
    shrink_node_memcg(pgdat, memcg, sc, &lru_pages);
    // 回收 slab cache
    shrink_slab(sc->gfp_mask, pgdat->node_id, memcg, ...);
    // 记录 vmpressure
    vmpressure(sc->gfp_mask, memcg, false, ...);
} while ((memcg = mem_cgroup_iter(root, memcg, &reclaim)) != NULL);

mem_cgroup_iter() 按 cgroup 层级树的顺序遍历,每个 memcg 都有独立的 reclaim_iter 记录上次扫描位置,避免每次都从头开始扫描。

shrink_node_memcg() 对单个 memcg 执行 get_scan_count() + shrink_list(),逻辑与无 memcg 时完全相同,只是操作的 lruvec 是该 memcg 的。

10.2 cgroup writeback(cgwb)

  • 问题背景
    在没有 cgwb 之前,所有 memcg 的脏页都由同一个 flusher(bdi->wb)写回。这带来一个问题:memcg_A 产生大量脏页,会占用 flusher 的带宽,影响 memcg_B 的写回,导致 memcg_B 的脏页积压,进而触发 memcg_B 的内存压力。memcg 的隔离性被破坏。

  • cgwb 的设计
    CONFIG_CGROUP_WRITEBACK 为每个 (memcg, blkcg) 组合创建独立的 writeback worker(bdi_writeback),称为 cgwb:

// mm/backing-dev.c — cgwb_create()
static int cgwb_create(struct backing_dev_info *bdi,
                       struct cgroup_subsys_state *memcg_css, gfp_t gfp)
{
    wb = kmalloc(sizeof(*wb), gfp);
    ret = wb_init(wb, bdi, blkcg_css->id, gfp);  // 初始化独立的 wb,含独立的 dwork
    // 注册到 bdi->cgwb_tree(以 memcg_css->id 为 key 的 radix tree)
    // 同时挂入 memcg->cgwb_list 和 blkcg->cgwb_list
}

cgwb 的组织结构:

struct backing_dev_info(块设备)
├── wb(root writeback worker,对应 root memcg)
└── cgwb_tree(radix tree,key = memcg_css->id)
    ├── cgwb_A(memcg_A 的 writeback worker)
    ├── cgwb_B(memcg_B 的 writeback worker)
    └── cgwb_C(memcg_C 的 writeback worker)

每个 cgwb 有独立的 dwork(delayed work),独立的脏页链表(b_dirty、b_io、b_more_io),独立的带宽统计。

  • cgwb 的触发与 inode 归属
    inode 的 writeback 由其归属的 cgwb 负责。inode 在首次被写脏时,根据写入进程所属的 memcg 和 blkcg 确定归属的 cgwb,后续该 inode 的所有写回都由这个 cgwb 处理。

当进程的 memcg 发生变化(如进程被移入另一个 cgroup),inode 的 cgwb 归属也会异步迁移(inode_switch_wbs()),保证 writeback 带宽计入正确的 memcg。

  • cgwb 与 LRU 的关联
    cgwb 的隔离使得 memcg 的脏页写回与 LRU 回收形成完整的隔离闭环:
memcg_A 的脏页
  → 由 cgwb_A 写回(不占用其他 memcg 的写回带宽)
  → 写回完成后 rotate_reclaimable_page() 移到 inactive 尾部
  → shrink_node_memcg() 只扫描 memcg_A 的 lruvec 进行回收

没有 cgwb 时,memcg_A 的脏页写回慢会导致其 inactive 链表中脏页积压,回收效率下降,进而影响 memcg_A 的内存限制执行。有了 cgwb,写回和回收都在 memcg 粒度上独立运作,隔离性得到保证。

11. 总结:LRU 管理的整体视图

11.1 完整状态机回顾

page 在 LRU 体系中的完整生命周期:

                    ┌─────────────────────────────────────────────────────┐
                    │                   buddy allocator                   │
                    └──────────────────────────┬──────────────────────────┘
                                               │ 分配
                          ┌────────────────────┼────────────────────┐
                          │ 文件页             │                    │ 匿名页
                          ▼                    │                    ▼
               add_to_page_cache_lru()         │        do_anonymous_page()
               lru_cache_add_file()            │        lru_cache_add_active_or_unevictable()
                          │                    │                    │
                          │ workingset refault?│                    │
                    ┌─────┴──────┐             │                    │
                    │ 是         │ 否          │                    │
                    ▼            ▼             │                    ▼
              [active FILE] [inactive FILE]    │              [active ANON]
                    │            │             │                    │
                    │            │ mark_page_accessed() ×2          │
                    │            ├─────────────────────────────────►│
                    │            │                                   │
                    │◄───────────┘ 晋升(activate_page)              │
                    │                                                │
                    │◄───────────────────────────────────────────────┘
                    │
              [active LRU]
                    │
                    │ shrink_active_list()
                    │ page_referenced() == false
                    │ 或非 VM_EXEC 文件页
                    ▼
              [inactive LRU]
                    │
                    │ shrink_inactive_list()
                    │ page_check_references()
                    ├──── PAGEREF_ACTIVATE ──────────────────► [active LRU]
                    ├──── PAGEREF_KEEP ───────────────────────► 留在 inactive
                    │
                    │ PAGEREF_RECLAIM / PAGEREF_RECLAIM_CLEAN
                    ▼
              workingset_eviction()
              shadow entry → radix tree
                    │
                    ▼
              [释放回 buddy]
                    │
                    │ 再次缺页(refault)
                    │ workingset_refault()
                    │ refault_distance <= NR_active?
                    ├──── 是 ──────────────────────────────► [active LRU](跳过 inactive)
                    └──── 否 ──────────────────────────────► [inactive LRU](正常路径)

page flags 状态对应:

LRU 位置 PG_lru PG_active PG_referenced
不在 LRU 0 - -
inactive,未访问 1 0 0
inactive,访问一次 1 0 1
active 1 1 0/1
unevictable 1 0 -
isolate 中 0 0/1 -

11.2 两条驱动路径的协作关系

LRU 管理由两条方向相反的路径共同驱动,形成动态平衡:

                    用户态访问(推动页面"升温")
                    ─────────────────────────────────────────►
page fault / read()
  → lru_cache_add_*()          新页进入 inactive 头部
  → mark_page_accessed()       inactive → active 晋升
  → workingset_refault()       refault 页直接进 active

                    ◄─────────────────────────────────────────
                    内核回收(推动页面"降温")

内存压力
  → shrink_active_list()       active → inactive 降级
  → shrink_inactive_list()     inactive → 回收或保留
  → workingset_eviction()      驱逐时记录 shadow entry

两条路径的触发完全独立:

  • 访问路径由用户态行为驱动,随时可能发生
  • 回收路径由内存水位触发,只在内存压力下激活

两者通过 LRU 链表这个共享数据结构交汇,lru_lock 保证并发安全。pagevec 作为访问路径的缓冲层,将锁竞争从"每次操作"降低到"每 14 次操作"。

11.3 各组件的职责边界

组件 职责 核心函数
swap.c LRU 链表操作的入口层,pagevec 管理 lru_cache_add_*(), activate_page(), mark_page_accessed()
vmscan.c 回收决策与执行 shrink_active_list(), shrink_inactive_list(), page_check_references(), get_scan_count()
workingset.c working set 检测,refault 激活 workingset_eviction(), workingset_refault(), workingset_activation()
rmap.c PTE accessed bit 采样 page_referenced()
filemap.c 文件页进入 LRU 的入口 add_to_page_cache_lru(), filemap_fault()
memcontrol.c per-memcg LRU 隔离,limit 回收 mem_cgroup_page_lruvec(), try_charge()
backing-dev.c per-memcg writeback 隔离 cgwb_create(), wb_workfn()

11.4 设计权衡与局限

权衡一:精度 vs 性能

严格 LRU 要求每次访问都更新全局有序链表,代价极高。Linux 的近似实现用以下手段换取性能:

  • per-cpu pagevec 批量操作,减少锁竞争
  • PTE accessed bit 采样(而非精确计数),感知"有没有访问"而非"访问了多少次"
  • 两次访问晋升门槛,过滤一次性流式 I/O

代价是 LRU 顺序不精确,可能出现"应该被回收的页面暂时留在内存"或"应该保留的页面被提前回收"的情况。

权衡二:active 链表的保护强度

active 链表提供保护,但保护不是无限的:

  • inactive_ratio 随内存增大而增大,大内存系统的 active 链表占比更高
  • workingset refault 检测到 thrashing 时,强制压缩 active 链表,牺牲保护换取新 working set 的建立速度

权衡三:匿名页 vs 文件页的回收策略

文件页回收代价低(干净页直接丢弃,磁盘上有备份),匿名页回收代价高(需要 swap out)。get_scan_count() 通过 swappiness 和最近回收效率的加权,在两者之间动态平衡,但这个平衡依赖历史统计,对突发 workload 变化的响应有延迟。

局限一:workingset 机制仅覆盖文件页

shadow entry 依赖 page cache radix tree 的槽位,匿名页没有对应的 radix tree,因此 workingset refault 机制在笔者分析的 Linux 4.14 代码中不适用于匿名页。匿名页的 thrashing 无法被检测和优化。

局限二:PTE accessed bit 的采样粒度

两次 page_referenced() 调用之间的多次访问被合并为"有访问"这一个结论,无法区分访问频率的高低。高频访问和低频访问的页面在 LRU 中的待遇相同,只要在扫描窗口内被访问过一次就能晋升。

局限三:NUMA 感知不足

lruvec 是 per-node 的,但回收时的扫描压力分配(get_scan_count())不感知 NUMA 拓扑,可能在远端 NUMA 节点上回收页面,而本地节点仍有冷页可回收。

11.5 总结

Linux LRU 管理的本质是:用访问历史近似估算页面热度,通过双时钟链表在"保护热页"和"回收冷页"之间动态平衡,在可接受的精度损失下实现可扩展的内存管理。

posted @ 2026-04-14 18:27  JiMoKuangXiangQu  阅读(129)  评论(0)    收藏  举报