Linux内核分析之内存管理-06
This language version is unavailable; showing the other language.
24.1 LRU 链表与页面老化
回收的本质是预测:哪一页未来最不可能被访问?内核的回答是把所有可回收页组织成按访问时间排序的链表,从最老端摘取。7.0 提供两套实现:传统 active/inactive 二链(64 位下仍可选用)与默认的 MGLRU(multi-gen LRU)——用"世代"把"多久没用"量化成序号,避免二链在巨型内存上的全表扫描。本节拆解两者共享的容器(lruvec)、各自的排序算法,以及跨回收周期的记忆机制(workingset)。
24.1.1 容器:lruvec 与五条链
// include/linux/mmzone.h:317-322
LRU_INACTIVE_ANON = LRU_BASE,
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE,
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE,
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE,
LRU_UNEVICTABLE,
NR_LRU_LISTS
// include/linux/mmzone.h:669-695(节选)
struct lruvec {
struct list_head lists[NR_LRU_LISTS]; /* 五条链 */
spinlock_t lru_lock; /* 每 lruvec 一把锁 */
/*
* 回收 file 与 anon 各自的代价账:
* 一侧代价升高 → 扫描配比偏向另一侧
*/
unsigned long anon_cost;
unsigned long file_cost;
/* Non-resident age, driven by LRU movement */
atomic_long_t nonresident_age; /* 换出侧时钟 */
/* Refaults at the time of last reclaim cycle */
unsigned long refaults[ANON_AND_FILE];
unsigned long flags;
#ifdef CONFIG_LRU_GEN
/* evictable pages divided into generations */
struct lru_gen_folio lrugen; /* MGLRU 滑窗 */
...
#endif
#ifdef CONFIG_MEMCG
struct pglist_data *pgdat;
#endif
};
lruvec 是"回收域"的容器:memcg 启用时每 (memcg × node) 一个,否则每 node 一个(pgdat 的 __lruvec 字段,18.2.4 节)——这正是 cgroup 内存记账(12.4 节)与回收共用一套结构的原因。anon/file × active/inactive 的二维分类由 folio_lru_list()(mm_inline.h:87)按页类型一次判定;LRU_UNEVICTABLE 是 mlock 等不可回收页的收容所(18.2.3 节 PG_unevictable)。
24.1.2 传统二链:active/inactive 的双时钟
传统方案用"两段链表 + 访问位"近似 LRU:
访问位(pg->referenced / PTE A 位)
inactive ──被访问──> active (晋升) active ──回收压力──> active 尾降级
^ │
└────────────────── 从尾部回收 ◄──────────────────────────────┘
页进出流水线:
新页 → inactive 头 (file 页在首次 readahead 时预判, mm_inline.h:343)
active 头部摘取降级 (shrink_active_list, vmscan.c:2118 的 isolate)
inactive 尾部回收 (shrink_inactive_list, vmscan.c:1974)
逃逸循环: 回收时发现"还被引用" → 置 referenced, 放回, 下轮晋升
缺陷在大规模内存上显现:判断一页"多久没用"依赖页在链上的位置流动,而这需要 O(内存) 次链表搬运;访问位是"有没有"而非"几次",扫过一遍后信息即耗尽——巨型内存机器上二链退化成"上轮回收的页这轮被冤枉回收"。
二链的"信息耗尽"缺陷图解 (1TB 内存的机器):
回收轮次 1: 扫过所有页, A 位全清 → "全都一样冷"
回收轮次 2: 访问过的页 A 位置 1 → 只有"1 轮内碰过"的信息
第 3 轮: 又全清 → 老页 vs 新页不可区分
→ 回收决策退化成"猜" — 链表搬运却是 O(全内存)
这就是 MGLRU 诞生的动机
24.1.3 MGLRU:世代滑窗
// include/linux/mmzone.h:374-399(注释要点)
* They form a sliding window of a variable size [MIN_NR_GENS, MAX_NR_GENS]. An
* offset within MAX_NR_GENS, i.e., gen, indexes the LRU list of the ...
* MAX_NR_GENS is set to 4 so that the multi-gen LRU can support twice the
* accesses through page tables. This requires order_base_2(MAX_NR_GENS+1) bits
#define MAX_NR_GENS 4U /* :399 */
MGLRU 把"访问频率"编码成世代号(folio->flags 的 gen 位段,LRU_GEN_MASK,mmzone.h:425):新页进最年轻代;每次被页表访问位证实访问过就向年轻方向移动一代;最老代尾部即回收候选。世代推进由 try_to_inc_max_seq()(vmscan.c:4021-4064)驱动——它调用 walk_mm()(:3775)遍历进程页表批量清访问位并按"这轮是否命中"给页分代:
MGLRU 的滑窗与分代:
gen 0(最老) gen 1 gen 2 gen 3(最新)
┌──────────┬───────────┬───────────┬──────────┐
│ 回收候选 │ 一次访问 │ 二次访问 │ 新页 │
└────┬─────┴───────────┴───────────┴──────────┘
│ try_to_inc_max_seq: walk_mm 扫页表
│ 命中的页 → 年轻一代; 全体 gen++
v
世代推进 = "把时钟整体拨一格", 页不动或只小幅移动
(对比二链: 每页都要物理搬运链表节点)
walk_mm 的成本 = O(页表项数) 而非 O(页数)
lru_gen_look_around (:4201) 在缺页时顺手看邻居
(PTE 表 512 项批量分代 —— 空间局部性免费利用)
关键洞察:把"扫链表"换成"扫页表"。页表本身就是"哪些页最近被用过"的硬件索引(访问位由 CPU 置位),扫描它天然按访问热度聚类,lru_gen_look_around() 更是在每次缺页时把同 PTE 表的邻居一起分代——热点页成批晋升,冷页成批下沉。
一次 walk_mm 的分代判定 (伪流程):
for 每个 PTE in 进程页表:
if pte 未映射: 跳过
young = pte accessed 位?
(x86: A 位; arm64: AF; riscv: A — 19 章三架构的
"硬件置位"特性在此变现)
清 accessed 位 (下轮重新计龄)
young → 该页 folio 移入年轻一代
!young → 该页留在老代 (下轮回收候选)
扫完全部 mm → max_seq++ (滑窗整体拨一格)
24.1.4 扫描入口的分岔
// mm/vmscan.c:5760 与 5017(两个 CONFIG 分支的 lru_gen_shrink_lruvec)
static void lru_gen_shrink_lruvec(struct lruvec *lruvec, struct scan_control *sc)
{ ... /* MGLRU 路径: 按 max_seq 滑窗, gen 前沿逐代回收 */ }
// mm/vmscan.c:5772
static void shrink_lruvec(struct lruvec *lruvec, struct scan_control *sc)
{ ... /* 传统路径: get_scan_count 配比 → shrink_list 二链 */ }
上层 shrink_node()(vmscan.c:6039)按 lru_gen_enabled() 分岔到两实现之一。无论哪条,anon/file 配比都由 get_scan_count()(vmscan.c:2527)裁决:输入是 swappiness(默认 60,vmscan.c:201;memcg 可覆盖——sc_swappiness(),:244)与两链的代价账(anon_cost/file_cost,lruvec 注释原文"扫描配比偏向另一侧")、以及 refault 记忆(下节)。产出是两条链各自的扫描配额。
24.1.5 workingset:跨回收周期的记忆
一次回收对页"冷热"的判断只覆盖本次周期;被换出的页如果马上又被要回来(refault),说明回收错了对象——这个反馈必须跨周期记下来:
// mm/workingset.c:355 与 534
void workingset_age_nonresident(struct lruvec *lruvec, unsigned long nr_pages)
{ ... /* nonresident_age: 换出侧的时钟, lruvec:680 */ }
void workingset_refault(struct folio *folio, void *shadow)
{ ... /* 换出时把 lruvec+代号藏进 radix 槽位(shadow),
重读时对比: 活得比本次回收周期久 → refault 判定,
记入 lruvec->refaults[] (lruvec:681), 影响下轮 get_scan_count */ }
实现机制极为巧妙:用页缓存 XArray 槽位里本已存在的 shadow 指针当记忆载体——页被回收后槽位不清理,存入"当时的 nonresident_age"(一个指针大小的整数,靠低位复用)。重读同一文件页时 workingset_refault() 把 shadow 与当前 age 对比:早于回收起点即"这页是冤枉的"。
refault 的判定时序:
t0: 文件页 F 被回收 (影子 shadow=t0 代号存入槽位)
t1: F 被重读 → slot 里挖出 shadow
shadow < 当前回收周期起点?
├─ 是 → F 在缓存里只活了很短 = "刚回收就要" = refault!
│ lruvec->refaults[FILE]++
└─ 否 → 正常老化, 非冤案
下一轮 get_scan_count: refaults[FILE] 高 → 文件配比上调
→ 回收器的"学习回路": 错了会自己改
refault 统计最终反哺 get_scan_count() 的 anon/file 配比——回收器的学习回路。观测面:/sys/kernel/mm/workingset_report(若启用)与 PSI 的 memory full/ some 曲线是 refault 压力的用户可见形态。
小结
回收的候选名单由 lruvec 容器承载(每 memcg×node 一套五链 + MGLRU 世代),传统二链以"位置流动"近似冷热而受困于大规模搬运,MGLRU 以"世代序号 + 页表扫描"把排序成本压到对页表项数的线性、并用 look_around 免费利用空间局部性;workingset 用 XArray 槽位 shadow 跨周期记忆 refault,反哺 anon/file 扫描配比。名单有序之后,谁来按名单执行?下一节看后台执行者 kswapd。
24.2 kswapd 后台回收
kswapd 是每个 NUMA 节点一对的常驻内核线程(18.2.4 节 pgdat 的 kswapd 字段与 kswapd_wait 队列),职责是在水位跌破 low 后从容地把空闲页补到 high。它的价值不在算法(算法与 24.3 节直接回收完全共享),而在时机与身份:早于分配者窘迫、以 PF_MEMALLOC 免疫回收递归。本节沿主循环分析它的唤醒协议、回收主体与睡眠条件。
24.2.1 唤醒:谁、何时、带什么参数
// mm/vmscan.c:7361(签名)
void wakeup_kswapd(struct zone *zone, gfp_t gfp_flags, int order,
int highest_zoneidx)
唤醒者有两类:分配路径的 rmqueue()(18.3.6 节页分配器出口处 ZONE_BOOSTED_WATERMARK 标志的检查,page_alloc.c:3433-3437)与 get_page_from_freelist() 的水位快查失败点——它们置位/检测 ZONE_BELOW_HIGH 时不阻塞,只异步唤醒。唤醒携带两个参数:请求的 order 与最高 zone——kswapd 会记住"这次是为了几阶的分配",决定回收深度(高阶请求需要的不只是页数,还有连续性)。
唤醒时序 (与 18.3.6 节分配路径衔接):
分配者: zone_watermark_fast(low) 失败
├─ 置 ZONE_BELOW_HIGH (page_alloc.c:3915-3924)
├─ 继续本轮分配尝试 (可能成功)
└─ wakeup_kswapd(zone, gfp, order, idx) ← 异步, 不等待
│
另一 CPU: v
kswapd 线程醒来 ──> balance_pgdat() ──> 回收到 high ──> 睡回
"异步"的关键: 分配者不等 kswapd —
若 PCP/buddy 尚有余粮, 分配立刻成功;
kswapd 只是提前备货 — 这是 24.3 节直接回收
不被触发的第一道缓冲
24.2.2 主循环:kswapd()
// mm/vmscan.c:7280-7360(节选)
static int kswapd(void *p)
{
...
tsk->flags |= PF_MEMALLOC | PF_KSWAPD; /* :7296 身份: 免疫回收递归 */
set_freezable();
WRITE_ONCE(pgdat->kswapd_order, 0);
WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);
for ( ; ; ) {
alloc_order = reclaim_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx);
kswapd_try_sleep:
kswapd_try_to_sleep(pgdat, alloc_order, reclaim_order,
highest_zoneidx); /* :7313 */
/* Read the new order and highest_zoneidx */
alloc_order = READ_ONCE(pgdat->kswapd_order);
highest_zoneidx = kswapd_highest_zoneidx(pgdat, highest_zoneidx);
WRITE_ONCE(pgdat->kswapd_order, 0);
WRITE_ONCE(pgdat->kswapd_highest_zoneidx, MAX_NR_ZONES);
...
/*
* Reclaim begins at the requested order but if a high-order
* reclaim fails then kswapd falls back to reclaiming for
* order-0. If that happens, kswapd will consider sleeping
* for the order it finished reclaiming at (reclaim_order)
* but kcompactd is woken to compact for the original
* request (alloc_order).
*/
...
balance_pgdat(pgdat, alloc_order, highest_zoneidx); /* :7347 区 */
}
}
三个身份细节构成 kswapd 的安全性:PF_MEMALLOC 让它在任何分配中走"应急储备"通道(忽略水位),否则"回收自己需要内存→递归回收"成死循环;set_freezable() 让系统休眠(suspend-to-disk)时先冻结它,防止快照过程中后台改写内存;order 双变量(alloc_order vs reclaim_order)实现注释所述的分工——高阶回收失败降级为 order-0 回收后,kcompactd(pgdat 的另一线程,18.2.4 节)被唤醒去做"连续性"(compaction),kswapd 只对"够不够"负责。
kswapd 与 kcompactd 的分工图:
请求: alloc_pages(order=9) 唤醒 kswapd(order=9)
│
kswapd 回收后空闲页够, 但连续 4MB 块没有?
│
┌─────┴─────┐
▼ ▼
reclaim_order alloc_order
(降为 0 继续回收 (原请求交给 kcompactd)
保 page 供给) │
│ v
▼ kcompactd_do_scan:
睡回 迁移 M 页 → 挤出连续块
→ 唤醒等待高阶分配的线程
(两个线程各管一维: 数量 vs 连续性)
24.2.3 睡眠协议:kswapd_try_to_sleep
// mm/vmscan.c:7183(要点)
static void kswapd_try_to_sleep(pg_data_t *pgdat, unsigned int alloc_order,
unsigned int reclaim_order, int highest_zoneidx)
{
...
prepare_to_wait(&pgdat->kswapd_wait, &wait, TASK_INTERRUPTIBLE);
/* 双检查: 水位已够 & 没有排队的新唤醒 → 真睡 */
if (!kswapd_should_wakeup(pgdat, ...))
schedule();
...
finish_wait(...);
}
睡眠的竞态防护是"先挂等待队列、再查唤醒条件、两者都在自旋锁内"的经典模式:如果水位于挂队与检查之间跌破,唤醒者对队列的 wake_up 不会丢失。kswapd_should_wakeup 复查水位(已到 high 则继续睡)与 kswapd_order(有新请求则带着新参数干活)。
睡眠竞态的时序证明:
kswapd: 唤醒者 (分配者):
spin_lock(pgdat->kswapd_lock)
set_current_state(INTERRUPTIBLE)
prepare_to_wait(挂入队列)
水位跌破 low
检查 kswapd_should_wakeup() wakeup_kswapd():
→ 水位低! 不睡 wake_up(kswapd_wait) ← 已在队列,
spin_unlock 不会被丢!
(继续 balance_pgdat)
若顺序反过来 (先查后挂): 查时水位够 → 挂队 →
此刻水位跌破+唤醒 → 唤醒落在"挂队前" → 丢失!
kswapd 多睡一轮 (秒级) — 这就是双检查存在的意义
24.2.4 回收主体:balance_pgdat
// mm/vmscan.c:6950(要点)
static int balance_pgdat(pg_data_t *pgdat, int order, int highest_zoneidx)
{
...
/* 自低 zone 向高 zone 汇总各 zone 缺口, 构造 scan_control:
priority 从 DEF_PRIORITY 递减 (扫描比例 1/512 → 1/1) */
...
do {
...
ret = shrink_node(pgdat, &sc); /* 复用 24.3 节全流水线 */
...
if (zone_watermark_ok(zone, sc.order, high_wmark_pages(zone), ...))
break; /* 到 high 即收工 */ /* :7040 区 */
...
} while (...);
...
}
balance_pgdat() 与直接回收共享 shrink_node() 流水线(24.3.2 节展开)——两者的区别只剩 scan_control 的参数:kswapd 的 gfp_mask 宽松(可睡眠、可写回)、目标水位是 high、priority 逐轮加深。priority 是扫描深度旋钮:从 DEF_PRIORITY(每轮只看 1/512 的候选链)逐级加倍到最深的 1/1——正常压力下轻扫即可达标,重压才升级为全表扫。
priority 与扫描比例的对照:
priority 12 (DEF): 每轮扫 inactive 链的 1/4096
priority 10: 1/1024
priority 8: 1/256
priority 6: 1/64
priority 4: 1/16
priority 1: 1/4 (最接近全扫)
DEF_PRIORITY=12, 每降 1 深度×4 —
正常波动在 12-10 之间就解决; 持续打到 <8
= 系统进入"真缺内存"状态 (观测告警点)
24.2.5 节拍与观测
观测入口:
/proc/vmstat: pgscan_kswapd_*, pgsteal_kswapd_* (kswapd 扫描/回收量)
kswapd_inodesteal, kswapd_skip...
/proc/pagetypeinfo, /proc/zoneinfo: 各 zone 水位现状
ps: kswapd0/1... 的 CPU 占用 — 持续高 = 稳态内存不足
调参联动:
min_free_kbytes → 三水位整体抬升 (18.2.3 节 setup_per_zone_wmarks)
watermark_scale_factor → high-low 间距放大 (降低唤醒频率, 提高批次效率)
numa_balancing / vm.swappiness → 影响回收对象构成
kswapd 的健康判据是"短促工作、快速回睡":唤醒频率高但每次只回收少量页属于正常;kswapd 常驻 CPU 且 pgscan 大于 pgsteal(扫得多收得少)说明扫描在空转——页都在 active/引用态,压力已逼近 24.4 节的 OOM 语义。
kswapd 行为的三阶段诊断:
[1] 健康: 唤醒频繁, 每次 <100ms, pgsteal/pgscan ≈ 0.9
[2] 亚健康: priority 常驻 ≤10, kswapd CPU 5-15%
→ 检查是否有泄漏倾向 / swappiness 配置
[3] 病态: kswapd CPU >30% 且 pgsteal/pgscan < 0.3
→ 扫描空转, 全是 pinned/active 页
→ 逼近 OOM (24.4) 或需加内存
小结
kswapd 的设计是"在分配者窘迫之前从容备货":分配路径的低水位检测只做异步唤醒,kswapd 携带请求 order/highest_zoneidx 醒来,以 PF_MEMALLOC 身份免疫递归、经 balance_pgdat 逐级加深 priority 执行 shrink_node 流水线,回收到 high 水位即以双检查协议睡回;它与 kcompactd 按"数量 vs 连续性"分工,与直接回收共享全部算法而差异只在时机与参数。下一节看分配者等不及时的同步路径——直接回收。
24.3 直接回收与内存压力
当分配路径必须同步等页到位——水位触及 min、或 gfp 允许且快路径已失败——当前进程亲自下场回收:try_to_free_pages() 挂起本次分配、执行回收流水线、带着战果重试。直接回收是性能敏感路径(调用者在阻塞!),本节分析它的入口、shrink_node() 的调度框架、shrink_folio_list() 的页处理细节,以及 throttl 机制如何防止回收风暴反噬。
24.3.1 入口:try_to_free_pages
// mm/vmscan.c:6566(签名与要点)
unsigned long try_to_free_pages(struct zonelist *zonelist, int order,
gfp_t gfp_mask, int highest_zoneidx)
{
...
/* 扫描目标: SWAP_CLUSTER_MAX 起步 ×32 的预算, priority 加深 */
...
throtl = throttle_writeback(...) /* 回写风暴节流入口 */
...
do {
...
progress = shrink_zones(zonelist, &sc); /* 各 zone 依次 shrink_node */
...
if (sc.nr_reclaimed >= SWAP_CLUSTER_MAX)
break; /* 够分配一批就收手 */
...
} while (--sc.priority >= 1 && ...);
...
return sc.nr_reclaimed;
}
调用者正是 18.3.6 节分配慢路径的 __alloc_pages_direct_reclaim()(page_alloc.c:4445)——回收→重试→should_reclaim_retry()(page_alloc.c:4608)裁决是否再来一轮的循环。直接回收的身份代价:调用者持有的锁、可睡眠性约束都被 gfp_mask 严格限定(GFP_NOWAIT 上下文根本不进此路径);为防进程被自己的回收饿死,每次进入都有预算与节流。
直接回收在分配慢路径中的位置 (18.3.6 节流程的展开):
__alloc_pages_slowpath (:4718)
├─ 唤醒 kswapd (异步, 24.2)
├─ get_page_from_freelist 重试 (wake 后可能已备货)
├─ __alloc_pages_direct_compact (:4173)
│ 同步规整 → 挤出连续块 → 重试
├─ should_reclaim_retry 循环 {
│ try_to_free_pages (:6566) ← 本节: 同步回收
│ wake_up_kswapd (再催后台)
│ should_reclaim_retry (:4608):
│ 水位达标? 有进展? 重试次数? → 继续/放弃
│ }
├─ 无进展 & 可睡 → __alloc_pages_may_oom (:4078)
└─ OOM 杀完 → 最后一次快速路径尝试
24.3.2 调度框架:shrink_node
// mm/vmscan.c:6039(要点)
static void shrink_node(pg_data_t *pgdat, struct scan_control *sc)
{
struct mem_cgroup *rootmemcg = NULL;
...
/* memcg 场景: 限定回收域 (12.4 节) */
...
do {
struct lruvec *lruvec = mem_cgroup_lruvec(...);
...
/* anon/file 配比裁决 (24.1.4 节) */
get_scan_count(lruvec, sc, &stat); /* 传统 */
...
shrink_lruvec(lruvec, sc); /* :6090 区 传统路径 */
/* 或 lru_gen_shrink_lruvec (MGLRU, vmscan.c:5017/5760) */
...
shrink_slab(pgdat, sc); /* 内核可收缩对象: dentry/inode 缓存 */
...
} while (should_continue_reclaim(...));
...
}
shrink_node 是 kswapd 与直接回收共享的调度层:确定回收域(全局 vs 限定 memcg)、把预算分发给 lruvec 层(LRU 扫描)与 slab 层(shrink_slab——dentry/inode 等内核缓存的自收缩器,21 章观测口径的另一半)。should_continue_reclaim 控制多轮加深。
shrink_node 的两层分派图:
shrink_node(pgdat, sc)
├─ 域选择: 全局 or 限定 memcg 子树 (12.4 节)
├─ LRU 面:
│ ├─ MGLRU 路径: lru_gen_shrink_lruvec (:5017/5760)
│ │ 世代滑窗前沿 → shrink_folio_list
│ └─ 传统路径: get_scan_count (:2527) 配比
│ → shrink_lruvec (:5772) → shrink_list 二链
├─ slab 面: shrink_slab
│ 遍历全部注册的 shrinker (dcache/icache/
│ dentry/... 每个子系统一个, 21.4 节)
└─ 循环控制: should_continue_reclaim
(目标未达 & 有进展 & 预算未尽 → 再来)
24.3.3 页处理流水线:shrink_folio_list
// mm/vmscan.c:1083(签名)与流水线要点
* shrink_folio_list() returns the number of reclaimed pages
static unsigned int shrink_folio_list(struct list_head *folio_list,
struct pglist_data *pgdat, struct scan_control *sc,
struct scan_stat *stat, bool ignore_references, ...)
README 节的决策树在这里逐页执行,几个工程细节值得展开:
[1] 批量隔离。上游 isolate_lru_folios()(vmscan.c:1710)从 lruvec 链上批量摘取候选——摘取动作在 lru_lock 自旋锁内完成(lock 覆盖面刻意最小),随后放锁处理:回收中昂贵的 IO 与 TLB 操作全部在锁外,别的 CPU 照常增删链表。
[2] unmap 与 swap 槽。被映射页先 try_to_unmap()(rmap 反查所有映射点,22.2.5 节的 anon_vma/i_mmap 结构在此兑现)清 pte;匿名页清 pte 前必须先分配 swap 槽并把槽号编进 pte(swap 槽编码,19.1.4 节"换出槽"形态)——"清映射"与"留后路"的顺序保证:任何时刻访问都有处可回。
[3] 脏页的放行。脏文件页交给 writeback(36.3 节)后本轮不算回收成功——它仍在 PG_writeback 隔离区(vmscan.c:2057 注释"which are in writeback to finish"),等 IO 完成的回调把它放入下一轮的干净池。这解释了"内存充足但回收量骤降"的观测:瓶颈在 IO 而非扫描。
[4] MGLRU 版本。lru_gen_shrink_lruvec()(vmscan.c:5017/5760 双 CONFIG 分支)沿用同一 shrink_folio_list() 终点,只改变上游候选的产生方式(世代滑窗前沿而非链表尾部)——流水线的投资复用。
shrink_folio_list 的单页决策树 (完整版):
被引用? (referenced 位/页表 A 位)
└─是→ ignore_references? 否 → 放回(rotate), 计失败
被映射? (_mapcount > 0)
└─是→ try_to_unmap:
anon → rmap 反查页表, pte 换 swap 槽
file → rmap 反查, pte 清零
TLB 刷新 (19.5 节)
脏? (PG_dirty)
└─是→ pageout:
文件页 → writeback (36.3), 本轮跳过
匿名页 → swap 槽已备 → 写 swap/zswap
本轮不回收成功 (等 IO)
干净? → 终检 (引用又置位? 放回)
→ __remove_mapping: 从页缓存/XArray 摘除
→ unmap 收尾 → folio_put → refcount 归零
→ 释放回伙伴系统 → nr_reclaimed++
24.3.4 扫描配比:get_scan_count
// mm/vmscan.c:2527
static void get_scan_count(struct lruvec *lruvec, struct scan_control *sc,
struct scan_stat *stat)
anon/file 的配比裁决综合三类信号:
输入信号 倾向
─────────────────────────────────────────────
vm.swappiness (默认 60) >0: 允许扫 anon (有 swap 前提)
swappiness=0 + 无 swap anon 扫描归零
lruvec->refaults (workingset) 文件页 refault 高 → 文件配比降
(别再冤枉热文件页, 24.1.5 节)
anon_cost/file_cost 一侧贵 → 配比偏另一侧
sc->may_swap == false (GFP_NOIO 等) anon 直接出局
swappiness 的真实语义常被误读:它不是"换出的意愿"而是"匿名页相对文件页的扫描权重"——60 意味着文件页:匿名页约 1.4:1(anon_prio = 200-swappiness,file_prio = swappiness 的比例换算)。无 swap 系统上调它没有任何效果(anon 无路可去)。
配比计算的形象化 (扫描预算 100 页):
swappiness=60, 无特殊信号:
file_prio=60, anon_prio=140 → 归一后
文件:匿名 ≈ 1.4 : 1 → 扫 58 文件页 + 42 匿名页
swappiness=100: 1:1 对等
swappiness=10: 文件:匿名 ≈ 19:1 (几乎只扫文件)
swappiness=0 且无 swap: anon=0 (但 OOM 前可能强扫)
refault 高 (文件页被冤枉过): 文件配比再压
24.3.5 压力节流与观测
节流点 (防回收风暴反噬):
vmscan_throttle 系 (pgdat->reclaim_wait[NR_VMSCAN_THROTTLE],
18.2.4 节 pgdat 字段): writeback 风暴/分配者过载时
分配进程直接在节流队列睡眠等 IO 完成
(sc_swappiness 附近的 proactive_swappiness, vmscan.c:96-97
是 memcg 级主动回收的参数)
观测:
/proc/vmstat: pgscan_direct/pgsteal_direct (直接回收量,
与 pgscan_kswapd 对照看压力形态)
allocstall DMA/NORMAL/MOBILE: 直接回收触发次数
(>0 即有进程在分配路径阻塞过)
/sys/kernel/mm/swap/ 与 memory.pressure (12.4 节):
memcg 级压力曲线
健康的直接回收应当接近不存在:allocstall 长期非零意味着水位配置(min_free_kbytes)或容量规划失配,进程级延迟尖刺常源于此——这正是 12 章 memory pressure 通知与 PSI 压力监控的价值所在。
小结
直接回收把回收流水线搬进分配路径:try_to_free_pages 以预算与优先级递减驱动 shrink_node,后者分派 lruvec 扫描(传统二链或 MGLRU)与 shrink_slab;shrink_folio_list 逐页执行"引用检查→unmap→swap 槽→写回放行→丢弃",批量隔离与锁外处理把自旋锁开销压到最小,get_scan_count 以 swappiness、refault 记忆与代价账裁决 anon/file 配比。vmscan 节流队列防止回收风暴反噬调用者。三层防线至此只剩最后一层——回收彻底失败后的 OOM 裁决。
24.4 OOM Killer —— 内存耗尽的终极手段
回收(24.2/24.3)的前提是"内存只是被占着、可以要回来";当占用者本身不再释放——内存泄漏、超配的容器、无 swap 的匿名膨胀——回收产出趋零,系统必须在"集体慢死(颠簸)"与"杀一人保全船"之间选择。OOM Killer 选后者:以 oom_badness 评分选出牺牲者,经一套防御性协议击杀并回收其全部内存。本节分析触发条件、评分算法、击杀协议与治理手段。
24.4.1 触发:out_of_memory 的门槛
// mm/oom_kill.c:1111(签名)
* out_of_memory - kill the "best" process when we run out of memory
bool out_of_memory(struct oom_control *oc)
调用点在 18.3.6 节分配慢路径的末端 __alloc_pages_may_oom()(page_alloc.c:4078)——且只在"回收/压缩/重试全部失败且无进展"后到达(should_reclaim_retry 的多轮裁决失败,page_alloc.c:4608)。入口的防御性检查链:
out_of_memory 的前置闸门:
1. oom_killer_disabled? → 拒绝 (sysrq 手工禁用窗口)
2. 责任约束判定: CONSTRAINT_NONE/CPUSET/
oc->constraint MEMORY_POLICY/MEMCG
(oom_kill.c:247-252 的约束文本表)
memcg 场景只在该 cgroup 内裁决 (12.4 节)
3. 已有击杀进行中? 串行化锁 oom_lock (:61 注释) 防连环杀
4. 系统级 OOM 前查 sysrq
手工候选 / panic_on_oom sysctl (有些系统宁可重启不可错杀)
oom_lock 的串行化(oom_kill.c:61 注释"Serializes oom killer invocations from all contexts")是关键防抖:多个 CPU 同时 OOM(大内存机器常态)时只有一个真正执行裁决,其余等待或复核——一次击杀解决一次危机。
OOM 的触发路径全景 (从分配者视角):
alloc_pages(GFP_KERNEL)
└─ 慢路径 (18.3.6): 回收→压缩→重试 全循环
└─ should_reclaim_retry: 连续 N 轮无进展
└─ __alloc_pages_may_oom (:4078)
├─ oom_mutex trylock (防并发 OOM)
├─ out_of_memory(&oc) (:1111)
│ ├─ select_bad_process → victim
│ └─ oom_kill_process → SIGKILL + reaper
└─ 重试分配 (victim 的内存正被回收)
memcg 场景: memory.max 触顶时走同一函数,
但 oc->constraint=MEMCG — 只杀 cgroup 内进程
24.4.2 评分:oom_badness
// mm/oom_kill.c:202-243(节选, 保留注释要点)
long oom_badness(struct task_struct *p, unsigned long totalpages)
{
long points;
long adj;
if (oom_unkillable_task(p))
return LONG_MIN;
p = find_lock_task_mm(p);
if (!p)
return LONG_MIN;
/*
* Do not even consider tasks which are explicitly marked oom
* unkillable or have been already oom reaped or the are in
* the middle of vfork
*/
adj = (long)p->signal->oom_score_adj;
if (adj == OOM_SCORE_ADJ_MIN ||
mm_flags_test(MMF_OOM_SKIP, p->mm) ||
in_vfork(p)) {
task_unlock(p);
return LONG_MIN;
}
/*
* The baseline for the badness score is the proportion of RAM that each
* task's rss, pagetable and swap space use.
*/
points = get_mm_rss_sum(p->mm) + get_mm_counter_sum(p->mm, MM_SWAPENTS) +
mm_pgtables_bytes(p->mm) / PAGE_SIZE;
task_unlock(p);
/* Normalize to oom_score_adj units */
adj *= totalpages / 1000;
points += adj;
return points;
}
评分的极简主义是刻意设计(注释原文"as simple and predictable as possible")——可预测比"聪明"重要:运维必须能在事后算出"为什么是它"。三个正项(RSS + swap 占用 + 页表字节/页)近似"杀它能回收多少",一项负权 oom_score_adj(-1000..1000,每千分点折合 totalpages/1000)留给管理员做人工干预。豁免清单:oom_unkillable_task(init/内核线程/oom_score_adj=-1000)、已被标记 MMF_OOM_SKIP(处理中)、vfork 进行中(杀 vfork 父会拖垮无辜子进程)。
评分示例 (totalpages = 1TB/4KB = 256M 页):
进程 A: RSS 80GB, swap 0, 页表 256MB
points = 20M + 0 + 64K ≈ 20.06M
进程 B: RSS 40GB, oom_score_adj = +500
points = 10M + 500×256K = 138M ← adj 一个千分点顶 256K 页
进程 C: oom_score_adj = -1000
points = LONG_MIN → 永不选中
换算直觉: +1000 (满分) 相当于"外加一整台内存"的罪;
-1000 = 绝对豁免; 生产上 sshd/监控 agent 设 -1000
是标准操作 — 但全体豁免等于关闭 OOM 保护!
24.4.3 选择与击杀协议
// mm/oom_kill.c:365 与 928/1024
static void select_bad_process(struct oom_control *oc) /* :365 */
{ ...
points = oom_badness(task, oc->totalpages); /* :342 遍历打分 */
...
}
static void __oom_kill_process(struct task_struct *victim, /* :928 */
const char *message)
{ ...
/* 1. 标记 MMF_OOM_SKIP 前置: 防其他 CPU 重复裁决
2. victim 选"内存占用最大的同线程组" (mm 共享者)
3. oom_reaper 伴行: 后台线程抢先把 victim 的匿名页
扫描摘除 (不等进程自然退出), 加速回收
4. do_send_sig_info(SIGKILL) */
...
}
oom_reaper 是击杀协议的现代核心:SIGKILL 后进程若陷于不可中断状态(D 状态、大锁持有中),自然退出可能遥遥无期;reaper 后台线程以 MMF_OOM_SKIP 的配合异步收割victim 地址空间的匿名页(页表逐级摘除+页帧释放),不需要进程配合退出。这把"OOM 后系统恢复时间"从秒级(等调度)压到亚秒级——大内存容器场景的关键差异。
击杀的时序线:
t0: out_of_memory 判定, select_bad_process 选出 victim
t1: __oom_kill_process:
MMF_OOM_SKIP 置位 (防重复裁决)
SIGKILL 发出
oom_reaper 排队接管
t2 (并行): reaper 扫 victim 页表, 匿名页逐个摘除释放
(不等 victim 的线程响应信号!)
t3: victim 线程们陆续死于信号 / reaper 已清空其地址空间
t4: 分配者重试 → 水位回升 → 系统恢复
(老内核 t3 要等所有线程真正退出 — D 状态下可能永远;
reaper 把"回收内存"与"杀死进程"解耦)
击杀的旁观者协议:victim 的线程组内任一线程在 OOM 时刻被选中都行(选 mm 占用最大者);父进程收 SIGCHLD 与 memory.events 的 oom_kill 计数(12.4 节);dmesg 留下完整判决书(评分明细、约束类型、栈快照)——事后审计的原料。
24.4.4 治理面:让 OOM 可控
三层治理:
[1] 系统级 sysctl:
panic_on_oom 宁可 panic 也不错杀 (高可用配 kdump)
oom_kill_allocating_task 简化裁决: 谁触发谁死 (低延迟偏好)
admin_reserve_kbytes / user_reserve_kbytes 保底预留
[2] 进程级: oom_score_adj
-1000 豁免 (sshd/监控 agent 标准做法 — 但须防全系统豁免!)
+500 主动让位 (临时任务)
[3] cgroup 级 (12.4 节):
memory.max + memory.oom.group 容器限额内 OOM, 不殃及全局
memory.events: oom / oom_kill 计数器供监控
memory.peak / PSI memory pressure 提前预警 (OOM 是最坏结局,
PSI 曲线才是预警线)
memcg OOM 与全局 OOM 的分野是容器时代的主战场:限额满时 OOM 发生在 cgroup 域内(CONSTRAINT_MEMCG,oom_kill.c:252),候选只从该 cgroup 选——单容器失控不伤害宿主;代价是"以邻为壑"的防室断绝,全局水位管理仍需 24.2/24.3 节机制。oom_reaper 对 memcg OOM 同样生效,容器 OOM 的恢复延迟因此可控。
三层防线的完整闭环 (24 章总收束):
24.1 LRU/MGLRU 候选名单 (谁冷谁热)
24.2 kswapd 水位 low → 异步备货到 high
24.3 直接回收 水位 min → 分配者同步回收
24.4 OOM 回收彻底失败 → 评分击杀
触发关系:
low 触发 kswapd ──失败──▶ min 触发直接回收 ──无进展──▶ OOM
每一层都是上一层的"兜底", 越往后代价越高
(kswapd 后台无感 / 直接回收阻塞一个进程 / OOM 杀死一个进程)
小结
OOM Killer 是三层防线的终审:out_of_memory 以 oom_lock 串行化与约束判定收口多核竞争,oom_badness 以"RSS+swap+页表"三正项与 oom_score_adj 千分点修正给出可预测的裁决,击杀协议用 MMF_OOM_SKIP 防重复、oom_reaper 异步收割打破 D 状态僵局。治理面上,sysctl 决定系统姿态、oom_score_adj 给出进程豁免、memcg 限额把爆炸半径收进容器。至此回收防线完整闭环——水位触发(kswapd)、同步回收(direct reclaim)、终审裁决(OOM)。