Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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。要做到这一点,内核必须回答三个问题:

  1. 挑谁:哪些页是「冷」的、可以回收?→ LRU 链表。
  2. 怎么找到映射:这一页被哪些进程的哪些 PTE 映射?→ 反向映射(RMAP)。
  3. 搬得动吗:要拼出连续内存时,把页搬到别处行不行?→ 页面迁移与内存规整。

KSM 与 Dirty COW 是这条主线上的两个特殊案例:前者主动合并相同页(用写保护换内存),后者利用 COW 与 GUP 的竞态提权。它们都说明一件事——回收与共享的每一个优化,都在「正确性」上开了新的口子。

3.10.1 什么时候开始回收:水位、kswapd 与直接回收

回收由水位触发(水位定义见 3.7):

触发点谁来做上下文代价
空闲页 < LOWkswapd(每 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 线程本身也带这个标志。

定义 3.10.1 · 回收优先级

回收器按「代价从低到高」尝试:

  1. 干净页缓存:直接丢 PTE,页回伙伴系统,无需 I/O;
  2. 脏页缓存:先回写再丢,要等 I/O;
  3. 匿名页(有 swap):写入 swap 再丢,要 I/O 且占 swap 空间;
  4. 匿名页(无 swap):不能回收,只能靠 OOM;
  5. 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 说明回收有效,差距大说明收不动。

MGLRU(多代 LRU)

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

内核侧的流程:

  1. 扫描:ksm_scan_thread 周期性遍历所有 mm_slot(登记过的进程),对每个 VMA 的页计算校验和;
  2. 查重:用两棵树——stable_tree(已合并的页,按内容哈希)与 unstable_tree(本轮候选)——找相同内容的页;
  3. 合并:try_to_merge_one_page() → write_protect_page()(把所有 PTE 改成只读)→ replace_page()(把 PTE 指向 stable 页),原页释放;
  4. 写时复制:任一进程写这一页,触发 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/:

文件含义
run0/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 的四个误解
  1. 「KSM 会自动合并所有匿名页」:只合并显式标了 MADV_MERGEABLE 的 VMA,且要 run=1。
  2. 「合并后写会直接改 stable 页」:写触发 COW,复制出新页,其他映射者不受影响。
  3. 「pages_sharing 就是省下的内存」:省下的是 pages_sharing - pages_shared;pages_sharing 里包含 stable 页自身。
  4. 「KSM 适合所有场景」:它适合「大量相同只读数据」(虚拟机镜像、容器基础层、科学计算副本),不适合频繁写或含密钥的内存。

3.10.5 页面迁移与内存规整

回收解决「页太多」,迁移解决「页太散」。要分配 2 MiB 连续块(THP、DMA),但物理内存里只有零散的 4 KiB 页时,内核可以把这些页搬走,拼出连续空间——前提是页可迁移(MIGRATE_MOVABLE)。

migrate_pages() 的核心步骤:

  1. 隔离:把待迁移页从 LRU 摘下(isolate_lru_folios()),防止别人同时回收;
  2. 分配新页:compaction_alloc() 从目标区域分配;
  3. 复制内容:migrate_folio_move() → copy_highpage();
  4. 更新映射:RMAP 遍历所有 PTE,把指向旧页的改成新页(migrate_folio 的 ->mapnewpage);
  5. 释放旧页:__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()。它做三件事:

  1. 选受害者:select_bad_process() 遍历所有进程,用 oom_badness() 打分:
badness = (rss + swap + pgtables) * 1000 / totalpages * (oom_score_adj + 1000)

分数最高者被杀。oom_score_adj 范围 −1000..1000:−1000 表示「永不杀」(如 sshd、systemd),1000 表示「优先杀」。

  1. 杀:oom_kill_process() 发 SIGKILL,并杀掉整个线程组(可选 oom_kill_all_tasks)。
  2. 收尸: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_oom0/1/2,OOM 时是否 panic
memory.events(cgroup v2)oom、oom_kill、oom_group_kill 计数
memory.oom.group1 表示整组一起杀(容器场景)

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。

漏洞的抽象描述:

  1. 进程 A 用 mmap(MAP_PRIVATE) 映射一个只读文件(如 /etc/passwd 或 setuid 程序);
  2. 进程 A 用 madvise(MADV_DONTNEED) 丢弃这段映射的 PTE;
  3. 进程 A 在一个线程里对这段内存发起 write()(触发 COW 缺页);
  4. 同时另一个线程用 ptrace(PTRACE_POKEDATA) 或 write() 到 /proc/self/mem 触发 get_user_pages(FOLL_WRITE);
  5. 在旧内核里,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 链表大小回收的主要来源
Unevictablemlock 等不可回收页持续增长要查是谁锁的
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 里做)。

例 3.10.1 · 一次「内存没满却 OOM」的排查

现象:容器里 free 显示还有 2 GiB,但进程被 OOM kill。三步定位:

  1. dmesg | grep -i oom 看到 Memory cgroup out of memory: Killed process ... (java),说明是 memcg OOM,不是全局 OOM;
  2. cat /sys/fs/cgroup/.../memory.events 看到 oom_kill 3,memory.current 接近 memory.max;
  3. 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 会继续。

回收主循环的六个坑
  1. 把 priority 当「轮数」:它是扫描粒度的指数,12→0 是最多 13 轮,每轮扫描量翻倍。
  2. 以为 kswapd 会回收所有 zone:它从低端往 end_zone 扫,高端 zone 可能被跳过;pgdat_balanced 才决定是否继续。
  3. may_writepage=0 时脏页会被回收:不会,脏页被跳过;只有第二轮允许回写才会处理。
  4. 直接回收一定比 kswapd 快:直接回收是同步的,可能阻塞分配者几十毫秒;kswapd 是后台的,但可能来不及。
  5. pgscan 大就说明回收有效:pgsteal/pgscan 才反映命中率;扫描多、回收少说明页被引用/脏/锁定。
  6. 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_filerefault 后被激活的次数
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_* 字段。

refault/MGLRU 的五个坑
  1. 把 workingset_refault 当「缺页」:它只统计「被回收后又访问」的文件页,不含首次缺页/匿名页。
  2. 以为影子项免费:影子占 xarray 槽位,workingset_nodes 会增长;大量小文件回收后影子可能占用可观内存。
  3. MGLRU 下继续看 pgactivate/pgdeactivate:这些计数在 MGLRU 下语义不同或不存在,看 lru_gen_*。
  4. min_ttl_ms 调得越大越好:太大会让回收器扫不到足够页,内存压力下反而触发直接回收/OOM。
  5. 把 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 设备的优先级;高优先级先用
typeswap 类型号(0..MAX_SWAPFILES-1),写进 PTE
max槽位总数
swap_map[]每个槽的引用计数;0 空闲,SWAP_MAP_MAX 表示坏槽
cluster_info把槽按 256 个一组管理,减少锁争用
bdev底层块设备
swap_extent_root逻辑槽 → 物理块的映射(swap 文件可能不连续)
zpoolzswap 压缩池

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() 在缺页时:

  1. 从 PTE 取 swp_entry;
  2. swap_cache_get_folio(entry) 查 swap cache(可能已被别人换入);
  3. 没有就 swap_readpage() 从设备读;
  4. 建立新的匿名页 PTE,swap_free(entry) 减少槽计数;
  5. 如果是 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 设备典型场景
zswapswap 前端缓存是有 swap 分区,想减少 I/O
zram内存块设备否(zram 自己就是)无盘/容器/Android
swap 文件文件系统是临时加 swap
swap 的六个坑
  1. 以为 swappiness=0 就禁用 swap:它只改变 anon/file 的扫描比例;OOM 前仍可能换出。
  2. SwapCached 高就以为泄漏:它是「已换入但槽未释放」的正常缓存,内存压力下降后会回落。
  3. swap 分区越大越好:swap 太大会让系统在内存不足时长时间抖动(thrashing),而不是早点 OOM;按负载设上限。
  4. 在 SSD 上频繁 swap:写放大影响寿命;优先加内存/zswap,而不是加大 swap。
  5. swapon 一个稀疏文件:swap 文件不能有 holes,fallocate 创建的文件才可靠;dd 创建的要确保块已分配。
  6. 容器里看不到宿主 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.group1 表示整组一起杀容器场景常用

memory.high 是「软限制」:超过后任务会被 schedule_timeout() 节流,同时后台回收;它不会直接 OOM,但会让应用变慢。memory.max 是「硬限制」:回收不动就 OOM。

memory.stat 的关键字段:

字段含义
anon匿名页
filepage cache
slab内核 slab(含可回收/不可回收)
socksocket 缓冲
shmem共享内存/tmpfs
file_mapped被 mmap 的文件页
workingset_refault_*回收后重新访问的页
pgscan/pgstealmemcg 级回收计数

容器 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 只用于「宁可重启也不带病运行」的场景。

PSI/memcg/OOM 的六个坑
  1. 把 memory.current 当 RSS:它含 page cache、slab、swap;RSS 只是其中一部分。
  2. memory.high 设得比 max 低很多:会导致长期节流,应用变慢但不 OOM;这是「软限制」的预期行为,不是 bug。
  3. 容器 OOM 后只看宿主机 free:容器限额是 cgroup 级的,宿主空闲内存对它没用;看 memory.events。
  4. oom_score_adj=-1000 对所有进程都生效:普通进程也能设,但 CAP_SYS_RESOURCE 才能调低;误设会让关键进程永不被杀,最终全局 OOM。
  5. PSI 的 total 当毫秒:它是微秒;avg10 是百分比(0–100),不是绝对值。
  6. 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 的比值,能判断「回收效率」。

例 3.10.1 · PSI 高但 `MemFree` 也高

某机器 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 状态关键函数
1A/proc/self/mem 写入无 PTE(首次)mem_write() → __access_remote_vm()
2AGUP 带 FOLL_WRITE无 PTE__get_user_pages()
3A缺页,写 fault映射新匿名页,只读 + dirtydo_cow_fault() → do_set_pte()
4Afaultin_page() 返回 VM_FAULT_WRITE同上faultin_page()
5A清掉 FOLL_WRITE,重试 GUP同上*flags &= ~FOLL_WRITE
6BMADV_DONTNEED 清 PTE无 PTEzap_page_range()
7A以「读」重试 GUP,读 fault映射 page cache 页,只读do_read_fault() → filemap_fault()
8Acopy_to_user_page() 写 page cachepage 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)
Dirty COW 复盘的五个坑
  1. 以为只有 /proc/self/mem 能触发:ptrace(PTRACE_POKEDATA)、process_vm_writev 等 GUP 写路径也可能触发同类问题。
  2. 以为 MADV_DONTNEED 是「释放内存」:它只是清 PTE,页可能还在 page cache;对私有映射,下次访问会重新 fault。
  3. 以为修复只是「加锁」:根因是 GUP 的语义错误,锁不能替代「先破 COW」。
  4. 在新内核上复现旧 PoC:4.8.3+ 已修复,PoC 只会看到「写不进去」;要验证请在隔离的旧内核 QEMU 里做。
  5. 把 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、刷 TLB3.10.3
解释 KSM 的合并与 COWpages_sharing 的含义3.10.4
说清迁移的五个步骤隔离、分配、复制、改映射、释放3.10.5
复盘 Dirty COW 根因GUP 未破 COW3.10.7
走通 balance_pgdat 主循环priority 与 shrink_folio_list3.10.9
解释 refault distance影子项与 workingset3.10.10
说清 swap entry 与 zswaptype/offset 编码、压缩路径3.10.11
区分 PSI some/fullmemcg high/max 与 OOM badness3.10.12
跑通 PSI 与 MADV_PAGEOUTRss/Swap/pgscan 的变化3.10.13
按 PTE 状态复盘 Dirty COW五步竞态与修复3.10.14
即时自测 · 3.10

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 反向映射 RMAP3.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 KSM3.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 的宽限期到底在等什么。