Kernel Source Analysis

文件页回收流程对比:主线 Linux vs Android

逐阶段源码对比 · 基线:torvalds/master 与 android15-6.6 GKI · 聚焦回收流程:扫描平衡 → LRU 隔离 → shrink_folio_list 状态机 → shadow 摘除 → refault 反馈

文件页回收流程对比:主线 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 越小越被扫、越扫越小"正反馈失控:
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)逐页分诊:
/* 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 逐行展开)做反向映射查引用:

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)——文件页回收的关键分叉

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)注释明说:

/*
 * 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 + 逐出时钟 + 访问热度:

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):

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):

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()):一次性覆盖三个扫描控制量——

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:439start 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-135CONFIG_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() 最顶端,且是"接管式"的:
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)的 hookshrink_active_list 内,2842-2854):

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 扫描中止 hookandroid_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):

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 60init.gs101.rc:754)。即"shmem 脏页写出去"这一步从闪存 I/O 变成了内存压缩——pageout() 对 shmem 的写出口在 Android 上天然指向 zram。

3. 对照总表

流程阶段 主线行为 Android 改动 源码锚点
① 扫描平衡 cache_trim_mode 硬编码偏向文件页;file_is_tiny 防抖逃生 set_balance_anon_file_reclaim 可关偏向;tune_swappinessmodify_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)原封不动。