3.10 页回收、RMAP、KSM 与页面迁移
内存不够时,内核怎么决定「牺牲哪一页」?页缓存和匿名页的回收路径为什么完全不同?要回收一页,怎么找到所有映射它的进程——总不能遍历全系统的页表吧?KSM 把相同页合并成一份,写的时候怎么办?「Dirty COW」这个提权漏洞,是怎么从 COW 与反向映射的交互里长出来的?
画出 LRU 两条链表与 `kswapd`/直接回收的关系;说清匿名页与文件页回收的差别;解释 RMAP 的 anon_vma 链与 i_mmap 区间树;用 /proc/vmstat 判断回收压力来自哪里;读懂 KSM 的 pages_sharing 与内存规整的 compact_* 计数器;复盘 Dirty COW 的根因与修法。
3.7 讲了内存从哪来,3.8 讲了怎么映射,3.9 讲了内核自己的对象池。这一章讲最后一环:内存不够时怎么办。
Linux 的策略是「尽量不丢数据,先丢可再生的」:页缓存可以丢(磁盘上还有),匿名页可以换出(swap 上有副本),只有两者都不够才 OOM。要做到这一点,内核必须回答三个问题:
- 挑谁:哪些页是「冷」的、可以回收?→ LRU 链表。
- 怎么找到映射:这一页被哪些进程的哪些 PTE 映射?→ 反向映射(RMAP)。
- 搬得动吗:要拼出连续内存时,把页搬到别处行不行?→ 页面迁移与内存规整。
KSM 与 Dirty COW 是这条主线上的两个特殊案例:前者主动合并相同页(用写保护换内存),后者利用 COW 与 GUP 的竞态提权。它们都说明一件事——回收与共享的每一个优化,都在「正确性」上开了新的口子。
3.10.1 什么时候开始回收:水位、kswapd 与直接回收
回收由水位触发(水位定义见 3.7):
| 触发点 | 谁来做 | 上下文 | 代价 |
|---|---|---|---|
| 空闲页 < LOW | kswapd(每 node 一个内核线程) | 进程上下文,可睡眠 | 后台,影响小 |
| 空闲页 < MIN 且 kswapd 来不及 | 分配者自己(直接回收) | 分配路径内 | 同步阻塞,延迟尖峰 |
| 直接回收也失败 | 内存规整 / OOM | 同上 | 更慢 / 杀进程 |
kswapd 的主循环在 mm/vmscan.c: kswapd() → balance_pgdat():它按 zone 从高到低扫描,直到 pgdat_balanced() 为真(水位回到 HIGH 以上)或没东西可回收。kswapd 睡眠在 pgdat->kswapd_wait 上,wakeup_kswapd() 在分配慢路径里唤醒它。
直接回收走另一条路:
/* mm/vmscan.c: do_try_to_free_pages() —— 6.8 节选,已简化 */
static unsigned long do_try_to_free_pages(struct zonelist *zonelist,
struct scan_control *sc)
{
unsigned long total_reclaimed = 0;
...
/* 第一轮:只回收「干净」的页,不动需要回写的脏页 */
sc->may_writepage = 0;
total_reclaimed = shrink_zones(zonelist, sc);
...
/* 第二轮:允许回写 */
sc->may_writepage = 1;
total_reclaimed += shrink_zones(zonelist, sc);
...
return total_reclaimed;
}
分配者进入直接回收时带 PF_MEMALLOC,这个标志让它在回收过程中仍能分配内存(否则「回收需要内存、内存需要回收」会死锁)。kswapd 线程本身也带这个标志。
回收器按「代价从低到高」尝试:
- 干净页缓存:直接丢 PTE,页回伙伴系统,无需 I/O;
- 脏页缓存:先回写再丢,要等 I/O;
- 匿名页(有 swap):写入 swap 再丢,要 I/O 且占 swap 空间;
- 匿名页(无 swap):不能回收,只能靠 OOM;
- mlocked / unevictable:永不回收,跳过。
/proc/vmstat 的 pgscan_* 与 pgsteal_* 记录每一类的扫描与回收量。pgscan 远大于 pgsteal 说明「扫了很多但收不动」,通常是脏页太多或全是匿名页且 swap 满了。
3.10.2 LRU:两条链表与「二次机会」
每个 lruvec(per memcg、per node)维护 5 条链表:
/* include/linux/mmzone.h —— 6.8 */
enum lru_list {
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
};
file LRU(可回收,代价低) anon LRU(需 swap,代价高)
┌──────────────────────────┐ ┌──────────────────────────┐
│ inactive_file │ │ inactive_anon │
│ ← 回收器从这里摘 │ │ ← swap 满时这里也摘不动 │
└────────────▲─────────────┘ └────────────▲─────────────┘
│ 二次访问(PG_referenced) │
┌────────────┴─────────────┐ ┌────────────┴─────────────┐
│ active_file │ │ active_anon │
│ 热页,需先降级再回收 │ │ 热页,需先降级再回收 │
└──────────────────────────┘ └──────────────────────────┘
▲ ▲
└────────── unevictable(mlock 等,永不回收)──────────┘
新页进入 inactive。当它被再次访问,mark_page_accessed() 把 PG_referenced 置位;第二次访问才把它升到 active。回收器扫描 active 时,若页的 PG_referenced 已被清(说明这一轮没被访问),就降级回 inactive;若还热,就留在 active。这就是「二次机会(second chance)」算法:
/* mm/swap.c: mark_page_accessed() —— 6.8 节选,已简化 */
void mark_page_accessed(struct page *page)
{
page = compound_head(page);
if (!PageActive(page) && !PageUnevictable(page)) {
if (PageReferenced(page))
activate_page(page); /* 第二次访问 → 升 active */
else
SetPageReferenced(page); /* 第一次访问 → 只置位 */
}
...
}
PG_referenced 由硬件 _PAGE_ACCESSED 位辅助:page_referenced()(RMAP)遍历所有映射的 PTE,看 pte_young();只要有一个 PTE 的 accessed 位是新的,就认为这页是热的。这就是为什么回收一页必须先做 RMAP——没有 RMAP,就不知道它热不热、也不知道该清哪些 PTE。
/proc/vmstat 里的对应计数:
| 字段 | 含义 |
|---|---|
pgactivate | 被升到 active 的页数 |
pgdeactivate | 从 active 降级的页数 |
pgrefill | 回收器扫描 active 链表时补充的页数 |
pgscan_kswapd / pgscan_direct | 后台/直接回收扫描量 |
pgsteal_kswapd / pgsteal_direct | 实际回收量 |
pgrotated | 被 rotate 回链表尾的页数 |
pgscan_direct 增长说明分配者被迫自己回收,通常伴随延迟尖峰;pgsteal_direct 接近 pgscan_direct 说明回收有效,差距大说明收不动。
6.1 引入的 CONFIG_LRU_GEN 用「代(generation)」替代两条链表:每页记录它最后一次被访问的代,回收器从最老的一代开始扫。好处是「冷热」判断更细、扫描量更小,尤其适合「一次性扫描大量文件」的场景。开关在 /sys/kernel/mm/lru_gen/enabled,min_ttl_ms 控制最短存活时间。6.8 上 MGLRU 仍是可选(lru_gen),默认用传统 LRU。
3.10.3 反向映射:从 page 找到所有 PTE
正向映射是「虚拟地址 → 物理页」,靠页表。回收需要的是反向:「物理页 → 所有映射它的虚拟地址」,靠 RMAP。
匿名页的 RMAP 用 anon_vma + anon_vma_chain 两级结构:
page->mapping(低位=1)──► anon_vma A
▲
│ anon_vma_chain
┌───────────┴───────────┐
vma 1 (进程 P1) vma 2 (进程 P2,fork 而来)
│ │
└── 都指向同一批物理页 ──┘
查找:page → anon_vma → 遍历 anon_vma_chain → 每个 vma → 该 vma 的页表范围
anon_vma 是「一组共享同一批匿名页的 VMA」的集合。fork 时子进程的 VMA 会通过 anon_vma_fork() 建一个新的 anon_vma,并用 anon_vma_chain 把它和父进程的 anon_vma 连起来;这样从任一页出发都能找到所有映射它的 VMA(包括 fork 出来的子孙)。
文件页的 RMAP 用 address_space 里的区间树:
/* include/linux/fs.h —— 6.8 节选 */
struct address_space {
struct inode *host;
struct xarray i_pages; /* 页缓存:index → folio */
struct rb_root_cached i_mmap; /* 映射该文件的 VMA 区间树 */
...
};
i_mmap 是一棵按 vm_start 排序的增强红黑树(interval tree),vma_interval_tree_insert() 插入、vma_interval_tree_foreach() 按区间查询。给定页的 index(文件内偏移),就能找到所有覆盖这个偏移的 VMA。
统一的入口是 rmap_walk():
/* mm/rmap.c: rmap_walk() —— 6.8 节选,已简化 */
void rmap_walk(struct folio *folio, struct rmap_walk_control *rwc)
{
if (unlikely(folio_test_ksm(folio)))
rmap_walk_ksm(folio, rwc);
else if (folio_test_anon(folio))
rmap_walk_anon(folio, rwc, false);
else
rmap_walk_file(folio, rwc, false);
}
try_to_unmap() 是回收器用的「解除映射」:
/* mm/rmap.c: try_to_unmap_one() —— 6.8 节选,已简化 */
static bool try_to_unmap_one(struct folio *folio, struct vm_area_struct *vma,
unsigned long address, void *arg)
{
struct mmu_notifier_range range;
pte_t pteval;
...
/* 建立「页 → PTE」的临时映射,拿 pte 锁 */
if (!page_vma_mapped_walk(&pvmw))
return false;
...
pteval = ptep_get_and_clear(mm, address, pvmw.pte); /* 清 PTE */
...
if (pte_dirty(pteval))
folio_mark_dirty(folio);
if (pte_young(pteval) || folio_test_young(folio))
folio_set_referenced(folio);
...
folio_remove_rmap_pte(folio, &folio->page, vma);
...
}
关键点:
- 清 PTE 前要先判断脏/访问位,否则会丢数据(脏页没回写)或误判冷热(把热页回收)。
- TLB 必须同步失效(
mmu_notifier_range+flush_tlb_range),否则 CPU 可能还用旧 PTE 访问已回收的页。 page_vma_mapped_walk()处理并发:别的线程可能正在改这个 PTE(mprotect、munmap),walk 会在ptl锁下重试。TTU_*标志控制行为:TTU_IGNORE_MLOCK(忽略 mlock)、TTU_SYNC(同步)、TTU_BATCH_FLUSH(批量刷 TLB)、TTU_SPLIT_HUGE_PMD(先拆大页)。
page_referenced() 是同一套 RMAP 的「只读」版本:遍历所有 PTE,看 accessed 位,返回「有多少个映射最近访问过」。LRU 用它决定升降级。
3.10.4 KSM:把相同的页合并成一份
KSM(Kernel Samepage Merging,2.6.32+)主动扫描标记了 MADV_MERGEABLE 的匿名页,把内容相同的页合并成一份只读页,所有映射指向它;写时再 COW 复制出来。
/* 用户态标记一段内存可合并 */
madvise(addr, len, MADV_MERGEABLE);
/* 取消标记 */
madvise(addr, len, MADV_UNMERGEABLE);
内核侧的流程:
- 扫描:
ksm_scan_thread周期性遍历所有mm_slot(登记过的进程),对每个 VMA 的页计算校验和; - 查重:用两棵树——
stable_tree(已合并的页,按内容哈希)与unstable_tree(本轮候选)——找相同内容的页; - 合并:
try_to_merge_one_page()→write_protect_page()(把所有 PTE 改成只读)→replace_page()(把 PTE 指向 stable 页),原页释放; - 写时复制:任一进程写这一页,触发
do_wp_page(),分配新页复制,恢复可写。
/* mm/ksm.c: try_to_merge_one_page() —— 6.8 节选,已简化 */
static int try_to_merge_one_page(struct vm_area_struct *vma,
struct page *page, struct page *kpage)
{
pte_t orig_pte = __pte(0);
int err = -EFAULT;
if (page == kpage) /* 已经合并 */
return 0;
if (!PageAnon(page))
goto out;
/* 把 page 的所有 PTE 改成只读;有别人在写就失败 */
err = write_protect_page(vma, page, &orig_pte);
if (err)
goto out;
if (!pages_identical(page, kpage))
err = -EAGAIN;
if (err)
goto out;
err = replace_page(vma, page, kpage, orig_pte);
out:
return err;
}
观测在 /sys/kernel/mm/ksm/:
| 文件 | 含义 |
|---|---|
run | 0/1,是否运行;写 2 表示停止并解除所有合并 |
pages_shared | 被合并的「唯一内容」页数(stable 页) |
pages_sharing | 有多少个映射共享这些页(含 stable 页自身) |
pages_unshared | 扫描过但未合并的页 |
pages_volatile | 内容变化太快、放弃合并的页 |
full_scans | 完整扫描轮数 |
sleep_millisecs | 两轮扫描间隔 |
merge_across_nodes | 是否跨 NUMA 节点合并(默认 1) |
max_page_sharing | 单页最多被多少映射共享(默认 256) |
pages_sharing - pages_shared 就是「省下来的页数」。如果 pages_sharing 很大而 pages_shared 很小,说明合并收益高;如果 pages_unshared 很大,说明内容都不同,白扫了。
KSM 的代价:
- CPU:扫描要算校验和、比较内容,
sleep_millisecs调小会显著吃 CPU; - 写放大:合并后的页一写就 COW,频繁写的内存合并反而更慢;
- 安全:合并让「内容相同」变得可观测(时序侧信道),敏感数据不要标
MADV_MERGEABLE。
- 「KSM 会自动合并所有匿名页」:只合并显式标了
MADV_MERGEABLE的 VMA,且要run=1。 - 「合并后写会直接改 stable 页」:写触发 COW,复制出新页,其他映射者不受影响。
- 「
pages_sharing就是省下的内存」:省下的是pages_sharing - pages_shared;pages_sharing里包含 stable 页自身。 - 「KSM 适合所有场景」:它适合「大量相同只读数据」(虚拟机镜像、容器基础层、科学计算副本),不适合频繁写或含密钥的内存。
3.10.5 页面迁移与内存规整
回收解决「页太多」,迁移解决「页太散」。要分配 2 MiB 连续块(THP、DMA),但物理内存里只有零散的 4 KiB 页时,内核可以把这些页搬走,拼出连续空间——前提是页可迁移(MIGRATE_MOVABLE)。
migrate_pages() 的核心步骤:
- 隔离:把待迁移页从 LRU 摘下(
isolate_lru_folios()),防止别人同时回收; - 分配新页:
compaction_alloc()从目标区域分配; - 复制内容:
migrate_folio_move()→copy_highpage(); - 更新映射:RMAP 遍历所有 PTE,把指向旧页的改成新页(
migrate_folio的->mapnewpage); - 释放旧页:
__folio_put()回伙伴系统。
/* mm/migrate.c: migrate_pages() —— 6.8 节选,已简化 */
int migrate_pages(struct list_head *from, new_folio_t get_new_folio,
free_folio_t put_new_folio, unsigned long private,
enum migrate_mode mode, int reason)
{
...
list_for_each_entry_safe(folio, folio2, from, lru) {
...
rc = migrate_folio_move(put_new_folio, private, folio, dst, mode, reason);
switch (rc) {
case MIGRATEPAGE_SUCCESS:
nr_succeeded++;
break;
case -EAGAIN:
/* 页被锁住/正在回写,稍后重试 */
...
}
}
...
}
内存规整(compaction) 是迁移的主要使用者。它把 zone 分成 pageblock,从两端向中间扫:一端找「可迁移的已用页」(isolate_migratepages()),另一端找「空闲页」(isolate_freepages()),把前者搬到后者,逐渐在 zone 的一端拼出连续块。
触发方式:
| 方式 | 触发者 | 说明 |
|---|---|---|
| 直接规整 | 分配高阶页失败时 | __alloc_pages_direct_compact(),同步,慢 |
kcompactd | 每 node 一个内核线程 | 后台规整,compact_proactiveness 控制积极性 |
| 手动 | echo 1 > /proc/sys/vm/compact_memory | 立即规整所有 zone |
| THP 折叠 | khugepaged | 把 512 个 4 KiB 页折叠成 2 MiB 大页 |
计数器在 /proc/vmstat:
compact_stall 1234 # 分配因碎片卡住的次数
compact_fail 234 # 规整后仍失败
compact_success 1000 # 规整成功
compact_migrate_scanned 56789 # 扫描的迁移页数
compact_free_scanned 67890 # 扫描的空闲页数
compact_isolated 45678 # 被隔离的页数
compact_stall 持续增长说明碎片严重;compact_fail/compact_stall 高说明「连规整都救不了」,需要考虑 THP 策略或调整工作负载。
/sys/kernel/debug/extfrag/extfrag_index 给出每个 zone、每个 order 的「外部碎片指数」(0–1000,越大越碎),是比 buddyinfo 更直接的指标。
3.10.6 OOM killer:最后一招
回收与规整都失败时,__alloc_pages_slowpath() 调 out_of_memory()。它做三件事:
- 选受害者:
select_bad_process()遍历所有进程,用oom_badness()打分:
badness = (rss + swap + pgtables) * 1000 / totalpages * (oom_score_adj + 1000)
分数最高者被杀。oom_score_adj 范围 −1000..1000:−1000 表示「永不杀」(如 sshd、systemd),1000 表示「优先杀」。
- 杀:
oom_kill_process()发SIGKILL,并杀掉整个线程组(可选oom_kill_all_tasks)。 - 收尸:
oom_reaper内核线程(4.13+)异步回收被杀进程的地址空间,避免在内存耗尽时同步拆页表导致死锁。
相关文件:
| 路径 | 内容 |
|---|---|
/proc/PID/oom_score | 当前分数(只读) |
/proc/PID/oom_score_adj | 可调偏移(−1000..1000) |
/proc/PID/oom_adj | 旧接口(−17..15),已废弃 |
/proc/sys/vm/panic_on_oom | 0/1/2,OOM 时是否 panic |
memory.events(cgroup v2) | oom、oom_kill、oom_group_kill 计数 |
memory.oom.group | 1 表示整组一起杀(容器场景) |
memcg 的 OOM 与全局 OOM 是两套:cgroup 内存超 memory.max 时,先在组内选受害者(mem_cgroup_out_of_memory()),不会波及组外。容器里的 OOMKilled 就是这条路径。
3.10.7 Dirty COW:一次 COW 与 RMAP 的竞态
CVE-2016-5195(Dirty COW)是 Linux 历史上最著名的本地提权漏洞之一,根因正好落在本章的两个机制上:COW 与 GUP + RMAP。
漏洞的抽象描述:
- 进程 A 用
mmap(MAP_PRIVATE)映射一个只读文件(如/etc/passwd或 setuid 程序); - 进程 A 用
madvise(MADV_DONTNEED)丢弃这段映射的 PTE; - 进程 A 在一个线程里对这段内存发起
write()(触发 COW 缺页); - 同时另一个线程用
ptrace(PTRACE_POKEDATA)或write()到/proc/self/mem触发get_user_pages(FOLL_WRITE); - 在旧内核里,GUP 拿到的是只读的 COW 页(page cache 里的原页),随后内核写这个页,绕过了 COW,直接改到了 page cache——于是只读文件被改,且所有映射者都看到修改。
根因是 get_user_pages() 的 FOLL_WRITE 路径没有「先破 COW」:它以为「要写」就意味着「页已经可写」,但对私有映射的只读 PTE,正确的做法是先触发一次 do_wp_page() 复制。修复(4.8.3+)在 follow_page_pte() 里加了判断:
/* mm/gup.c: follow_page_pte() —— 修复后的逻辑(简化) */
if (flags & FOLL_WRITE && !pte_write(pte) && !PageAnonExclusive(page)) {
/*
* 私有映射的只读页:不能直接把 page cache 页交给调用者写,
* 必须先 break COW(走 fault 路径复制一页)。
*/
pte_unmap_unlock(ptep, ptl);
return no_page_table(vma, flags);
}
修复的实质是:GUP 必须尊重「私有映射的写」语义,不能把共享的 page cache 页当成本进程独占的页。后续内核又引入 PageAnonExclusive(5.17+)把「这页是否被多个映射共享」显式记在 page flag 里,让判断更可靠。
这个案例的教训:
- COW 的正确性依赖「所有写路径都先检查 PTE 权限」,GUP 是一条容易被忘掉的旁路;
- RMAP 让「一页被谁映射」可查,但也意味着「改一页会影响所有映射者」,任何绕过 COW 的写都是全局性的;
- 回收、迁移、GUP 都在改 PTE,它们之间的锁与标志必须一致。
3.10.8 观测与实验
/proc/meminfo 里与回收相关的字段:
| 字段 | 含义 | 判读 |
|---|---|---|
Active(anon/file) | active 链表大小 | active 远大于 inactive 说明热页多 |
Inactive(anon/file) | inactive 链表大小 | 回收的主要来源 |
Unevictable | mlock 等不可回收页 | 持续增长要查是谁锁的 |
Dirty | 待回写脏页 | 高且不降说明回写受阻 |
Writeback | 正在回写 | 长期非零说明设备慢 |
SwapCached | 换入后仍在 swap 的页 | 换入换出频繁的信号 |
vmstat 1 的第一行 si/so(swap in/out)与 free 列是日常观测的首选;/proc/vmstat 的 pgscan_*/pgsteal_* 用于定量。
tracepoint 观测(需要 CONFIG_VMSCAN 相关 trace):
# 直接回收的起止与耗时
perf trace -e 'vmscan:mm_vmscan_direct_reclaim_begin,vmscan:mm_vmscan_direct_reclaim_end' -a sleep 5
# 每小时扫描/回收的页数(bpftrace)
bpftrace -e '
tracepoint:vmscan:mm_vmscan_lru_shrink_inactive
{
@steal[args->nid] = sum(args->nr_reclaimed);
@scan[args->nid] = sum(args->nr_scanned);
}
interval:s:10 { print(@steal); print(@scan); clear(@steal); clear(@scan); }'
下面的模块读全局 LRU 计数,打印五条链表的页数(演示 global_node_page_state()):
// lru_demo.c — 打印全局 LRU 链表长度(Linux 6.8)
#include <linux/init.h>
#include <linux/module.h>
#include <linux/mm.h>
#include <linux/vmstat.h>
static int __init lru_demo_init(void)
{
pr_info("lru_demo: active_anon=%lu inactive_anon=%lu\n",
global_node_page_state(NR_ACTIVE_ANON),
global_node_page_state(NR_INACTIVE_ANON));
pr_info("lru_demo: active_file=%lu inactive_file=%lu\n",
global_node_page_state(NR_ACTIVE_FILE),
global_node_page_state(NR_INACTIVE_FILE));
pr_info("lru_demo: unevictable=%lu\n",
global_node_page_state(NR_UNEVICTABLE));
pr_info("lru_demo: pgscan_direct=%lu pgsteal_direct=%lu\n",
global_node_page_state(NR_PGSCAN_DIRECT),
global_node_page_state(NR_PGSTEAL_DIRECT));
return 0;
}
static void __exit lru_demo_exit(void) { }
module_init(lru_demo_init);
module_exit(lru_demo_exit);
MODULE_LICENSE("GPL");
MODULE_DESCRIPTION("dump global LRU counters");
用户态程序:分配并触碰大块内存,前后对比 pgscan/pgsteal,观察回收是否被触发:
/* reclaim_watch.c — 观察分配压力下的回收计数
* 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -O2 -o reclaim_watch reclaim_watch.c
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
static unsigned long vmstat(const char *key)
{
FILE *f = fopen("/proc/vmstat", "r");
char line[128];
unsigned long v = 0;
if (!f) return 0;
while (fgets(line, sizeof line, f)) {
if (strncmp(line, key, strlen(key)) == 0) {
sscanf(line + strlen(key), "%lu", &v);
break;
}
}
fclose(f);
return v;
}
int main(int argc, char *argv[])
{
size_t mb = argc > 1 ? (size_t)atoi(argv[1]) : 256;
unsigned long s0 = vmstat("pgscan_direct"), t0 = vmstat("pgsteal_direct");
char *p = mmap(NULL, mb << 20, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
memset(p, 0x5a, mb << 20);
printf("touched %zu MiB\n", mb);
printf("pgscan_direct delta=%lu\n", vmstat("pgscan_direct") - s0);
printf("pgsteal_direct delta=%lu\n", vmstat("pgsteal_direct") - t0);
munmap(p, mb << 20);
return 0;
}
在内存充足的机器上 delta 为 0;在接近满载的机器上会看到明显的 pgscan_direct(分配者被迫回收)。把 mb 调到超过可用内存,就能观察 OOM(小心,会杀进程——在 QEMU 里做)。
现象:容器里 free 显示还有 2 GiB,但进程被 OOM kill。三步定位:
dmesg | grep -i oom看到Memory cgroup out of memory: Killed process ... (java),说明是 memcg OOM,不是全局 OOM;cat /sys/fs/cgroup/.../memory.events看到oom_kill 3,memory.current接近memory.max;cat /sys/fs/cgroup/.../memory.stat看到file很小、anon很大——页缓存几乎为 0,全是匿名页,无法回收。
结论:容器限额 1 GiB,进程实际需要 1.2 GiB 匿名内存;宿主的 2 GiB 空闲对它没用。解法是调大 memory.max,或让进程少用内存(-Xmx 之类)。这个例子说明:看内存要看「哪个 cgroup 的哪种页」,全局 free 会骗人。
3.10.9 回收主循环:balance_pgdat、priority 与 shrink_folio_list
这一节回答:kswapd 被唤醒后,按什么顺序扫 zone?priority 从 12 降到 0 代表什么?一页从 LRU 到真正释放,中间要过哪几道检查?
3.10.1 说了「什么时候回收」,这一节说「回收器内部怎么走」。6.8 的回收代码围绕 struct scan_control 展开,几乎所有策略都由它的字段控制。
scan_control:一次回收的完整参数
/* mm/vmscan.c —— 6.8 节选,已简化 */
struct scan_control {
unsigned long nr_to_reclaim;
nodemask_t *nodemask;
struct mem_cgroup *target_mem_cgroup;
unsigned int may_writepage:1;
unsigned int may_unmap:1;
unsigned int may_swap:1;
unsigned int may_shrinkslab:1;
unsigned int compaction_ready:1;
unsigned int proactive:1;
unsigned int no_demotion:1;
gfp_t gfp_mask;
int order;
int priority;
int reclaim_idx;
unsigned long nr_scanned;
unsigned long nr_reclaimed;
...
};
| 字段 | 含义 | 典型值 |
|---|---|---|
nr_to_reclaim | 本次目标回收页数 | 直接回收为 order 对应块大小,kswapd 为 ULONG_MAX |
gfp_mask | 触发分配的 GFP,决定能否回写/换出 | 来自 __alloc_pages |
order | 触发分配的阶 | 0–10 |
priority | 扫描粒度,12(轻)→ 0(重) | 每轮减 1 |
may_writepage | 是否允许回写脏页 | 第一轮 0,第二轮 1 |
may_unmap | 是否允许解除映射 | 通常 1 |
may_swap | 是否允许换出匿名页 | 由 swappiness/GFP 决定 |
reclaim_idx | 允许回收的最高 zone | 由分配掩码算 |
target_mem_cgroup | 只回收这个 memcg | 容器回收 |
may_writepage 的两阶段策略很关键:第一轮只回收干净页,避免为了回收而触发大量回写;如果不够,第二轮才允许回写。这解释了 pgscan 第一轮很大、pgsteal 很小,第二轮 pgsteal 才上来。
kswapd 的主循环
/* mm/vmscan.c: kswapd() —— 6.8 节选,简化 */
static int kswapd(void *p)
{
struct pglist_data *pgdat = (struct pglist_data *)p;
unsigned int order, new_order;
int classzone_idx, new_classzone_idx;
for ( ; ; ) {
/* 睡眠,直到 wakeup_kswapd() 或超时 */
kswapd_try_to_sleep(pgdat, order, classzone_idx);
new_order = READ_ONCE(pgdat->kswapd_max_order);
new_classzone_idx = READ_ONCE(pgdat->kswapd_highest_zoneidx);
if (order < new_order || classzone_idx > new_classzone_idx) {
order = new_order;
classzone_idx = new_classzone_idx;
}
balance_pgdat(pgdat, order); /* 核心回收循环 */
}
}
balance_pgdat() 的框架:
/* mm/vmscan.c: balance_pgdat() —— 6.8 节选,简化 */
static int balance_pgdat(pg_data_t *pgdat, int order)
{
struct scan_control sc = {
.gfp_mask = GFP_KERNEL,
.order = order,
.priority = DEF_PRIORITY, /* 12 */
.may_writepage = !laptop_mode,
.may_unmap = 1,
.may_swap = 1,
};
do {
/* 从高端 zone 往低端找第一个「不平衡」的 zone */
for (i = pgdat->nr_zones - 1; i >= 0; i--) {
if (!zone_balanced(&pgdat->node_zones[i], order))
break;
}
end_zone = i;
/* 从低端 zone 到 end_zone 依次回收 */
for (i = 0; i <= end_zone; i++)
kswapd_shrink_zone(&pgdat->node_zones[i], &sc);
/* 逐步加大扫描粒度,直到平衡或 priority 用尽 */
} while (--sc.priority >= 0 && !pgdat_balanced(pgdat, order));
return order;
}
两个方向容易记反:
- 找不平衡的 zone 从高往低:高端 zone(
Normal/Movable)先被压,低端DMA32尽量保留; - 实际回收从低到高:先回收低端 zone 的页,因为低端 zone 更稀缺,回收能缓解整体压力。
priority 的计算:每轮扫描 total_size >> priority 页,total_size 是 zone 的 LRU 页数。priority=12 时只扫 1/4096,priority=0 时扫全部。所以「12 → 0」是「从轻拍到重拍」,不是「12 轮」。
shrink_folio_list:一页的六道关
shrink_folio_list() 是回收一页的核心,顺序大致是:
1. folio_test_unevictable / mlocked → 跳过(或放到 unevictable)
2. folio_test_writeback → 正在回写,跳过
3. folio_check_dirty_writeback → 脏页且不允许回写,跳过
4. page_referenced / folio_referenced → 最近被访问,放回 active
5. try_to_unmap → 解除所有 PTE
6. pageout / swap_writepage → 回写或换出
7. try_to_free_buffers / free_folio → 释放到伙伴系统
几个细节:
folio_test_unevictable:mlock()的页不能被回收;如果发现 VMA 是VM_LOCKED,会folio_set_unevictable()并移到 unevictable 链表,避免每轮重复扫描。folio_check_dirty_writeback:对文件页,如果PG_dirty且may_writepage=0,直接跳过;对匿名页,PG_swapbacked且没有 swap 空间,也跳过。page_referenced:通过 RMAP 遍历 PTE 的 accessed 位;有引用就放回 active,不回收。这是「二次机会」的第二次判断。try_to_unmap:清 PTE、刷 TLB、更新_mapcount;如果还有别的映射者(mapcount > 0),不能释放。pageout:文件页走writepage,匿名页走swap_writepage;如果回写失败,页留在 LRU,下一轮再试。
shrink_folio_list() 返回「本次真正回收的页数」,sc->nr_reclaimed 累加。如果 nr_reclaimed 远小于 nr_scanned,说明大部分页被跳过(引用、脏、锁定),回收效率低。
直接回收:分配者的自我牺牲
直接回收走 __alloc_pages_slowpath() → __alloc_pages_direct_reclaim() → __perform_reclaim():
/* mm/page_alloc.c: __perform_reclaim() —— 6.8 节选,简化 */
static unsigned long __perform_reclaim(gfp_t gfp_mask, unsigned int order,
const struct alloc_context *ac)
{
struct reclaim_state reclaim_state;
unsigned int noreclaim_flag;
unsigned long progress;
/* 标记 PF_MEMALLOC,允许在回收中使用保留页 */
noreclaim_flag = memalloc_noreclaim_save();
reclaim_state.reclaimed_slab = 0;
current->reclaim_state = &reclaim_state;
progress = try_to_free_pages(ac->zonelist, order, gfp_mask, ac->nodemask);
current->reclaim_state = NULL;
memalloc_noreclaim_restore(noreclaim_flag);
return progress;
}
memalloc_noreclaim_save() 设置 PF_MEMALLOC,让当前任务在回收中仍能分配内存(ALLOC_NO_WATERMARKS),否则「回收要分配、分配要水位」会死锁。
直接回收还有节流:throttle_direct_reclaim() 用 pfmemalloc_wait 让多个同时进入直接回收的任务排队,避免「所有人一起扫 LRU」把系统打爆。/proc/vmstat 的 pgscan_direct 与 pgsteal_direct 是它的直接指标。
zone_balanced 与 pgdat_balanced
zone_balanced(zone, order) 的判断:
free_pages = zone_page_state(zone, NR_FREE_PAGES)
if (free_pages <= watermark[WMARK_HIGH] + watermark_boost)
return false;
if (order && zone->free_area[order].nr_free == 0)
return false; /* 空闲页够,但没有连续块 */
return true;
注意后半段:即使空闲页高于 HIGH,如果请求的 order 没有连续块,zone 仍不算平衡。这就是为什么碎片化时 kswapd 会一直扫,直到 compaction 拼出高阶块或放弃。
pgdat_balanced() 检查节点内所有 zone 是否都平衡,并考虑 classzone_idx(分配允许的最高 zone)。如果只回收了低端 zone,高端 zone 仍不平衡,kswapd 会继续。
- 把
priority当「轮数」:它是扫描粒度的指数,12→0 是最多 13 轮,每轮扫描量翻倍。 - 以为 kswapd 会回收所有 zone:它从低端往
end_zone扫,高端 zone 可能被跳过;pgdat_balanced才决定是否继续。 may_writepage=0时脏页会被回收:不会,脏页被跳过;只有第二轮允许回写才会处理。- 直接回收一定比 kswapd 快:直接回收是同步的,可能阻塞分配者几十毫秒;kswapd 是后台的,但可能来不及。
pgscan大就说明回收有效:pgsteal/pgscan才反映命中率;扫描多、回收少说明页被引用/脏/锁定。zone_balanced只看空闲页:它还看 order 的连续块;碎片化时free很高也可能不平衡。
3.10.10 Refault Distance 与 MGLRU:页缓存的「二次机会」
这一节回答:一个文件页被回收后很快又被访问,内核怎么知道「回收错了」?workingset_refault 与 refault distance 是什么?MGLRU 为什么能用「代」替代两条链表?
文件页的「影子」
文件页被回收时,如果它是干净的,页直接释放,但内核会在 address_space->i_pages 的 xarray 里留一个影子项(shadow entry),记录「这个 index 的页曾经在 LRU 的哪个位置被回收」。这个影子不是页,只占一个 xarray 槽位。
当同一个文件偏移再次缺页时,filemap_fault() 发现影子,调用 workingset_refault():
/* mm/workingset.c —— 6.8 节选,简化 */
void workingset_refault(struct folio *folio, void *shadow)
{
unsigned long eviction = xa_to_value(shadow);
unsigned long refault = pack_shadow(...);
unsigned long refault_distance;
refault_distance = refault - eviction;
if (refault_distance < workingset_size) {
/* 回收错了:把它当作 active,并计入 workingset */
folio_set_active(folio);
count_vm_event(WORKINGSET_REFAULT);
}
}
refault_distance 的直观含义是「这个页被回收时,LRU 里还有多少页比它更冷」。如果 distance 小于当前 inactive+active 的总量,说明「如果当时多保留一点,它就不会被回收」——这是一次回收错误。内核把这种页直接标为 active,并增加 workingset 计数,避免下次再犯。
/proc/vmstat 的相关计数:
| 字段 | 含义 |
|---|---|
workingset_refault_file | 文件页 refault 次数 |
workingset_activate_file | refault 后被激活的次数 |
workingset_restore_file | 从影子恢复的次数 |
workingset_nodereclaim | 影子节点回收次数 |
workingset_nodes | 影子节点数(内存占用) |
如果 workingset_refault_file 持续增长,说明「回收太激进」或「工作集大于内存」,需要加内存/调 watermark_scale_factor/减少缓存压力。
MGLRU:用「代」代替链表
传统 LRU 只有 active/inactive 两条链,页的「年龄」只能通过多次扫描近似。MGLRU(Multi-Generation LRU,6.1+)把 LRU 分成多个「代」(generation),每页记录它最后一次被访问的代:
gen 0(最老) gen 1 gen 2 gen 3(最新)
冷页 温页 较热 热页
↑ 回收从最老代开始
回收器从最老的代开始扫,扫到足够页就停;新分配的页进最新代,被访问的页「升级」到更新的代。好处:
- 扫描量小:不用扫整个 inactive 链,只扫最老代;
- 冷热更细:多代比二机会更接近真实年龄;
- 适合一次性扫描:大量只读文件不会把 active 链冲爆。
开关与观测:
cat /sys/kernel/mm/lru_gen/enabled # 0/1
cat /sys/kernel/mm/lru_gen/min_ttl_ms # 最短存活时间
cat /sys/kernel/debug/lru_gen # 每代页数(需要 debugfs)
grep -E 'lru_gen|pgscan' /proc/vmstat
min_ttl_ms 是「页至少活多久才允许被回收」,防止「刚读进来的页立刻被扫走」。默认 0,调大能保护工作集,但会延迟回收。
MGLRU 在 6.8 仍是可选(CONFIG_LRU_GEN),默认关闭;Android/ChromeOS 等已默认开启。它和传统 LRU 的 /proc/vmstat 计数不完全兼容,切换后要看 lru_gen_* 字段。
- 把
workingset_refault当「缺页」:它只统计「被回收后又访问」的文件页,不含首次缺页/匿名页。 - 以为影子项免费:影子占 xarray 槽位,
workingset_nodes会增长;大量小文件回收后影子可能占用可观内存。 - MGLRU 下继续看
pgactivate/pgdeactivate:这些计数在 MGLRU 下语义不同或不存在,看lru_gen_*。 min_ttl_ms调得越大越好:太大会让回收器扫不到足够页,内存压力下反而触发直接回收/OOM。- 把 refault distance 当精确时间:它是「LRU 位置差」,不是秒;受扫描顺序、活跃度影响。
3.10.11 swap:从 PTE 到 swap_info_struct
这一节回答:匿名页换出后,PTE 里存的是什么?swp_entry_t 的 type/offset 怎么编码?swappiness 到底控制什么,设为 0 是不是就完全不换出?
匿名页没有磁盘上的「原文件」,要换出必须先有 swap 空间。Linux 的 swap 不是「一个文件」,而是一组 swap_info_struct,每个对应一个 swap 分区/文件。
swap_info_struct:一个 swap 设备的全部状态
/* include/linux/swap.h —— 6.8 节选,已简化 */
struct swap_info_struct {
unsigned long flags; /* SWP_USED/SWP_WRITEABLE/SWP_SUSPEND */
signed char prio; /* 优先级,越高越先用 */
unsigned int type; /* swap 类型号,编码进 PTE */
unsigned int max; /* 最大页槽数 */
unsigned char *swap_map; /* 每个槽的引用计数 */
struct swap_cluster_info *cluster_info;
struct swap_cluster_list free_cluster_head;
struct swap_cluster_list free_cluster_tail;
unsigned int lowest_bit;
unsigned int highest_bit;
struct block_device *bdev;
struct swap_extent *first_swap_extent;
struct rb_root swap_extent_root;
...
struct zpool *zpool; /* zswap 用 */
};
| 字段 | 含义 |
|---|---|
flags | 是否启用、是否可写、是否暂停 |
prio | 多个 swap 设备的优先级;高优先级先用 |
type | swap 类型号(0..MAX_SWAPFILES-1),写进 PTE |
max | 槽位总数 |
swap_map[] | 每个槽的引用计数;0 空闲,SWAP_MAP_MAX 表示坏槽 |
cluster_info | 把槽按 256 个一组管理,减少锁争用 |
bdev | 底层块设备 |
swap_extent_root | 逻辑槽 → 物理块的映射(swap 文件可能不连续) |
zpool | zswap 压缩池 |
swap_map 的计数不是「页引用计数」,而是「有多少个 PTE 指向这个 swap 槽」。fork() 后父子共享同一个 swap 槽,计数会加;只有计数归零,槽才真正空闲。
PTE 里的 swap entry
匿名页换出时,PTE 的 present 位清 0,剩下的位存 swp_entry_t:
/* include/linux/swapops.h —— 6.8 节选 */
typedef struct {
unsigned long val;
} swp_entry_t;
#define SWP_TYPE_SHIFT (BITS_PER_XA_VALUE - MAX_SWAPFILES_SHIFT)
#define SWP_OFFSET_MASK ((1UL << SWP_TYPE_SHIFT) - 1)
static inline swp_entry_t pte_to_swp_entry(pte_t pte)
{
swp_entry_t arch_entry;
arch_entry.val = pte_val(pte) >> PTE_PFN_SHIFT; /* 去掉 PTE 标志位 */
return arch_entry;
}
逻辑上:
swp_entry.val = (type << SWP_TYPE_SHIFT) | offset
PTE = swp_entry.val << PTE_PFN_SHIFT | _PAGE_PROTNONE | _PAGE_SWP...
不同架构的编码不同,但语义一致:type 找 swap 设备,offset 找槽位。swp_entry_to_pte()/pte_to_swp_entry() 负责转换;驱动/文件系统不应直接解析 PTE 的 swap 位。
do_swap_page() 在缺页时:
- 从 PTE 取
swp_entry; swap_cache_get_folio(entry)查 swap cache(可能已被别人换入);- 没有就
swap_readpage()从设备读; - 建立新的匿名页 PTE,
swap_free(entry)减少槽计数; - 如果是 COW 映射,可能还要
do_wp_page()复制。
swap cache:换入换出的中转站
swap cache 是一个 address_space(swapper_space),用 swp_entry 作为 index,缓存「已经在内存里、但 swap 槽还没释放」的页。它的作用:
- 避免重复读:多个进程共享同一 swap 页,第一个换入后,第二个直接命中 cache;
- 延迟释放槽:换入后页可能还被映射,槽不能立刻回收;等所有映射都断开,才
swap_free(); - 回写一致性:页在 swap cache 里被修改(COW 前),回写时能保持同一槽。
SwapCached 在 /proc/meminfo 里就是 swap cache 的页数;它高说明「换入后还没完全释放 swap 槽」,通常是内存压力下的正常现象。
swappiness 的公式
get_scan_count() 决定「本轮扫多少 anon、多少 file」:
anon_prio = swappiness
file_prio = 200 - swappiness
ap = anon_prio * (reclaim_stat->recent_scanned[0] + 1)
fp = file_prio * (reclaim_stat->recent_scanned[1] + 1)
然后按 ap:fp 分配扫描预算。swappiness=100 时 anon 与 file 等权;swappiness=0 时 anon 预算为 0,但不是完全不换出:
- 内存压力极大时,
may_swap仍可能为真,OOM 前会换出匿名页; swappiness=0只表示「优先回收文件页」,不是「禁用 swap」;- 要真正禁用 swap,用
swapoff -a或不在/etc/fstab挂 swap。
/proc/sys/vm/swappiness 默认 60,桌面/服务器常用 10–60;数据库常设 1–10,减少匿名页换出。
zswap 与 zram:压缩 swap
zswap 在 swap 设备前加一层压缩池:
匿名页 → zswap 压缩 → zpool(zbud/zsmalloc)→ 内存
↓ 池满/压缩率低
写回 swap 设备
启用:
# 启动参数
zswap.enabled=1 zswap.compressor=lz4 zswap.zpool=zsmalloc
# 或运行时
echo 1 | sudo tee /sys/module/zswap/parameters/enabled
cat /sys/kernel/debug/zswap/stored_pages
cat /sys/kernel/debug/zswap/pool_total_size
zram 是另一种:用一块内存模拟块设备,把 swap 放在 zram 上。它不需要真实 swap 分区,适合容器/嵌入式;代价是压缩/解压吃 CPU,且内存被 zram 占住。
区分:
| 机制 | 位置 | 需要 swap 设备 | 典型场景 |
|---|---|---|---|
| zswap | swap 前端缓存 | 是 | 有 swap 分区,想减少 I/O |
| zram | 内存块设备 | 否(zram 自己就是) | 无盘/容器/Android |
| swap 文件 | 文件系统 | 是 | 临时加 swap |
- 以为
swappiness=0就禁用 swap:它只改变 anon/file 的扫描比例;OOM 前仍可能换出。 SwapCached高就以为泄漏:它是「已换入但槽未释放」的正常缓存,内存压力下降后会回落。- swap 分区越大越好:swap 太大会让系统在内存不足时长时间抖动(thrashing),而不是早点 OOM;按负载设上限。
- 在 SSD 上频繁 swap:写放大影响寿命;优先加内存/zswap,而不是加大 swap。
swapon一个稀疏文件:swap 文件不能有 holes,fallocate创建的文件才可靠;dd创建的要确保块已分配。- 容器里看不到宿主 swap:
/proc/meminfo的SwapTotal在容器里可能是 0(cgroup 隔离),但宿主仍在 swap;看memory.stat的swap字段。
3.10.12 PSI、memcg 回收与 OOM:压力在哪里
这一节回答:/proc/pressure/memory 的 some 与 full 有什么区别?memory.high 和 memory.max 哪个会触发回收?OOM killer 的分数是怎么算的,为什么容器里的 OOM 只杀容器内的进程?
PSI:把「压力」变成可读的数字
PSI(Pressure Stall Information,4.20+)统计任务因为资源不足而停等的时间:
/proc/pressure/cpu: some avg10=0.00 avg60=0.00 avg300=0.00 total=0
/proc/pressure/memory: some avg10=0.00 avg60=0.00 avg300=0.00 total=0
full avg10=0.00 avg60=0.00 avg300=0.00 total=0
/proc/pressure/io: some ... / full ...
| 字段 | 含义 |
|---|---|
some | 至少有一个任务因为该资源停等的时间比例 |
full | 所有可运行任务都停等的时间比例(CPU 无 full) |
avg10/60/300 | 过去 10/60/300 秒的指数平均百分比 |
total | 累计停等微秒数 |
memory full 高说明「所有任务都在等内存」,通常伴随直接回收/OOM;memory some 高但 full 低说明「部分任务在等内存」,可能只是某个大分配在挣扎。
PSI 的内核实现:
psi_group按 cgroup/全局组织;psi_task_change()在任务切换时更新「运行/等待」状态;psi_memstall_enter()/leave()标记「内存停顿」区间(如直接回收、THP 分配);psi_trigger允许用户态注册「超过阈值就 poll 返回」的触发器。
用户态读取很简单:
cat /proc/pressure/memory
# some avg10=1.23 avg60=0.45 avg300=0.12 total=12345678
如果 avg10 长期 > 5%,说明内存压力已经影响延迟;> 20% 通常意味着频繁直接回收。
memcg:容器内存的四道闸
cgroup v2 的 memory.* 文件:
| 文件 | 含义 | 超限行为 |
|---|---|---|
memory.current | 当前用量(含 page cache、slab、swap) | 只读 |
memory.max | 硬上限 | 回收失败后触发 memcg OOM |
memory.high | 软上限 | 超过后节流并强制回收,不杀进程 |
memory.low | 保护下限 | 回收时尽量不碰 |
memory.min | 硬保护 | 除非 OOM,不回收 |
memory.reclaim | 主动回收(写入字节数) | 触发回收,返回实际回收量 |
memory.oom.group | 1 表示整组一起杀 | 容器场景常用 |
memory.high 是「软限制」:超过后任务会被 schedule_timeout() 节流,同时后台回收;它不会直接 OOM,但会让应用变慢。memory.max 是「硬限制」:回收不动就 OOM。
memory.stat 的关键字段:
| 字段 | 含义 |
|---|---|
anon | 匿名页 |
file | page cache |
slab | 内核 slab(含可回收/不可回收) |
sock | socket 缓冲 |
shmem | 共享内存/tmpfs |
file_mapped | 被 mmap 的文件页 |
workingset_refault_* | 回收后重新访问的页 |
pgscan/pgsteal | memcg 级回收计数 |
容器 OOM 的排查顺序:
cat /sys/fs/cgroup/<path>/memory.current
cat /sys/fs/cgroup/<path>/memory.max
cat /sys/fs/cgroup/<path>/memory.events
# low 0
# high 12
# max 3
# oom 1
# oom_kill 1
cat /sys/fs/cgroup/<path>/memory.stat | grep -E 'anon|file|slab|sock'
memory.events 的 max 增长说明「触发了硬限制」,oom_kill 增长说明「真的杀了」。如果 file 很小、anon 很大,说明没有页缓存可回收,只能 OOM。
OOM badness:为什么杀它
oom_badness() 的分数(简化):
badness = (rss + swap + pgtables) * 1000 / totalpages * (oom_score_adj + 1000)
rss:进程驻留页;swap:换出的页;pgtables:页表占用;oom_score_adj:−1000..1000,用户可调;totalpages:系统总页数。
分数最高者被杀。oom_score_adj=-1000 表示「永不杀」(内核会跳过),1000 表示「优先杀」。/proc/PID/oom_score 是只读的最终分数,oom_score_adj 可写。
oom_reaper(4.13+)异步回收被杀进程的地址空间:如果同步拆页表,可能因为「拆页表要分配内存、内存已耗尽」而卡死。oom_reaper 用独立线程慢慢收尸,避免这种死锁。
memcg OOM 与全局 OOM 是两套:
- 全局 OOM:
out_of_memory()遍历所有进程; - memcg OOM:
mem_cgroup_out_of_memory()只在超限 cgroup 内选受害者; memory.oom.group=1时,整个 cgroup 一起杀(避免「杀一个线程,其他线程继续吃内存」)。
panic_on_oom 控制 OOM 后是否 panic:0(默认,杀进程)、1(全局 OOM 才 panic)、2(任何 OOM 都 panic)。生产上通常设 0 或 1;2 只用于「宁可重启也不带病运行」的场景。
- 把
memory.current当 RSS:它含 page cache、slab、swap;RSS 只是其中一部分。 memory.high设得比max低很多:会导致长期节流,应用变慢但不 OOM;这是「软限制」的预期行为,不是 bug。- 容器 OOM 后只看宿主机
free:容器限额是 cgroup 级的,宿主空闲内存对它没用;看memory.events。 oom_score_adj=-1000对所有进程都生效:普通进程也能设,但CAP_SYS_RESOURCE才能调低;误设会让关键进程永不被杀,最终全局 OOM。- PSI 的
total当毫秒:它是微秒;avg10是百分比(0–100),不是绝对值。 memory.reclaim写多少就回收多少:它是「请求量」,实际回收量看返回值;可能因为页被引用/脏而回收不足。
3.10.13 实验:用 PSI 与 MADV_PAGEOUT 观察回收
这一节回答:不写内核模块,能不能用两个用户态程序,把「内存压力」与「主动回收」变成可重复的实验?PSI 的 some/full 与 Rss/Swap 的变化如何对应到 /proc/vmstat 的回收计数?
程序一:psi_watch.c
读取 /proc/pressure/memory,每秒打印一次 some/full 的累计值与 10 秒平均值;可以传入一个 MiB 数,先分配并触碰一段匿名内存,制造压力。
/* psi_watch.c — 观察 memory PSI 与自造压力
* 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o psi_watch psi_watch.c
* 用法: ./psi_watch [压力MiB]
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
struct psi {
double some_avg10, full_avg10;
unsigned long some_total, full_total;
};
static int read_psi(struct psi *p)
{
FILE *fp = fopen("/proc/pressure/memory", "r");
char line[256];
int got = 0;
if (!fp) { perror("/proc/pressure/memory"); return -1; }
memset(p, 0, sizeof(*p));
while (fgets(line, sizeof line, fp)) {
double a10, a60, a300;
unsigned long total;
if (sscanf(line, "some avg10=%lf avg60=%lf avg300=%lf total=%lu",
&a10, &a60, &a300, &total) == 4) {
p->some_avg10 = a10;
p->some_total = total;
got++;
} else if (sscanf(line, "full avg10=%lf avg60=%lf avg300=%lf total=%lu",
&a10, &a60, &a300, &total) == 4) {
p->full_avg10 = a10;
p->full_total = total;
got++;
}
}
fclose(fp);
return got == 2 ? 0 : -1;
}
int main(int argc, char *argv[])
{
size_t mb = argc > 1 ? (size_t)atoi(argv[1]) : 0;
char *p = NULL;
struct psi prev, cur;
int i;
if (mb) {
p = mmap(NULL, mb << 20, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
for (size_t off = 0; off < (mb << 20); off += 4096)
p[off] = (char)off;
printf("touched %zu MiB\n", mb);
}
if (read_psi(&prev) < 0)
return 1;
for (i = 0; i < 5; i++) {
sleep(1);
if (read_psi(&cur) < 0)
return 1;
printf("some avg10=%5.2f%% delta=%6lu ms | full avg10=%5.2f%% delta=%6lu ms\n",
cur.some_avg10,
(cur.some_total - prev.some_total) / 1000,
cur.full_avg10,
(cur.full_total - prev.full_total) / 1000);
prev = cur;
}
if (p)
munmap(p, mb << 20);
return 0;
}
在一台有 2 GiB 空闲的机器上跑 ./psi_watch 4096,some avg10 会先升后降;如果 full avg10 也明显上升,说明连内核回收线程都在等内存,接近 OOM。
程序二:pageout_demo.c
用 madvise(MADV_PAGEOUT)(5.4+)主动把匿名页换出/回收,前后对比 smaps_rollup 与 /proc/vmstat:
/* pageout_demo.c — 主动回收匿名页并观察 Rss/Swap
* 编译: cc -std=c17 -D_GNU_SOURCE -Wall -Wextra -Werror -O2 -o pageout_demo pageout_demo.c
* 用法: ./pageout_demo [MiB]
*/
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/mman.h>
#include <unistd.h>
#ifndef MADV_PAGEOUT
#define MADV_PAGEOUT 21
#endif
static unsigned long vmstat(const char *key)
{
FILE *fp = fopen("/proc/vmstat", "r");
char line[128];
unsigned long v = 0;
if (!fp) return 0;
while (fgets(line, sizeof line, fp)) {
if (strncmp(line, key, strlen(key)) == 0) {
sscanf(line + strlen(key), "%lu", &v);
break;
}
}
fclose(fp);
return v;
}
static void smaps(unsigned long *rss, unsigned long *swap)
{
FILE *fp = fopen("/proc/self/smaps_rollup", "r");
char line[128];
*rss = *swap = 0;
if (!fp) return;
while (fgets(line, sizeof line, fp)) {
if (sscanf(line, "Rss: %lu kB", rss) == 1)
continue;
if (sscanf(line, "Swap: %lu kB", swap) == 1)
continue;
}
fclose(fp);
}
int main(int argc, char *argv[])
{
size_t mb = argc > 1 ? (size_t)atoi(argv[1]) : 256;
size_t len = mb << 20;
char *p;
unsigned long rss, swap, s0, s1, g0, g1, w0, w1;
p = mmap(NULL, len, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (p == MAP_FAILED) { perror("mmap"); return 1; }
for (size_t off = 0; off < len; off += 4096)
p[off] = (char)(off >> 12);
smaps(&rss, &swap);
s0 = vmstat("pgscan_direct");
g0 = vmstat("pgsteal_direct");
w0 = vmstat("pswpout");
printf("before: Rss=%lu kB Swap=%lu kB pgscan=%lu pgsteal=%lu pswpout=%lu\n",
rss, swap, s0, g0, w0);
if (madvise(p, len, MADV_PAGEOUT) < 0)
perror("madvise(MADV_PAGEOUT)");
usleep(200000);
smaps(&rss, &swap);
s1 = vmstat("pgscan_direct");
g1 = vmstat("pgsteal_direct");
w1 = vmstat("pswpout");
printf("after: Rss=%lu kB Swap=%lu kB pgscan=%lu pgsteal=%lu pswpout=%lu\n",
rss, swap, s1, g1, w1);
printf("delta: pgscan=%lu pgsteal=%lu pswpout=%lu\n",
s1 - s0, g1 - g0, w1 - w0);
munmap(p, len);
return 0;
}
预期:
- 有 swap 且未满:
Rss下降、Swap上升、pswpout增长; - 无 swap:
MADV_PAGEOUT可能只回收干净的匿名页(如果没有 swap,匿名页不能换出,可能返回成功但页仍在); zswap开启:pswpout不一定增长(页被压缩在内存里),要看/sys/kernel/debug/zswap/stored_pages。
观测:回收事件的分布
# 直接回收的起止与 order
sudo bpftrace -e '
tracepoint:vmscan:mm_vmscan_direct_reclaim_begin
{ @begin[args->order] = count(); }
tracepoint:vmscan:mm_vmscan_direct_reclaim_end
{ @end[args->order] = count(); }
interval:s:5 { print(@begin); print(@end); exit(); }'
# LRU 扫描/回收的页数分布
sudo bpftrace -e '
tracepoint:vmscan:mm_vmscan_lru_shrink_inactive
{ @reclaimed = hist(args->nr_reclaimed); }
interval:s:5 { exit(); }'
# memcg 主动回收(cgroup v2)
echo 64M | sudo tee /sys/fs/cgroup/<path>/memory.reclaim
mm_vmscan_lru_shrink_inactive 的 nr_reclaimed=0 很常见:扫了很多页,但都被引用/脏/锁定,一页也没回收。结合 nr_scanned 与 nr_reclaimed 的比值,能判断「回收效率」。
某机器 memory some avg10=15%,但 MemFree 还有 3 GiB。排查发现是 memory.high 设得比较低,cgroup 内任务被节流,PSI 上升;全局内存其实充足。
结论:PSI 是 cgroup 级的,/proc/pressure/memory 是全局视图,但 memory.pressure 在 cgroup 里是组内视图。先确认看的是哪个层级,再决定加内存还是调 memory.high。
3.10.14 Dirty COW 的完整时间线:一次 PTE 状态复盘
这一节回答:CVE-2016-5195 的竞态到底发生在哪两条路径之间?为什么 faultin_page() 清掉 FOLL_WRITE 会成为漏洞的钥匙?修复为什么不是「加一把锁」?
3.10.7 给了根因概述,这里按 PTE 状态把时间线走一遍。前提:进程用 open(O_RDONLY) + mmap(PROT_READ, MAP_PRIVATE) 映射一个只读文件,然后两个线程并发:
- 线程 A:循环
lseek(/proc/self/mem, addr)+write(fd, payload, len); - 线程 B:循环
madvise(addr, len, MADV_DONTNEED)。
状态表
| 步骤 | 线程 | 动作 | PTE 状态 | 关键函数 |
|---|---|---|---|---|
| 1 | A | /proc/self/mem 写入 | 无 PTE(首次) | mem_write() → __access_remote_vm() |
| 2 | A | GUP 带 FOLL_WRITE | 无 PTE | __get_user_pages() |
| 3 | A | 缺页,写 fault | 映射新匿名页,只读 + dirty | do_cow_fault() → do_set_pte() |
| 4 | A | faultin_page() 返回 VM_FAULT_WRITE | 同上 | faultin_page() |
| 5 | A | 清掉 FOLL_WRITE,重试 GUP | 同上 | *flags &= ~FOLL_WRITE |
| 6 | B | MADV_DONTNEED 清 PTE | 无 PTE | zap_page_range() |
| 7 | A | 以「读」重试 GUP,读 fault | 映射 page cache 页,只读 | do_read_fault() → filemap_fault() |
| 8 | A | copy_to_user_page() 写 page cache | page cache 变脏 | __access_remote_vm() |
关键在步骤 4–5:do_cow_fault() 分配了匿名页,但因为 VMA 没有 VM_WRITE,do_set_pte() 里的 maybe_mkwrite() 不会把 PTE 设为可写。它仍然返回 VM_FAULT_WRITE,表示「COW 已经处理过了」。faultin_page() 看到 VM_FAULT_WRITE 且 !VM_WRITE,就清掉 FOLL_WRITE:
/* mm/gup.c: faultin_page() —— 漏洞版本(4.8 之前),简化 */
if ((ret & VM_FAULT_WRITE) && !(vma->vm_flags & VM_WRITE))
*flags &= ~FOLL_WRITE;
这个优化的本意是「既然 COW 已经完成,后续按读处理即可,避免反复触发写 fault」。但它忽略了一个事实:PTE 仍可能是只读的,而且可能被 MADV_DONTNEED 清掉。
步骤 6 的 MADV_DONTNEED 把刚建好的匿名 PTE 清掉。步骤 7 的 GUP 现在没有 FOLL_WRITE,按读 fault 处理;私有映射的读 fault 走 do_read_fault(),直接把 page cache 页映射进来(只读)。GUP 认为「读页」合法,返回 page cache 页。步骤 8 的 mem_write() 拿到 page 后执行 copy_to_user_page(),直接写 page cache——只读文件被修改。
为什么加锁不够
竞态窗口在「步骤 5 清 FOLL_WRITE」与「步骤 7 重试 GUP」之间。加一把锁能缩小窗口,但不能修正语义:
- GUP 的
FOLL_WRITE表示「调用者要写」,即使 PTE 只读,也应该先破 COW,而不是「把只读页当读页交出去」; MADV_DONTNEED是合法操作,不能靠「禁止它并发」来修复;/proc/self/mem的写路径本来就该尊重 VMA 权限,不能因为「GUP 说是读」就绕过。
修复(commit 19be0ea,4.8.3+)的核心是:不要因为 VM_FAULT_WRITE 就清 FOLL_WRITE。GUP 继续带写标志重试,follow_page_pte() 看到 FOLL_WRITE && !pte_write() 就返回 NULL,回到 fault 路径;do_wp_page() 正确地复制/升级 PTE。后续 PageAnonExclusive(5.17+)把「这页是否被多个映射共享」显式记在 page flag 里,让判断更可靠。
复盘要点
| 问题 | 答案 |
|---|---|
| 漏洞影响哪些内核 | 2.6.22–4.8(修复在 4.8.3/4.9) |
| 触发条件 | 私有只读映射 + 并发 MADV_DONTNEED + 写 /proc/self/mem(或 ptrace) |
| 根因 | GUP 在 VM_FAULT_WRITE 后清 FOLL_WRITE,把只读 page cache 页交给写路径 |
| 修复 | 不清 FOLL_WRITE;GUP 拒绝只读 PTE;后续用 PageAnonExclusive 加固 |
| 防御 | 及时更新内核;只读挂载关键文件;限制 ptrace//proc/self/mem(ptrace_scope) |
- 以为只有
/proc/self/mem能触发:ptrace(PTRACE_POKEDATA)、process_vm_writev等 GUP 写路径也可能触发同类问题。 - 以为
MADV_DONTNEED是「释放内存」:它只是清 PTE,页可能还在 page cache;对私有映射,下次访问会重新 fault。 - 以为修复只是「加锁」:根因是 GUP 的语义错误,锁不能替代「先破 COW」。
- 在新内核上复现旧 PoC:4.8.3+ 已修复,PoC 只会看到「写不进去」;要验证请在隔离的旧内核 QEMU 里做。
- 把
ptrace_scope=1当万能防御:它限制非父子 ptrace,但/proc/self/mem仍可用;根本防线是补丁。
3.10.15 本章出口检查
| 检查项 | 达标线 | 没达标去哪 |
|---|---|---|
| 说出三种回收触发 | kswapd/direct/OOM 的边界 | 3.10.1 |
| 画出 LRU 两条链表 | inactive→active 的升级条件 | 3.10.2 |
| 解释 RMAP 两套结构 | anon_vma 链与 i_mmap 树 | 3.10.3 |
说清 try_to_unmap 的顺序 | 清 PTE、判脏/young、刷 TLB | 3.10.3 |
| 解释 KSM 的合并与 COW | pages_sharing 的含义 | 3.10.4 |
| 说清迁移的五个步骤 | 隔离、分配、复制、改映射、释放 | 3.10.5 |
| 复盘 Dirty COW 根因 | GUP 未破 COW | 3.10.7 |
| 走通 balance_pgdat 主循环 | priority 与 shrink_folio_list | 3.10.9 |
| 解释 refault distance | 影子项与 workingset | 3.10.10 |
| 说清 swap entry 与 zswap | type/offset 编码、压缩路径 | 3.10.11 |
| 区分 PSI some/full | memcg high/max 与 OOM badness | 3.10.12 |
| 跑通 PSI 与 MADV_PAGEOUT | Rss/Swap/pgscan 的变化 | 3.10.13 |
| 按 PTE 状态复盘 Dirty COW | 五步竞态与修复 | 3.10.14 |
Q1 一个页在 inactive 链表里,被访问一次后 PG_referenced 置位,再被访问一次会怎样?如果一直不被访问呢?
Q2 回收一个被 3 个进程映射的匿名页,内核怎么找到这 3 个 PTE?如果其中一个是 fork 出来的子进程呢?
Q3 为什么 KSM 合并前要把所有 PTE 改成只读?不改成只读会怎样?
Q4 内存规整(compaction)与页回收(reclaim)解决的是同一个问题吗?各自的前提是什么?
Q5 Dirty COW 的修复为什么不能只靠「在 GUP 里加一把锁」?根本的语义错误是什么?
答案
A1 第二次访问时 mark_page_accessed() 发现 PG_referenced 已置位,调用 activate_page() 把它升到 active 链表。如果一直不被访问,回收器扫描 inactive 时直接回收(干净页丢 PTE,脏页先回写)。
A2 page->mapping 指向 anon_vma,遍历它的 anon_vma_chain 找到所有相关 VMA(父进程的、子进程的),对每个 VMA 用 page_vma_mapped_walk() 在页表里定位 PTE。fork 出来的子进程通过 anon_vma_fork() 把自己的 VMA 挂进同一条链,所以能被找到。
A3 合并后多个进程共享同一物理页,若仍可写,一个进程的写会直接改到别人的数据。改成只读后,任何写都触发 do_wp_page(),复制出新页再写,其他映射者不受影响。不改只读等于把「共享」变成了「数据竞争」。
A4 不是。回收解决「空闲页不够」,把页释放回伙伴系统;规整解决「空闲页够但不连续」,把可迁移页搬走来拼出连续块。回收的前提是页可丢弃/可换出;规整的前提是页可迁移(MIGRATE_MOVABLE),不可迁移页太多时规整也无能为力。
A5 加锁只能缩小竞态窗口,不能修正语义:GUP 的 FOLL_WRITE 对私有映射的只读页,正确行为是「先破 COW 再给调用者」,而不是「把 page cache 页直接交出去写」。修复是在 GUP 路径里识别这种情况并退回 fault 路径(或返回不可写),让 COW 正常发生。
母本对应
| 母本 | 章节 | 本书落点 | 程度 |
|---|---|---|---|
| 奔跑吧 Linux 内核 | 第 2 章 2.11 page 引用计数 | 3.10.2 | 精选(_refcount/_mapcount 与回收) |
| 奔跑吧 Linux 内核 | 第 2 章 2.12 反向映射 RMAP | 3.10.3 | 完整(anon_vma、i_mmap、try_to_unmap) |
| 奔跑吧 Linux 内核 | 第 2 章 2.13 回收页面 | 3.10.1–3.10.2 | 完整(LRU、kswapd、直接回收) |
| 奔跑吧 Linux 内核 | 第 2 章 2.14 匿名页面生命周期 | 3.10.2 | 精选 |
| 奔跑吧 Linux 内核 | 第 2 章 2.15 页面迁移 | 3.10.5 | 完整 |
| 奔跑吧 Linux 内核 | 第 2 章 2.16 内存规整 | 3.10.5 | 完整 |
| 奔跑吧 Linux 内核 | 第 2 章 2.17 KSM | 3.10.4 | 完整 |
| 奔跑吧 Linux 内核 | 第 2 章 2.18 Dirty COW 内存漏洞 | 3.10.7 | 改写(补上 PageAnonExclusive 的后续) |
| OSTEP | 第 21–22 章 超越物理内存:机制与策略 | 3.10.1、3.10.2 | 对照(换页与 LRU 的理论) |
| 奔跑吧 Linux 内核 | 第 2 章 2.13.8 Refault Distance 算法 | 3.10.10 | 完整(影子项与工作集) |
| OSTEP | 第 23 章 VAX/VMS 虚拟内存系统 | 3.10.10 | 背景(工作集模型) |
下一站:3.11 并发与同步 进入内核里最容易出错的部分:原子操作与内存屏障、spinlock 与 mutex 的边界、RCU 的宽限期到底在等什么。