一、总体定位
shrink_node 是 Linux 内存回收的核心枢纽——kswapd 后台回收和 direct reclaim 直接回收两条路径在此汇合。无论内存回收从哪条路径触发,最终都要经过 shrink_node 完成实际的页面回收工作。
┌─────────────────────┐
│ shrink_node() │ ← 核心枢纽
│ (节点级回收调度) │
└──────────┬──────────┘
│
┌────────────────┼────────────────┐
▼ ▼ ▼
shrink_node_memcgs() vmpressure() should_continue_reclaim()
│
┌─────────┴─────────┐
▼ ▼
shrink_lruvec() shrink_slab()
│
├─ get_scan_count() ← 计算扫描比例
├─ shrink_list()
│ ├─ shrink_active_list() ← 活跃→不活跃降级
│ └─ shrink_inactive_list() ← 不活跃页回收
└─ shrink_active_list() (尾部) ← 匿名页老化
1.1 调用者(上游)
1.2 函数签名
// mm/vmscan.c:3174
static void shrink_node(pg_data_t *pgdat, struct scan_control *sc)
参数:
pgdat: 目标 NUMA 节点sc: 回收控制结构(包含优先级、目标回收数、GFP 掩码等)
二、执行流程详解
2.1 完整流程图
shrink_node(pgdat, sc)
│
├─ ① 初始化阶段
│ ├─ target_lruvec = mem_cgroup_lruvec(sc->target_mem_cgroup, pgdat)
│ ├─ mem_cgroup_flush_stats() ← 刷新 memcg 统计,确保读到准确数据
│ ├─ memset(&sc->nr, 0, ...) ← 清零本轮扫描统计
│ └─ 读取 nr_reclaimed / nr_scanned 快照
│
├─ ② 获取回收成本
│ ├─ spin_lock_irq(&target_lruvec->lru_lock)
│ ├─ sc->anon_cost = target_lruvec->anon_cost ← 匿名页回收成本
│ ├─ sc->file_cost = target_lruvec->file_cost ← 文件页回收成本
│ └─ spin_unlock_irq()
│
├─ ③ 降级决策(may_deactivate)
│ ├─ force_deactivate == true?
│ │ └─ 是 → may_deactivate = DEACTIVATE_ANON | DEACTIVATE_FILE(强制降级)
│ └─ 否 → 分别判断 anon 和 file
│ ├─ 匿名页: refaults 变化 || inactive_is_low() → 允许降级
│ └─ 文件页: refaults 变化 || inactive_is_low() → 允许降级
│
├─ ④ cache_trim_mode 判断
│ ├─ file >> sc->priority && 无需降级文件页?
│ │ └─ 是 → cache_trim_mode = 1(优先回收文件缓存)
│ └─ 否 → cache_trim_mode = 0
│
├─ ⑤ file_is_tiny 保护(仅非 cgroup 回收)
│ ├─ 计算 free + file 总量 vs total_high_wmark
│ └─ file + free <= high_wmark && 无需降级 anon && anon 充足?
│ └─ 是 → file_is_tiny = 1(保护文件页,强制扫描匿名页)
│
├─ ⑥ 核心回收 ★
│ └─ shrink_node_memcgs(pgdat, sc)
│ └─ 遍历每个 memcg → shrink_lruvec() + shrink_slab()
│
├─ ⑦ 累加 slab 回收量
│ └─ sc->nr_reclaimed += reclaim_state->reclaimed_slab
│
├─ ⑧ vmpressure 评估
│ └─ vmpressure(gfp_mask, target_mem_cgroup, ...)
│
├─ ⑨ kswapd 专属:IO 拥塞处理
│ ├─ 全部 taken 页都在 writeback? → set PGDAT_WRITEBACK
│ ├─ 全部 taken 页都是 unqueued_dirty? → set PGDAT_DIRTY
│ └─ 有 immediate 页? → congestion_wait(100ms)
│
├─ ⑩ 拥塞标记(kswapd + cgroup)
│ └─ dirty 页全是 congested? → set LRUVEC_CONGESTED
│
├─ ⑪ direct reclaim 拥塞等待
│ └─ 非 kswapd && LRUVEC_CONGESTED? → wait_iff_congested(100ms)
│
├─ ⑫ 判断是否继续回收
│ └─ should_continue_reclaim() == true? → goto again
│
└─ ⑬ 清理
└─ reclaimable? → pgdat->kswapd_failures = 0(重置失败计数器)
2.2 关键阶段详解
阶段③:降级决策(may_deactivate)
这是 shrink_node 最关键的决策之一——决定是否将活跃链表页面降级到不活跃链表。
两个判断条件:
refaults 变化检测:
WORKINGSET_ACTIVATE_ANON/FILE计数器变化意味着有新的 workingset 正在建立,说明之前的降级决策可能有误,需要更激进地降级旧页面。inactive_is_low(): 检查 inactive:active 比例是否过低。
// mm/vmscan.c:2662
static bool inactive_is_low(struct lruvec *lruvec, enum lru_list inactive_lru)
{
unsigned long inactive, active;
unsigned long inactive_ratio;
unsigned long gb;
inactive = lruvec_page_state(lruvec, NR_LRU_BASE + inactive_lru);
active = lruvec_page_state(lruvec, NR_LRU_BASE + active_lru);
gb = (inactive + active) >> (30 - PAGE_SHIFT); // 总量(GB)
if (gb)
inactive_ratio = int_sqrt(10 * gb); // 比例随总量增长
else
inactive_ratio = 1;
return inactive * inactive_ratio < active; // inactive 太少?
}
inactive_ratio 的设计思想:
内存越大,inactive 比例可以越低(因为绝对数量已经够用)
1GB → ratio=3, 10GB→ratio=10, 100GB→ratio=31, 1TB→ratio=100
这避免了大内存系统频繁做 active→inactive 降级
阶段④⑤:cache_trim_mode 与 file_is_tiny
这两个标志控制 anon/file 扫描比例:
"缓存陷阱"防护: file_is_tiny 防止一个正反馈循环——文件缓存被大量回收 → 文件页变少 → 每次文件 fault 都触发 refault → 扫描比例偏向文件 → 文件页更少 → 恶性循环。file_is_tiny 在文件页极少时强制扫描匿名页,打破这个循环。
阶段⑨⑩⑪:IO 拥塞三级处理
kswapd direct reclaim
│ │
┌───────────┴───────────┐ │
▼ ▼ ▼
PGDAT_WRITEBACK PGDAT_DIRTY LRUVEC_CONGESTED
(全部页在回写) (全部是脏页) (脏页全拥塞)
│ │ │
└───── congestion_wait(100ms) ───────────┘
PGDAT_WRITEBACK: 所有隔离页都在回写中,说明 IO 跟不上回收速度
PGDAT_DIRTY: 所有隔离页都是未排队的脏页,唤醒 flusher 线程
LRUVEC_CONGESTED: 脏页的 BDI 全部拥塞,direct reclaim 等待 IO 完成
三、核心子函数分析
3.1 shrink_node_memcgs — memcg 遍历回收
// mm/vmscan.c:3112
static void shrink_node_memcgs(pg_data_t *pgdat, struct scan_control *sc)
职责: 遍历节点上的所有 memcg(内存控制组),对每个 memcg 执行 LRU 回收和 slab 回收。
shrink_node_memcgs(pgdat, sc)
│
├─ memcg = mem_cgroup_iter(target_memcg, NULL, NULL) ← 获取首个 memcg
│
└─ do {
├─ cond_resched() ← 避免 soft lockup
├─ mem_cgroup_calculate_protection() ← 计算保护阈值
│
├─ mem_cgroup_below_min(memcg)? ← 硬保护:跳过
│ └─ continue
├─ mem_cgroup_below_low(memcg)? ← 软保护:首次跳过
│ └─ sc->memcg_low_skipped = 1; continue
│
├─ shrink_lruvec(lruvec, sc) ← LRU 页面回收
├─ shrink_slab(gfp_mask, nid, memcg, priority) ← Slab 回收
└─ vmpressure() ← 压力评估
} while ((memcg = mem_cgroup_iter(...))) ← 遍历下一个 memcg
memcg 保护机制:
3.2 shrink_lruvec — LRU 扫描入口
// mm/vmscan.c:2914
static void shrink_lruvec(struct lruvec *lruvec, struct scan_control *sc)
职责: 对一个 lruvec(LRU 向量,包含 anon inactive/active 和 file inactive/active 五条链表)执行扫描和回收。
shrink_lruvec(lruvec, sc)
│
├─ get_scan_count(lruvec, sc, nr) ← 计算每条链表扫描量
├─ memcpy(targets, nr, ...) ← 保存原始目标(用于比例调整)
│
├─ scan_adjusted 判断:
│ └─ 非 cgroup && 非 kswapd && priority == DEF_PRIORITY?
│ → scan_adjusted = true(全局直接回收,回收够了也继续扫)
│
├─ blk_start_plug(&plug) ← 合并 IO 请求
│
├─ while 循环(扫描 anon inactive、file active、file inactive)
│ ├─ for_each_evictable_lru(lru):
│ │ ├─ nr_to_scan = min(nr[lru], SWAP_CLUSTER_MAX) ← 每次最多32页
│ │ └─ shrink_list(lru, nr_to_scan, lruvec, sc)
│ │ ├─ active? → shrink_active_list() ← 降级到 inactive
│ │ └─ inactive? → shrink_inactive_list() ← 尝试回收
│ │
│ ├─ cond_resched()
│ │
│ └─ 回收够了 && !scan_adjusted?
│ └─ 比例调整:停掉较小的 LRU,按比例调整较大的 LRU
│
├─ blk_finish_plug(&plug) ← 提交合并的 IO
├─ sc->nr_reclaimed += nr_reclaimed
│
└─ 尾部:匿名页老化
└─ can_age_anon_pages() && inactive_is_low(LRU_INACTIVE_ANON)?
└─ shrink_active_list(SWAP_CLUSTER_MAX, ..., LRU_ACTIVE_ANON)
关键设计 — scan_adjusted:
当全局直接回收(非 kswapd、非 cgroup)且优先级为默认值时,即使回收够了也继续扫描
原因:直接回收意味着 kswapd 已经跟不上,应该多做一些工作
对于 kswapd 和 cgroup 回收,回收够了就按比例缩减,避免过度回收
关键设计 — 比例调整: 当一种 LRU(如 file)先被扫完,系统不会继续猛攻另一种(anon),而是:
停掉已扫完的 LRU
按已扫描百分比缩减另一种 LRU 的扫描量
这保证 anon/file 扫描比例大致符合
get_scan_count的设计
3.3 get_scan_count — 扫描比例计算
// mm/vmscan.c:2698
static void get_scan_count(struct lruvec *lruvec, struct scan_control *sc,
unsigned long *nr)
职责: 计算五条 LRU 链表各应该扫描多少页面。这是回收策略的核心决策函数。
scan_balance 枚举(决定 anon/file 扫描比例):
SCAN_FRACT 比例计算:
// 匿名页回收成本(含 swap IO)
anon_cost = total_cost + sc->anon_cost;
// 文件页回收成本(含磁盘 IO)
file_cost = total_cost + sc->file_cost;
// swappiness 调节(0~200,100 为平衡点)
ap = swappiness * (total_cost + 1) / (anon_cost + 1);
fp = (200 - swappiness) * (total_cost + 1) / (file_cost + 1);
设计思想:
anon_cost包含 swap out 的 IO 代价,file_cost包含磁盘读回的代价swappiness=100时 anon/file 等价;swappiness=0时只扫文件(cgroup 场景)全局回收即使
swappiness=0也会扫描匿名页(防止 OOM)至少 1/3 的压力施加给每种 LRU(通过
total_cost的加法保证)
memcg 保护缩放:
// 如果有 memory.low/min 保护
scan = lruvec_size - lruvec_size * protection / (cgroup_size + 1);
scan = max(scan, SWAP_CLUSTER_MAX); // 至少扫 32 页
scan >>= sc->priority; // 优先级缩放
3.4 shrink_list — 分发器
// mm/vmscan.c:2618
static unsigned long shrink_list(enum lru_list lru, unsigned long nr_to_scan,
struct lruvec *lruvec, struct scan_control *sc)
职责: 根据 LRU 类型分发到不同的处理函数。
shrink_list(lru, nr_to_scan, lruvec, sc)
│
├─ is_active_lru(lru)?
│ ├─ may_deactivate 允许? → shrink_active_list() ← 降级,返回 0(不回收)
│ └─ 不允许? → sc->skipped_deactivate = 1, 返回 0
│
└─ inactive? → shrink_inactive_list() ← 实际回收,返回回收数
关键点: shrink_active_list 永远返回 0——它只做降级(active→inactive),不做回收。真正的回收由 shrink_inactive_list 完成。
3.5 shrink_active_list — 活跃链表降级
// mm/vmscan.c:2445
static void shrink_active_list(unsigned long nr_to_scan,
struct lruvec *lruvec,
struct scan_control *sc,
enum lru_list lru)
职责: 从活跃链表取出页面,检查访问情况,将未访问的页面降级到不活跃链表。
shrink_active_list(nr_to_scan, lruvec, sc, lru)
│
├─ ① 准备
│ ├─ lru_add_drain() ← 将 per-cpu pagevec 刷入 LRU
│ └─ 定义三个临时链表: l_hold, l_active, l_inactive
│
├─ ② 隔离(持锁)
│ ├─ spin_lock_irq(&lruvec->lru_lock)
│ ├─ isolate_lru_pages() ← 从活跃链表尾部取出页面到 l_hold
│ └─ spin_unlock_irq()
│
├─ ③ 逐页判断(无锁)
│ └─ while (!list_empty(&l_hold)):
│ ├─ page_evictable(page)? → 否: putback_lru_page()
│ ├─ buffer_heads_over_limit? → try_to_release_page() 释放 buffer head
│ │
│ ├─ page_referenced(page)? ← 检查最近是否有 PTE 引用
│ │ ├─ 是 && VM_EXEC && file? → list_add(l_active) ← 代码页,保活
│ │ └─ 否 → 继续降级
│ │
│ ├─ ClearPageActive(page) ← 清除活跃标志
│ ├─ SetPageWorkingset(page) ← 标记为 workingset
│ └─ list_add(l_inactive) ← 放入不活跃链表
│
├─ ④ 回写(持锁)
│ ├─ spin_lock_irq(&lruvec->lru_lock)
│ ├─ move_pages_to_lru(lruvec, &l_active) ← 保活页→活跃链表
│ ├─ move_pages_to_lru(lruvec, &l_inactive) ← 降级页→不活跃链表
│ └─ spin_unlock_irq()
│
└─ ⑤ 清理
├─ free_unref_page_list() ← 释放已成为空闲的页面
└─ 统计 PGDEACTIVATE 事件
关键设计 — 代码页保护:
if ((vm_flags & VM_EXEC) && page_is_file_lru(page)) {
nr_rotated += thp_nr_pages(page);
list_add(&page->lru, &l_active); // 放回活跃链表
continue;
}
可执行文件的代码页(
VM_EXEC)即使最近被引用过一次也保活这保证程序代码在中等内存压力下不会被回收
匿名页不享受此待遇(JVM 会创建大量
VM_EXEC匿名页)
3.6 shrink_inactive_list — 不活跃链表回收
// mm/vmscan.c:2320
static unsigned long
shrink_inactive_list(unsigned long nr_to_scan, struct lruvec *lruvec,
struct scan_control *sc, enum lru_list lru)
职责: 从不活跃链表取出页面,调用 shrink_page_list 尝试回收。
shrink_inactive_list(nr_to_scan, lruvec, sc, lru)
│
├─ ① 防抖保护
│ └─ too_many_isolated()? → msleep(100) ← 太多进程在回收,避免 OOM
│
├─ ② 隔离(持锁)
│ ├─ lru_add_drain()
│ ├─ spin_lock_irq()
│ ├─ isolate_lru_pages() ← 从不活跃链表尾部取出页面
│ └─ spin_unlock_irq()
│
├─ ③ 回收(无锁)★
│ └─ shrink_page_list(&page_list, pgdat, sc, &stat, false)
│ ├─ 干净页 → 直接释放
│ ├─ 脏页 → pageout() 写回
│ ├─ 映射页 → unmap + pageout
│ └─ 匿名页 → swap out
│
├─ ④ 回写(持锁)
│ ├─ spin_lock_irq()
│ ├─ move_pages_to_lru() ← 未回收的页面放回不活跃链表头部
│ └─ spin_unlock_irq()
│
├─ ⑤ 统计
│ ├─ PGSCAN_KSWAPD / PGSCAN_DIRECT ← 扫描量
│ ├─ PGSTEAL_KSWAPD / PGSTEAL_DIRECT ← 回收量
│ └─ PGSCAN_ANON / PGSCAN_FILE ← 按类型统计
│
├─ ⑥ 脏页唤醒
│ └─ 全部隔离页都是未排队脏页? → wakeup_flusher_threads()
│
└─ ⑦ 累加拥塞统计到 sc->nr
关键设计 — too_many_isolated 防抖:
while (unlikely(too_many_isolated(pgdat, file, sc))) {
msleep(100); // 等待其他回收进程完成
if (fatal_signal_pending(current))
return SWAP_CLUSTER_MAX; // 进程即将死亡,返回让 OOM 处理
}
防止大量进程同时做 direct reclaim 导致内存抖动
最多等一次 100ms,避免死锁
3.7 shrink_slab — Slab 回收
// mm/vmscan.c:908
static unsigned long shrink_slab(gfp_t gfp_mask, int nid,
struct mem_cgroup *memcg, int priority)
职责: 回收内核 slab 缓存(dentry cache、inode cache 等)。
shrink_slab(gfp_mask, nid, memcg, priority)
│
├─ memcg 非 root? → shrink_slab_memcg() ← 走 memcg 专属路径
│
├─ down_read_trylock(&shrinker_rwsem) ← 获取 shrinker 读锁
│ └─ 失败? → goto out(不等待,避免阻塞)
│
├─ list_for_each_entry(shrinker, &shrinker_list):
│ ├─ do_shrink_slab(&sc, shrinker, priority)
│ │ └─ shrinker->scan_objects() × (1 << priority) 次
│ ├─ freed += ret
│ └─ rwsem_is_contended()? → break ← 有新 shrinker 注册,让步
│
└─ up_read(&shrinker_rwsem)
shrinker 机制:
各子系统(VFS、inode、dentry 等)通过
register_shrinker()注册回收回调do_shrink_slab调用shrinker->scan_objects()回收对象down_read_trylock而非down_read:回收路径不能阻塞等待注册rwsem_is_contended检查:如果有进程在等待注册 shrinker,尽早让步
四、关键数据结构
4.1 struct reclaim_stat — 回收统计
// mm/vmscan.c (shrink_page_list 的输出)
struct reclaim_stat {
unsigned nr_dirty; // 隔离出的脏页数
unsigned nr_unqueued_dirty; // 未排队的脏页数
unsigned nr_congested; // 拥塞 BDI 的脏页数
unsigned nr_writeback; // 正在回写的页数
unsigned nr_immediate; // 需要立即回收的页数
unsigned nr_taken; // 隔离的总页数
unsigned nr_file_taken; // 隔离的文件页数
unsigned nr_pageout; // 执行 pageout 的页数
};
这些统计从 shrink_inactive_list 累加到 sc->nr,最终在 shrink_node 中用于 IO 拥塞判断。
4.2 scan_balance 枚举
// mm/vmscan.c:2680
enum scan_balance {
SCAN_EQUAL, // 均等扫描(OOM 边界)
SCAN_FRACT, // 按成本比例扫描(默认)
SCAN_ANON, // 只扫描匿名页(file_is_tiny)
SCAN_FILE, // 只扫描文件页(无 swap / cache_trim_mode)
};
4.3 DEACTIVATE 标志
#define DEACTIVATE_ANON (1 << LRU_INACTIVE_ANON) // = 1
#define DEACTIVATE_FILE (1 << LRU_INACTIVE_FILE) // = 4
五、核心设计思想
5.1 分层回收架构
shrink_node ← 节点级:IO 拥塞控制、vmpressure、继续回收判断
└─ shrink_node_memcgs ← memcg 级:保护阈值、遍历 cgroup
└─ shrink_lruvec ← LRU 级:扫描比例、比例调整
└─ shrink_list ← 链表级:active/inactive 分发
├─ shrink_active_list ← 页面级:访问检测、降级
└─ shrink_inactive_list ← 页面级:回收决策
└─ shrink_page_list ← 单页级:unmap、writeback、free
每一层都有自己的策略和保护机制,上层控制"扫多少",下层决定"怎么回收"。
5.2 两阶段回收
shrink_active_list: 不回收页面,只做降级(active→inactive)
shrink_inactive_list: 实际回收页面
这保证了"二次机会"——被降级的页面如果在不活跃链表中再次被访问,会被重新激活,而不是直接被回收。
5.3 anon/file 平衡
系统通过三个机制维持 anon/file 扫描平衡:
get_scan_count: 根据 swappiness 和回收成本计算初始比例
shrink_lruvec 比例调整: 回收够了之后按比例缩减
cache_trim_mode / file_is_tiny: 极端情况下的保护
5.4 拥塞感知
shrink_node 是回收路径中唯一做 IO 拥塞处理的函数:
kswapd: 检测 PGDAT_WRITEBACK/PGDAT_DIRTY,主动 congestion_wait
direct reclaim: 检测 LRUVEC_CONGESTED,wait_iff_congested
这避免了回收速度超过 IO 带宽时的无效工作
5.5 继续回收判断(should_continue_reclaim)
// mm/vmscan.c:3059
static inline bool should_continue_reclaim(pgdat, nr_reclaimed, sc)
判断条件:
在 reclaim/compaction 模式
本轮有回收成果(nr_reclaimed > 0)
所有 zone 都不适合直接压缩(
COMPACT_PARTIAL)不活跃页总量 > 压缩所需空闲页
这确保在"回收+压缩"联合模式下,回收为压缩提供足够的空闲页面。
六、关键函数调用表
七、流程图描述
7.1 shrink_node 总体流程
┌────────────────────────────────────────────────────────────────────┐
│ shrink_node() │
│ │
│ ┌──────────┐ ┌──────────────┐ ┌─────────────────────────────┐ │
│ │ 初始化 │→│ 降级决策 │→│ shrink_node_memcgs() │ │
│ │ (①②) │ │ (③④⑤) │ │ ┌───────────────────────┐ │ │
│ └──────────┘ └──────────────┘ │ │ 遍历 memcg │ │ │
│ │ │ ┌───────────────────┐ │ │ │
│ │ │ │ shrink_lruvec() │ │ │ │
│ │ │ │ ┌─────────────┐ │ │ │ │
│ │ │ │ │get_scan_count│ │ │ │ │
│ │ │ │ └──────┬──────┘ │ │ │ │
│ │ │ │ ▼ │ │ │ │
│ │ │ │ ┌─────────────┐ │ │ │ │
│ │ │ │ │ shrink_list │ │ │ │ │
│ │ │ │ └──┬──────┬──┘ │ │ │ │
│ │ │ │ ▼ ▼ │ │ │ │
│ │ │ │ active inactive │ │ │ │
│ │ │ │ (降级) (回收) │ │ │ │
│ │ │ └───────────────────┘ │ │ │
│ │ │ shrink_slab() │ │ │
│ │ └───────────────────────┘ │ │
│ └─────────────────────────────┘ │
│ ┌─────────────────────────────┐ │
│ │ IO 拥塞处理 + 继续回收判断 │ │
│ │ (⑨⑩⑪⑫) │ │
│ └─────────────────────────────┘ │
└────────────────────────────────────────────────────────────────────┘
八、与上游的衔接
8.1 kswapd 路径
balance_pgdat()
└─ kswapd_shrink_node()
├─ sc->nr_to_reclaim = Σ max(high_wmark_pages, SWAP_CLUSTER_MAX)
└─ shrink_node(pgdat, sc) ← 本报告分析的函数
8.2 direct reclaim 路径
try_to_free_pages()
└─ do_try_to_free_pages()
└─ shrink_zones()
└─ shrink_node(pgdat, sc) ← 同一个函数
8.3 node_reclaim 路径
__node_reclaim()
└─ shrink_node(pgdat, sc) ← 同一个函数
九、后续分析路线
当前位置: shrink_node ✅
│
▼
┌─────────────────────────────────────────────────────────┐
│ 下一步: shrink_page_list() — 单页回收决策(核心) │
│ │
│ 这是真正决定"一个页面能不能回收"的函数: │
│ - 干净页 → 直接释放 │
│ - 脏页 → pageout() 写回 │
│ - 映射页 → try_to_unmap() 解除映射 │
│ - 匿名页 → add_to_swap() + pageout() swap out │
│ - 正在回写 → 等待 / 立即回收 │
│ - 锁定页 → 跳过 │
│ │
│ 文件: mm/vmscan.c:1394-1633 │