# 文件页回收流程对比：主线 Linux vs Android（源码级）

> **范围**：只讨论文件页（page cache + shmem）从"被回收器选中"到"物理页归还 buddy"的**回收流程本身**。zram、lmkd 等外围机制仅在与该流程交汇处出现。
>
> **基线**：
> - **主线**：torvalds/master（`mm/vmscan.c` 8131 行）
> - **Android**：android15-6.6 GKI（Android 15 出厂内核，`mm/vmscan.c` 8470 行，含 49 处 vendor hook）；配置参照其 `gki_defconfig`，运行时参数参照 AOSP `rootdir/init.rc`
>
> 源码快照已下载至本工作区 `src/`，以下全部行号可复核。

---

## 0. 流程总览

```
分配变慢 → 水位不足（WMARK_LOW/MIN）
    └─ kswapd 后台回收 / 进程直接回收 → shrink_node()
         │
         ├─ 阶段① 扫描平衡 get_scan_count()
         │      "file LRU 扫不扫？扫多少？" ← refault 成本反馈（阶段⑤）
         │
         ├─ 阶段② LRU 选取与隔离
         │      传统：shrink_active_list() 降级 + isolate_lru_folios() 摘链
         │      MGLRU：get_type_to_scan() → get_tier_idx() → scan_folios()
         │
         ├─ 阶段③ 单页状态机 shrink_folio_list()（六道关口）
         │      trylock → 引用检查 → writeback 判定 → unmap → 脏页处理 → 摘除释放
         │
         ├─ 阶段④ 摘除 page cache：__remove_mapping()（留 shadow）→ free
         │
         └─ 阶段⑤ 反馈回路：重故障时算 refault distance → 修正阶段①
```

Android 没有另写一套回收器——`vmscan.c` 的流程骨架与主线同源。它的改动是**嵌进这条链的每一阶段**：阶段①有行为改写，阶段②③插了十几处决策 hook，阶段⑤则基本不动。下文逐阶段对齐。

---

## 1. 主线 Linux：文件页回收流程五阶段

### 阶段① 扫描平衡 —— `get_scan_count()`（`src/mainline/mm/vmscan.c:2550`）

决定 anon/file 两条 LRU 各自的扫描量，按优先级依次判定（`scan_balance` 五个取值）：

| 判定条件 | 结果 | 行号 | 含义 |
|---|---|---|---|
| 无 swap / `may_swap=0` | `SCAN_FILE` | 2578 | 文件页独扛全部回收（无 swap 服务器的常态） |
| memcg 回收且 swappiness=0 | `SCAN_FILE` | 2591 | 单 cgroup 禁止换出 |
| priority=0（濒临 OOM）且 swappiness≠0 | `SCAN_EQUAL` | 2601 | 等量扫两类 |
| `sc->file_is_tiny` | `SCAN_ANON` | 2609 | file LRU 快抖死，强制转向（见下） |
| `sc->cache_trim_mode` | `SCAN_FILE` | 2619 | 不活跃缓存多且不抖 → 只裁缓存不换出 |
| 其余 | `SCAN_FRACT` | 2624 | 按成本比例分配 |

两个状态位由 `prepare_scan_count()` 预先算好：

- **cache_trim_mode**（2395-2405）：`file >> priority` 且 file LRU 无抖动迹象 → "有大量不活跃、不在抖动的文件页"，回收优先裁它们，不碰匿名页；
- **file_is_tiny**（2407-2441，防 "cache trap"）：文件 LRU 缩到 `file + free ≤ 全部 zone 的 high 水位之和`且匿名页仍可观时，**禁止**继续只扫文件页——否则"cache 越小越被扫、越扫越小"正反馈失控：

```c
sc->file_is_tiny =
    file + free <= total_high_wmark &&
    !(sc->may_deactivate & DEACTIVATE_ANON) &&
    anon >> sc->priority;
```

`SCAN_FRACT` 时的成本模型在 `calculate_pressure_balance()`（2444-2479）：每条 LRU 的"回收成本"来自其 refault 率（阶段⑤的反馈），按 swappiness 加权反比分配扫描压力；swappiness=100 视为两类 I/O 成本相等。

### 阶段② LRU 选取与隔离 —— 两种实现并存

**传统双链**（`CONFIG_LRU_GEN` 未开或 per-lruvec 未启用）：

- `shrink_active_list()`（2065）：活跃链 → 不活跃链的"降级"。先 `folio_check_references()` 查引用，被引用的留在活跃链；同时 `VM_EXEC` 文件页首次使用即激活（"可执行文件页优先驻留"，android15-6.6 对应逻辑在 2869-2874，主线同源）；
- `isolate_lru_folios()`（1679）：从 inactive 链头摘 `nr_to_scan` 个候选页。

**MGLRU**（主线 6.1+，世代×类型×zone 组织，`MAX_NR_GENS=4`、文件页按访问热度 `MAX_NR_TIERS=4`）：

- `get_type_to_scan()`（4822）：比较两类各 tier 的 refault 率+swappiness，选先扫 anon 还是 file；
- `get_tier_idx()`（4802）：选 refault 率最低（最冷）的 tier；
- `scan_folios()`（4727）从最老世代隔离，期间 `sort_folio()`（4654）逐页分诊：

```c
/* protected */
if (tier > tier_idx || refs + workingset == BIT(LRU_REFS_WIDTH) + 1) {
    gen = folio_inc_gen(lruvec, folio);      /* 提回新世代 */
    ...
    return true;
}
```

**"经 fd 二次访问"的热文件页因 tier 高而被保护，不会与一次性扫描页一起被逐出**——这是 MGLRU 对文件页回收质量的直接提升。

### 阶段③ 单页状态机 —— `shrink_folio_list()`（1058-1512）

对隔离出的每个候选页，依次过六道关口，任何一关不过就把页放回 LRU（keep/activate）：

**关口 1 · 加锁与资格**（1089-1118）：`folio_trylock` 失败 → keep；`folio_evictable` 为假（mlocked 等）→ activate；`!may_unmap && folio_mapped` → keep。

**关口 2 · 引用检查**（1231-1242）：`folio_check_references()`（android15-6.6:1575 逐行展开）做反向映射查引用：

```c
if (referenced_ptes) {
    folio_set_referenced(folio);
    if (referenced_folio || referenced_ptes > 1)
        return FOLIOREF_ACTIVATE;    /* 近期被访问过 → 回活跃链 */
    if ((vm_flags & VM_EXEC) && folio_is_file_lru(folio))
        return FOLIOREF_ACTIVATE;    /* 可执行文件页一票保护 */
    return FOLIOREF_KEEP;            /* 第一次引用 → 再绕一圈 */
}
```

**关口 3 · writeback 判定**（1141-1229）：回收路径遇到正在写回的页，三案例——kswapd 高压循环（activate 让路）/ 普通（设 `PG_reclaim` 标记等下一轮"立即回收"）/ 旧式 memcg（等写回完成）。

**关口 4 · unmap**（1328-1357）：`try_to_unmap()` 摘掉进程页表映射；失败 → activate。

**关口 5 · 脏页处理**（1370-1435）——**文件页回收的关键分叉**：

```c
if (folio_test_dirty(folio)) {
    if (folio_is_file_lru(folio)) {
        /* Immediately reclaim when written back. */
        node_stat_mod_folio(folio, NR_VMSCAN_IMMEDIATE, nr_pages);
        if (!folio_test_reclaim(folio))
            folio_set_reclaim(folio);
        goto activate_locked;            /* 不写！标记后放回 */
    }
    ...  /* pageout() 仅处理 anon/shmem */
}
```

主线对脏文件页的策略是**绝不主动写盘**：打上 `PG_reclaim` 标记放回，等 writeback 子系统（脏页比率驱动）洗白后，下一轮回收按标记"立即回收"。`pageout()`（621-676）注释明说：

```c
/*
 * We no longer attempt to writeback filesystem folios here, other
 * than tmpfs/shmem.  That's taken care of in page-writeback.
 */
```

脏页大量积压时回收只做一件事——踢 flusher：`wakeup_flusher_threads(WB_REASON_VMSCAN)`（1958）。
（版本差异注：6.6 基线（含 android15-6.6:2084-2111）仍保留"kswapd + PGDAT_DIRTY + 已带 reclaim 标记"三条件齐备时由 `pageout()` 兜底写脏文件页的最后手段；master 已删除该分支。这是主线版本演进，非 Android 改动。）

**关口 6 · 摘除与释放**（1460-1512）：

- `filemap_release_folio()`：让文件系统无 I/O 地释放页内 buffer；
- `__remove_mapping()` → **干净未映射文件页直接从 page cache 摘除**（零 I/O，文件页回收的理想路径）；
- `free_unref_folios()` 归还 buddy。

### 阶段④ 摘除时的信息埋点 —— shadow entry

`__remove_mapping` 内（主线 1495、android15-6.6:1498-1505）：文件页被摘除瞬间调用 `workingset_eviction()` 在 page cache XArray 原**位置**留下 shadow entry，编码 memcg id + 逐出时钟 + 访问热度：

```c
if (reclaimed && folio_is_file_lru(folio) &&
    !mapping_exiting(mapping) && !dax_mapping(mapping)) {
    shadow = workingset_eviction(folio, target_memcg);
}
__filemap_remove_folio(folio, shadow);
```

### 阶段⑤ 反馈回路 —— refault distance

被逐出的文件页再次故障进来时（`mm/workingset.c`）：

```c
refault_distance = ((refault - eviction) & EVICTION_MASK);   /* :511 */
return refault_distance <= workingset_size;                  /* :536 */
```

distance ≤ 当前活跃工作集大小 → 判定"逐出得太早"，`folio_set_active()` 直接放回活跃链（577-588），计入 `WORKINGSET_ACTIVATE_FILE`。该统计回流到阶段①：`prepare_scan_count()` 用 ACTIVATE_FILE 增量判断 file LRU 是否在抖（决定 `may_deactivate`/`cache_trim_mode`，主线 2385-2391），`calculate_pressure_balance()` 用它抬 file_cost 降文件页扫描压力。MGLRU 下同样的信息进 `refaulted[hist][type][tier]`（workingset.c:320）驱动 tier 选择。

---

## 2. Android：同一条流程上的改动（逐阶段对齐）

### 2.1 阶段① 平衡决策：一处行为改写 + 一组覆盖入口

**(a) `android_rvh_set_balance_anon_file_reclaim` —— 改写 cache_trim 偏向**（android15-6.6:3189/3229-3237）：

```c
bool balance_anon_file_reclaim = false;
...
trace_android_rvh_set_balance_anon_file_reclaim(&balance_anon_file_reclaim);
/*
 * If there is enough inactive page cache, we do not reclaim
 * anything from the anonymous working right now. But when balancing
 * anon and page cache files for reclaim, allow swapping of anon pages
 * even if there are a number of inactive file cache pages.
 */
if (!balance_anon_file_reclaim && sc->cache_trim_mode) {
    scan_balance = SCAN_FILE;
```

主线 cache_trim_mode 是**硬编码偏向**："不活跃缓存够多就只扫文件页"。Android 把它变成**厂商可关闭的开关**——动机写在注释里：zram 在场时换出匿名页（纯 CPU 压缩）常比逐出文件页更便宜，不应让"缓存多"自动屏蔽匿名页回收。这是文件页回收**流程内部**最实质的一处 Android 行为改写。

**(b) `android_vh_tune_swappiness`**（3198 / 3474）：分别插在传统 `get_scan_count` 和 MGLRU `get_swappiness` 里，厂商可按运行状态动态改写 swappiness（静态 sysctl 是全局一份，这里给了 per-reclaim 的动态口子）。

**(c) `android_vh_modify_scan_control`**（7062-7080，Android 新增函数 `modify_scan_control()`）：一次性覆盖三个扫描控制量——

```c
static void modify_scan_control(struct scan_control *sc)
{
    bool file_is_tiny = false, may_writepage = true;
    trace_android_vh_modify_scan_control(&sc->android_vendor_data1,
        &sc->nr_to_reclaim, sc->target_mem_cgroup, &file_is_tiny,
        &may_writepage);
    if (file_is_tiny)
        sc->file_is_tiny = true;
    if (!may_writepage)
        sc->may_writepage = false;
```

注意厂商可以把 `file_is_tiny` **置真**——即人为触发"强制扫匿名页"的防 cache-trap 逃生门；也能关掉 `may_writepage`（连带收紧关口 5）。

**(d) 上游水位调控**：`init.rc:439` 在 `start lmkd` 前写 `write /proc/sys/vm/watermark_boost_factor 0`——主线默认 15000，在迁移类型回退偷页块时抬 kswapd 起扫水位（`boost_watermark()`，page_alloc.c:2175）；Android 关掉它，避免 kswapd 被人为提前/加量唤醒而过度清缓存。

### 2.2 阶段② 隔离：MGLRU 默认启用 + 一组流程 hook

**(a) MGLRU 出厂默认全开**（`gki_defconfig:134-135`：`CONFIG_LRU_GEN=y` + `CONFIG_LRU_GEN_ENABLED=y`），且留了 DeviceConfig 远程灰度开关（`init.rc:1317-1327`）：

```
on property:persist.device_config.mglru_native.lru_gen_config=core_and_mm_walk
  write /sys/kernel/mm/lru_gen/enabled 3
...（none/core/core_and_mm_walk/core_and_nonleaf_young/all → 0/1/3/5/7）
```

即 Android 的文件页选取默认走"世代+tier"路径（阶段②的 MGLRU 分支），热文件页由 `sort_folio()` 的 protected 分支保护。

**(b) 传统隔离路径的 hook**：

- `android_vh_mm_isolate_priv_lru`（2412-2415）——插在 `isolate_lru_folios()` **最顶端**，且是"接管式"的：

```c
trace_android_vh_mm_isolate_priv_lru(nr_to_scan, lruvec, lru, dst, sc->reclaim_idx,
                                     sc->may_unmap, nr_scanned, &nr_taken);
if (*nr_scanned != nr_scanned_before)
    return nr_taken;      /* hook 改写过 nr_scanned → 整个原生隔离逻辑被跳过 */
```

厂商可以整体替换"从 LRU 摘哪些页"这一步（例如实现自己的选页策略）。

- `android_vh_del_page_from_lrulist`（2465）：每摘一页通知一次，供厂商记账。

**(c) 降级路径（active→inactive）的 hook**（`shrink_active_list` 内，2842-2854）：

```c
trace_android_vh_page_should_be_protected(folio, sc->nr_scanned,
    sc->priority, &sc->android_vendor_data1, &should_protect);
if (unlikely(should_protect)) {
    nr_rotated += folio_nr_pages(folio);
    list_add(&folio->lru, &l_active);      /* 留在活跃链，不降级 */
    continue;
}
trace_android_vh_page_referenced_check_bypass(folio, nr_to_scan, lru, &bypass);
if (bypass)
    goto skip_folio_referenced;            /* 跳过引用检查，强制降级 */
```

两个方向都能调：**保护**指定活跃文件页不被降级（进入被逐出候选池），或**强制**降级（跳过引用检查）。

**(d) MGLRU 扫描中止 hook**：`android_vh_mglru_should_abort_scan` / `..._order`（5571/5580）——厂商可越过"已达回收目标/水位已够"的原生中止条件，强制继续或提前中止一次扫描。

### 2.3 阶段③ 单页状态机：hook 最密集的环节（8 处）

按六道关口的位置逐个列出（全部在 android15-6.6 `mm/vmscan.c`）：

| 关口 | hook（行号） | 嵌入方式 |
|---|---|---|
| 关口 1/2 之间，dirty 统计前 | `vh_shrink_folio_list`（1835） | 传 `&activate, &keep`——**单页判决的提前出口**，厂商可不经任何原生检查直接判 activate/keep |
| 关口 2 引用检查开头 | `vh_page_should_be_protected` + `vh_check_folio_look_around_ref`（1584/1587） | 在 `folio_check_references()` 最前面，返回值直接当判决用（可伪造任意 `FOLIOREF_*`） |
| 关口 2 内 | `vh_folio_trylock_set` / `vh_get_folio_trylock_result`（1591/1595） | 包裹 `folio_referenced()`；**trylock 失败 → 提前返回 KEEP**（主线靠 `referenced_ptes == -1` 分支，Android 加了显式的失败通道） |
| 关口 4 unmap 前 | `vh_folio_trylock_set`（2062） | 标记"此页进入 unmap 回收流程"，供厂商侧跟踪 |
| 匿名/shmem 大 folio 拆分 | `vh_should_split_folio_to_list` / `vh_split_large_folio_bypass`（1982/1994） | 控制大 folio 是否拆分（shmem 大 folio 是文件页流程的一部分） |
| 关口 6 摘除瞬间 | `vh_keep_reclaimed_folio` / `vh_clear_reclaimed_folio`（1502/1507） | **最终否决点**，见下 |
| 状态机返回后 | `vh_handle_trylock_failed_folio`（2715） | `shrink_folio_list` 返回后、`move_folios_to_lru` 前对 trylock 失败页做批处理 |
| MGLRU 同位置 | `vh_evict_folios_bypass`（5411） | 逐出后处理循环里跳过指定 folio 的放回逻辑 |

**最终否决点**值得单独看——它嵌在阶段④的 `__remove_mapping` 里（1502-1508）：

```c
if (reclaimed && folio_is_file_lru(folio) &&
    !mapping_exiting(mapping) && !dax_mapping(mapping)) {
    bool keep = false;
    trace_android_vh_keep_reclaimed_folio(folio, refcount, &keep);
    if (keep)
        goto cannot_free;               /* 最后一刻放回：页已被选中、已 unmap、
                                            已过脏页检查，仍可被 veto */
    shadow = workingset_eviction(folio, target_memcg);
}
trace_android_vh_clear_reclaimed_folio(folio, reclaimed);
__filemap_remove_folio(folio, shadow);
```

整条流程走完、page cache 即将摘除的瞬间，厂商仍能把一个文件页救回来（`goto cannot_free`）。配合关口 2 的 `page_should_be_protected`，Android 给 OEM 提供了"**指定文件页绝不回收**"的完整能力（典型用途：保护前台应用正在用的资源）。

### 2.4 关口 5（脏文件页）的系统级调控

脏文件页本身的主线机制（标记等待、flusher 洗白）Android 没改，改的是**上游的洗白速度**——让脏文件页更快变成关口 6 那种"零成本干净页"（`init.rc:1092-1095`，低内存设备）：

```
on boot && property:ro.config.low_ram=true
    write /proc/sys/vm/dirty_expire_centisecs 200     # 默认 3000（30s）→ 2s
    write /proc/sys/vm/dirty_background_ratio  5      # 默认 10 → 5
```

配合 2.1(c) 的 `may_writepage` vendor 控制（可整体关掉 pageout 兜底），构成 Android 对"脏文件页"这一流程分支的完整态度：**不阻塞回收路径，靠加快后台洗白保证候选池里干净页的比例**。

### 2.5 阶段⑤ 反馈回路：基本不动，shmem 例外

- `mm/workingset.c` 与主线同源（diff 仅版本基线差异）——refault distance 机制、shadow 编码、`WORKINGSET_*` 统计原样保留；
- 唯一与文件页流程相关的系统性差异在 **shmem**：shmem 文件页逐出走 swap，而 Android 的 swap 恒为 zram（`gki_defconfig:331` `CONFIG_ZRAM=m`），设备侧 `swappiness 60`（`init.gs101.rc:754`）。即"shmem 脏页写出去"这一步从闪存 I/O 变成了内存压缩——`pageout()` 对 shmem 的写出口在 Android 上天然指向 zram。

---

## 3. 对照总表

| 流程阶段 | 主线行为 | Android 改动 | 源码锚点 |
|---|---|---|---|
| ① 扫描平衡 | cache_trim_mode 硬编码偏向文件页；file_is_tiny 防抖逃生 | `set_balance_anon_file_reclaim` 可关偏向；`tune_swappiness`；`modify_scan_control` 可置 file_is_tiny/关 may_writepage；`watermark_boost_factor=0` | android15-6.6:3229/3198/7062；init.rc:439 |
| ② 选取隔离 | 传统双链 / MGLRU 世代+tier | MGLRU 默认启用+远程灰度；`mm_isolate_priv_lru` 可整体接管；降级路径可保护/强制降级（`page_should_be_protected`/`page_referenced_check_bypass`）；`mglru_should_abort_scan` | gki_defconfig:134；vmscan:2412/2843/2852/5571 |
| ③-1 引用检查 | folio_check_references 四种判决 | hook 可伪造任意判决；trylock 失败显式 KEEP；可整体跳过（bypass） | vmscan:1584/1587/1591/2852 |
| ③-2 writeback/脏页 | 脏文件页绝不主动写（标记等 flusher）；pageout 仅 shmem/anon | 机制不变；上游加快洗白（dirty_expire 2s、bg_ratio 5）；may_writepage 可关 | vmscan:1370(main)/2084(6.6)；init.rc:1093 |
| ③-3 unmap | try_to_unmap，失败 activate | trylock_set/clear 标记跟踪；失败页批处理 hook | vmscan:2062/2715 |
| ④ 摘除释放 | __remove_mapping 留 shadow → free | `keep_reclaimed_folio` 最终否决点（cannot_free）；`clear_reclaimed_folio` 通知 | vmscan:1502/1507 |
| ⑤ 反馈回路 | refault distance → activate + 成本回流 | 代码同源不动；shmem 逐出指向 zram（swappiness 60） | workingset.c 全文；init.gs101.rc:754 |

---

## 4. 结论

**主线 Linux 的文件页回收是一个"成本感知的缓存裁剪器"**：五阶段闭环里，判断热（引用检查、tier）、标记脏（PG_reclaim 等 flusher）、留证据（shadow）、听回声（refault distance 修正扫描平衡），脏文件页绝不主动写盘。

**Android 的改动全部嵌在流程节点上，而不是替换流程**，可归为三类：

1. **改决策**（阶段①）：cache_trim 偏向变可选（zram 下换 anon 可能更便宜）、swappiness/scan_control 可被厂商逐次回收动态覆盖、关 watermark boost；
2. **开插口**（阶段②③）：从"整条 LRU 隔离逻辑可接管"到"单页判决可伪造"、"page cache 摘除最后一刻可否决"——OEM 能精确控制**哪些文件页**被回收，这是主线完全不具备的能力；
3. **保底不变**（阶段④⑤）：shadow/refault 反馈回路原样保留（同源代码），文件页回收质量的根基仍由主线算法保证。

一句话：**Android 拿走了主线的文件页回收流水线，在"选多少"（阶段①）和"选哪个"（阶段②③）两处装上了厂商旋钮，而"怎么判断冷热、怎么防抖"的底层机制（workingset/MGLRU tier）原封不动。**
