AkiraZheng's Time.

Linux 页面回收全景:从水位触发到换回内存

Word count: 9.7kReading time: 36 min
2026/09/12

页面回收(Page Reclaim)是内核在内存不足时腾挪页框的机制:

  • 内存低于水位线 -> 唤醒 kswapd
  • 从LRU inactivate链表挑出冷页
  • 借助rmap反向映射机制找到哪些进程映射了该页,然后unmap解除掉该页在所有进程页表中的映射关系
  • 把物理页框归还伙伴系统。

常见误会是把回收等同于 swap。实际上 swap 只是回收对匿名页的处置分支,一个被选中的页有三条出路:

  • 文件页:在磁盘上有备份,干净的直接丢,脏的写回文件;
  • 匿名页:没有备份,必须先抄到 swap 区,没有"干净直接丢"这个选项
  • 实在挤不出页框,才轮到 OOM Killer 杀进程。

先带着四个问题往下读,全文结束时应能全部回答:

  • 内核怎么知道内存不够了,又该在什么时候出手?
  • 凭什么抢这一页,而不是那一页?
  • 抢之前,要不要跟正在用它的进程打个招呼?
  • 进程下次访问被抢走的页,会发生什么?

plantuml diagram

全景:监控、触发与三条出路

整套机制按内存紧张程度分三档,先看全景:

plantuml diagram

三个角色值得先记住:

  • 监控不是专职的。内核没有专门巡逻内存的线程,每次内存分配都会顺手查一下水位,分配路径本身就是监控探头
  • kswapd(kernel swap daemon) 是后台保姆。内存刚紧张时它异步干活,不阻塞任何进程,目标是让直接回收尽量别发生
  • swap 只是三分支之一。处置方式由"这页有没有磁盘备份"决定,这是第五章的核心分叉

后面每一章就是走到这张图的一个站点细看:先认全两端的主角(第一章),怎么触发(第二章),怎么挑页(第三章),怎么拆掉映射(第四章),怎么处置(第五章),匿名页为什么必须去仓库(第六章),最后跟着一个匿名页完整走一遍(终章)。

一、两端的身份证:folio 与 PTE

走进机制之前,先把舞台上的两个主角认全:一个在物理内存端,一个在虚拟内存端,后面每一章都在它们身上做文章。

folio:物理页的账本

内核用 struct folio 描述一个物理页,它是老代码 struct page 的封装,还能把透明大页这样的复合页(2MB 整块)当一个整体处理。回收关心的字段:

  • mapping:这页的归属对象。匿名页指 anon_vma,文件页指 address_space,而且最低位被抠出来当类型标签,1 是匿名页,0 是文件页。两个指针都 4 字节对齐,低位天然为 0,一个布尔量白捡。这个标签是第五章处置分叉的判据
  • index:页在映射里的偏移。反向映射靠它反推这页在每个映射它的进程里各自的虚拟地址,fork 后父子进程算出的 VA 不同(第四章)
  • flags:位图。PG_activePG_referenced 两位组成冷热计数器(第三章主角),PG_dirty 记录脏(第五章主角),PG_swapbacked 决定排匿名队还是文件队(第三章,shmem 这类文件长相匿名命运的页也置它)
  • lru:把页挂上 LRU 链表的节点(第三章)
  • _refcount:数"现在有几个地方在用这个页",页缓存算一个,内核临时做 I/O 算一个,归 0 页框才能释放
  • _mapcount:数"有几个 PTE 指着这个页",-1 表示无人映射

_refcount_mapcount 不是一回事:前者管"什么时候能释放",后者管"和进程还有没有瓜葛"。一页干净的文件页躺在缓存里,_refcount 为 1、_mapcount 为 -1,活着但没人用,这正是回收最爱的目标。两个计数器的完整辨析在第四章,这里先记住分工。

PTE:虚拟端的门牌

物理页这头是 folio,虚拟那头是 PTE(Page Table Entry,页表项)。进程每次访问内存,硬件顺着页表走到 PTE,再按它记录的输出地址(OA,物理页框号)取数。ARM64 的 PTE 里有四个位,是硬件与回收机制之间的接口,贯穿全文:

名字 谁置位 在回收里的戏份
bit[0] Valid 内核 清零表示页不在内存,触发缺页异常;换出后剩余位存仓库地址(第六章)
bit[7] RDONLY(即 AP[2]) 内核置,硬件可清 只读门闩。硬件脏管理开启时它是脏指示:干净可写页带着它出生,首写被硬件清掉(第五章)
bit[10] AF(Access Flag) 硬件 页被访问过的案底,冷热判断的原始证据(第三章)
bit[51] DBM(Dirty Bit Modifier) 内核 可写位(PTE_WRITE 的别名),兼作硬件脏更新的候选资格(第五章)

AF 和 DBM 不是无条件硬件更新的,要打开 FEAT_HAFDBS(TCR_EL1.HA/HD 置 1)才由硬件置位。没开时,访问 AF=0 的页会直接触发 Access Flag Fault,由内核缺页处理把 AF 置 1。Linux 恰好拿这个特性做访问追踪:回收扫描前清掉 AF,之后谁访问谁就把它立起来。另外 AF=0 的描述符不能进 TLB,所以软件改 AF 不需要 TLB 失效操作。DBM 则由内核置位,Linux 把它借用作可写位(PTE_WRITE 的别名),硬件从不改它。硬件的写脏动作是清 RDONLY;没开硬件脏管理时,写案底记在软件位 PTE_DIRTY(bit55)上。脏的完整故事见第五章。

账本与金库:folio 怎么挂在物理内存上

讲到这里容易产生一个误会:PTE 的 OA 指向的"物理页",是不是就是 folio 这个结构体?不是。OA 指向的是存数据的物理页框,folio 是住在另一块内存里的账本,两者靠 PFN(物理页框号)挂钩。同一条 OA 有两条消费路径:硬件拿 PFN 换物理地址,访问柜子里的数据;内核拿同一个 PFN 去查账。

打个比方:物理内存是一排按 PFN 编号的储物柜,柜里是用户数据;内核在 vmemmap 虚拟区建了一本等长的登记簿,所有 struct page 排成一个大数组,PFN 就是数组下标,一格对一柜。PTE 上记的是柜号,folio 是登记簿上那一格:

1
2
3
4
5
6
7
8
9
/* include/asm-generic/memory_model.h, ARM64 走 SPARSEMEM_VMEMMAP 分支 */
#define __pfn_to_page(pfn) (vmemmap + (pfn)) /* 柜号 → 登记簿第 n 格 */
#define __page_to_pfn(page) (unsigned long)((page) - vmemmap) /* 反算柜号 */

/* include/linux/mm.h */
static inline struct folio *pfn_folio(unsigned long pfn)
{
return page_folio(pfn_to_page(pfn)); /* 复合页取 head page */
}

注意 pfn_to_page 算出来的是内核虚拟地址,不是物理地址:数据页的 PA 有公式(PFN 左移 12 位拼页内偏移),而 struct page 自己存在哪块物理内存,由 vmemmap 建映射时的页表决定,没有算术公式——金库有公式,账本没有。

老书讲的 mem_map[] 数组就是这本登记簿的前身,现代内核把它虚拟地址连续化成了 vmemmap。为什么用虚拟映射,而不是直接声明一个全局大数组?因为物理内存本身不连续:NUMA 多节点加上内存空洞,直接数组必须连空洞一起预留格子,白吃物理内存。vmemmap 只保证虚拟地址连续,登记簿的底层页表按需填充,有物理内存的区域才真正映射,空洞区域的格子一个字节都不占。查找开销不变:PFN 就是下标,一次访存,O(1)。这本登记簿是物理内存管理的基石,伙伴系统分配释放、页缓存关联文件、LRU 回收,都要先经它找到 page,再操作其中的状态字段。

画成图(画法学自内核源码自己的注释,arch/powerpc/mm/book3s64/hash_pgtable.c 里有一幅异曲同工的):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
vmemmap: struct page array (virtual, contiguous)    physical memory (sparse)
+--------------+
| page struct | index 0 -------------------------> data page, PFN 0
+--------------+
| page struct | index 1 -------------------------> data page, PFN 1
+--------------+
| no mapping | index 2 ( PFN 2, 3: memory hole )
+--------------+ ( these ledger pages
| no mapping | index 3 cost 0 byte )
+--------------+
| page struct | index 4 -------------------------> data page, PFN 4
+--------------+
...
index == PFN ==> pfn_to_page(n) = vmemmap + n ( one access, O(1) )

这张图还留了个问题:vmemmap 是虚拟地址,靠什么兑现?靠页表。而页表本身也是内存里的数据结构,这里把两者的关系一次说清。

ARM64(4KB 页,48 位虚拟地址)的页表是棵四级树:pgd → pud → pmd → pte。每一级就是一张普通的物理页,页里排 512 个条目,条目记着下一级页表页的物理地址,一路往下指;最底层的 pte 指向数据页,就是前面 PTE 表里那些位的主人。硬件访存时自动走这棵树,查用户地址从 TTBR0 出发,查内核地址从 TTBR1 出发。每个进程的虚拟空间分两半:下半是进程私有的用户区,上半是所有进程共享的内核区,vmemmap 就在上半。

所以 vmemmap 和页表的关系是内容与机制:vmemmap 是内核虚拟空间里的一段区域,装着 struct page 数组;它的翻译记录在内核页表里。vmemmap_populate_address()(mm/sparse-vmemmap.c)就是逐级填 pgd→pud→pmd→pte 条目,把 vmemmap 的虚拟地址映射到存放 struct page 的物理页上,登记簿本身也是被页表映射的。

再往深一层是个咬尾的圈:页表页自己也是物理页,在 vmemmap 里同样有登记,同样从伙伴系统分配。访问登记簿要过页表,页表页的账又记在登记簿里。内存管理是自举的:启动时先用汇编搭好的最小页表撑起基本映射,然后用这套机制管理包括它自己在内的全部内存:

1
2
3
4
5
6
7
8
 pgd page --entry--> pud page --entry--> pmd page --entry--> pte page --entry--> data page
| | |
v v v
[struct page] [struct page] [struct page] <-- vmemmap
( table pages are normal physical pages: registered
in the ledger, allocated from the buddy allocator )

ledger VA --(kernel page table, TTBR1)--> struct page storage pages

plantuml diagram

一本账的开销:struct page 约 64 字节,每 1GB 物理内存对应约 16MB 登记簿,不到 2%。回收全程翻的是登记簿(folio 的标志位、计数器)、改的是门牌(PTE),不碰柜内物品;唯一的例外是匿名页换出,那是要把柜里的内容抄去 swap 仓库。

纸上谈完,动手走一遍。MMU 走四级页表从 VA 取出 PFN 是硬件的活,一笔带过;这里演示软件侧的两个方向:正向 pfn_to_page,拿着柜号查账本;反向 page_to_pfn,拿着账本指针反算柜号(输入 PFN,vmemmap 基址取 48 位 VA / 4KB 页配置的典型值,sizeof(struct page) 按 64 字节):

多对一:回收麻烦的根源

plantuml diagram

多个 PTE 可以指向同一个物理页框:fork 之后父子进程共享同一页,两套页表各有一条 PTE 记着同一个柜号,数据只有一个副本,而柜子的账本 folio 只有一本,_mapcount 记的就是门的数量(基数 -1,两扇门记成 1)。回收的全部麻烦就出在这个多对一关系上:释放页框之前,得先把每一扇门都拆掉,漏掉一扇,进程就顺着旧映射踩进已复用的页框。怎么找到所有指向它的 PTE,是第四章反向映射要解决的问题。

vma(虚拟内存区域,描述进程一段连续虚拟地址的结构)按下不表。注意它不是映射的"端":vma 只描述一段虚拟地址区间的属性,并不记录这段区间当前落在哪些物理页上,指向物理页框的只有 PTE 的输出地址。它是第四章反向映射的落脚点,rmap 的反向查找走到 vma 为止,再由 vma 正向走页表拿到 PTE。到那时"怎么找到所有指向它的 PTE"这个问题背景齐了,它出场才有意义。

二、监控与触发:水位三线与三个入口

水位像水库的三条刻度线,刻在水区描述符 zone->_watermark[] 里:

  • high:充裕线。kswapd 回收到这里才睡觉
  • low:警戒线。空闲页跌破此线,叫醒 kswapd
  • min:保命线。给紧急分配留的底,跌破此线的分配只能动用储备甚至直接回收

知道这三条线的位置,就能预测整套行为:分配路径要求空闲页高于 low 才走快速路径(代码里 __alloc_frozen_pages_noprof 给快速路径设的就是 ALLOC_WMARK_LOW),低于 low 进慢速路径;慢速路径第一件事是叫醒 kswapd;等不起了才自己下场;kswapd 一旦醒来要干到 high 才睡,高低水位之间的缓冲带避免了频繁醒睡的抖动。

plantuml diagram

直接回收和 kswapd 最终汇聚到同一个函数 shrink_node(),另外还有用户主动发起的第三个入口:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* 入口一: 直接回收, 要内存的进程自己动手 */
__alloc_frozen_pages_noprof /* mm/page_alloc.c */
+-> get_page_from_freelist /* 快速路径, low 水位判据 */
+-> __alloc_pages_slowpath /* 慢速路径 */
+-> wake_all_kswapds /* 先叫醒 kswapd */
+-> __alloc_pages_direct_reclaim
+-> __perform_reclaim
+-> try_to_free_pages /* mm/vmscan.c */
+-> do_try_to_free_pages
+-> shrink_zones
+-> shrink_node /* 汇聚点 */

/* 入口二: kswapd, 每个 NUMA 节点一个内核线程 */
kswapd /* mm/vmscan.c */
+-> balance_pgdat
+-> kswapd_shrink_node
+-> shrink_node /* 汇聚点 */

/* 入口三: 主动回收, 用户说了算 */
madvise(MADV_PAGEOUT) /* mm/madvise.c */
+-> reclaim_pages /* mm/vmscan.c */
+-> reclaim_folio_list
+-> shrink_folio_list /* 直达流水线 */

shrink_node() 内部还有一次分叉。MGLRU(多代 LRU,第三章末尾展开)开启时走按世代组织的新算法,否则走传统 LRU 路径,两条路最终都落到第五章的流水线:

1
2
3
4
5
6
7
8
9
shrink_node                              /* mm/vmscan.c */
+-> lru_gen_shrink_node /* MGLRU 新算法 */
+-> shrink_node_memcgs /* 传统路径: 遍历内存 cgroup */
+-> shrink_lruvec
+-> shrink_list
+-> shrink_active_list /* active 尾部老化 */
+-> shrink_inactive_list
+-> isolate_lru_folios /* 从 LRU 摘候选页 */
+-> shrink_folio_list /* 第五章的流水线 */

顺带一提,现代内核还有更主动的 DAMON 子系统:它不等水位,而是抽样统计各内存区域的访问频率,把冷得彻底的区域直接交给 reclaim_pages() 处理。它解决的是"冷热检测和回收耦合在一起导致检测不准"的问题,可以理解为把图1 全景里"选页"这一步外包给了一个专职侦探。

三、选页:LRU 冷热分离

触发了回收,下一个问题是挑谁。依据是局部性原理:很久没人访问的页,将来被访问的概率也小。内核用 LRU 链表给页排队,像图书馆的借阅排序,最近被翻过的书放前排,最久没人碰的自然沉到队尾,回收只从队尾下手。排队用的家当,lru 链表节点和 flags 里的两位计数器,第一章已经认过,现在看它们怎么转。

链表本身按"匿名/文件"和"活跃/不活跃"分成 4 条,外加一条不可回收链表:

1
2
3
4
5
6
7
8
9
10
/* include/linux/mmzone.h */
enum lru_list {
LRU_INACTIVE_ANON = LRU_BASE, //匿名页的 INACTIVE 链表
LRU_ACTIVE_ANON = LRU_BASE + LRU_ACTIVE, //匿名页的 ACTIVATE 链表
LRU_INACTIVE_FILE = LRU_BASE + LRU_FILE, //文件页的 INACTIVE 链表
LRU_ACTIVE_FILE = LRU_BASE + LRU_FILE + LRU_ACTIVE, //文件页的 ACTIVATE 链表
LRU_UNEVICTABLE, //不可回收链表: 公共设施,动了系统瘫痪
// 比如内核代码、内核栈、mlock 锁定的页
NR_LRU_LISTS //总链表数:5
};

注意新页一出生就挂 inactive,不是 active。进哪条链,查的还是第一章的身份证,folio_lru_list()(include/linux/mm_inline.h)三步定夺:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static __always_inline enum lru_list folio_lru_list(const struct folio *folio)
{
enum lru_list lru;

if (folio_test_unevictable(folio)) /* mlock 页, 直接进 UNEVICTABLE */
return LRU_UNEVICTABLE;

lru = folio_is_file_lru(folio) ? LRU_INACTIVE_FILE : LRU_INACTIVE_ANON;
if (folio_test_active(folio)) /* PG_active 置位, 升一档 */
lru += LRU_ACTIVE;

return lru;
}

/* 队别看的不是 mapping 低 bit, 是 PG_swapbacked */
static inline int folio_is_file_lru(const struct folio *folio)
{
return !folio_test_swapbacked(folio);
}

有个细节值得单独说:队别判断读 PG_swapbacked 而不是 mapping 低 bit,为的是让 shmem/tmpfs 这类"文件长相、匿名命运"的页(有 address_space,但没有磁盘原版,脏了只能走 swap)挂进匿名队:

1
2
3
4
5
6
7
8
9
                PG_swapbacked = 0            PG_swapbacked = 1
(file pages) (anon + shmem)
+------------------------+------------------------+
inactive | LRU_INACTIVE_FILE | LRU_INACTIVE_ANON | <-- reclaim
+------------------------+------------------------+ picks tail
active | LRU_ACTIVE_FILE | LRU_ACTIVE_ANON |
+------------------------+------------------------+
unevictable| LRU_UNEVICTABLE (mlock / SHM_LOCK) |
+-------------------------------------------------+

链表本体是 lruvec 里的一个数组:

1
2
3
4
5
6
7
8
9
/* include/linux/mmzone.h */
struct lruvec {
struct list_head lists[NR_LRU_LISTS]; /* 5 条双向链表 */
spinlock_t lru_lock; /* 本 lruvec 专用锁 */
// ...
#ifdef CONFIG_LRU_GEN
struct lru_gen_folio lrugen; /* MGLRU 数据, 开启时用 */
#endif
};

lruvec 本身每个 (NUMA 节点, 内存 cgroup) 组合一份:无 memcg 时用 pg_data_t->__lruvec,有 memcg 时用 mem_cgroup->nodeinfo[nid]->lruvec。每个 cgroup 一套独立账本,容器之间互不偷页,谁超了限额就收谁的。

冷热怎么判断?硬件在进程访问页时自动置 PTE 的 AF 位(Access Flag),内核回收扫描时收割这些位,折算成软件标志。判断规则是第二次机会法,像缓刑制度:第一次被抓(被访问)不处决,清掉标记放回,再犯才处理。

plantuml diagram

为什么两次才升级?设想一个每小时写一次日志的进程,一次访问就标热的话,它会永远霸占 active 链表。两次访问确认的是"近期反复使用",这才是热的确凿证据。

真正做判决的函数是 folio_check_references(),它综合 AF 收割结果和软件标志,给出三值判决:

情况 判决 去向
无任何引用,AF 全 0 RECLAIM 进入第五章流水线
匿名页有引用,或可执行文件页有引用 ACTIVATE 升回 active
文件页第一次被引用 KEEP 留在 inactive,下次再判

注意判决发生在拆映射之前。热页在这一关就被放走,根本走不到拆映射那一步,这是对热页的第一道保护。

传统 LRU 之外还有 MGLRU(Multi-Gen LRU):页按访问时间归入多个世代,年轻代留内存,年老代优先回收,判定冷热不再靠两次访问的启发式,而是直接看落在第几代。开启后 shrink_node()lru_gen_shrink_node(),传统链表闲置。思想是同一棵树,本文主线沿传统路径讲。

四、拆映射:反向映射与两个计数器

判决 RECLAIM 的页,接下来要处理它和进程的瓜葛。进程页表里还留着指向这个物理页的条目,不清掉就释放页框,进程下次访问这个虚拟地址,硬件会顺着页表走到一个已被复用的页框,读到别人的数据,静默损坏,排查极难。

页身上有个专门数这个瓜葛的计数器 _mapcount:数有几个用户态 PTE 指向这个页,基数是 -1。-1 表示无人映射,0 表示一个,1 表示两个。回收前必须把它打回 -1。

被引用和被映射不是一回事

folio 上还有另一个计数器 _refcount,初学者极易把它和 _mapcount 混为一谈:都是"谁在用这个页",有区别吗?

有。"被进程页表指向"只是"被引用"的其中一种方式,_refcount 还多统计两类和进程无关的持有者:

  • 页缓存:文件数据读进内存后会留在缓存里备查,哪怕所有进程都退出,缓存这一份引用还在,为了下次读同一文件不用碰磁盘
  • 内核临时引用:页在做 I/O 或迁移的中途,内核会临时握住它,防止半路被释放

只有 PTE 映射这种引用,才是两个计数同时加一。看一个文件页的时间线:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
时刻1: read() 读文件, 数据进页缓存
_refcount = 1 _mapcount = -1
没有进程在用, 但页活着, 缓存要它

时刻2: 进程 mmap 该文件并访问
_refcount = 2 _mapcount = 0
PTE 建立, 两个计数一起 +1 (重合区)

时刻3: 进程退出, 解除映射
_refcount = 1 _mapcount = -1
又只剩缓存一份引用

时刻4: 内存紧张, 回收扫到它
_mapcount = -1 → 没有进程瓜葛, 跳过反向映射
从缓存摘除 → _refcount = 0 → 页框归还伙伴系统

分叉全在时刻 1 和时刻 3:页活着(_refcount > 0)但没有进程在用(_mapcount = -1)。内核分开数它们,是因为回收要做两个不同的决策:_mapcount 决定要不要拆映射,大于 -1 就必须走反向映射把每扇门拆掉;_refcount 决定页框什么时候真正归还,归零才能还给伙伴系统。时刻 4 也解释了为什么回收最爱 _mapcount 为 -1 的干净缓存页:跳过整个反向映射,直接摘除释放,成本最低。

先看正向:进程怎么组织地址空间

反向查询之前,先看正向:进程自己怎么记住它的地址空间。一条链走下来:task_struct 的 mm 指向 mm_struct(内核线程的 mm 为 NULL,它们借用别人的地址空间而不拥有);mm_struct 是全貌,pgd 是页表树的根,进程切换时被转成物理地址载入 TTBR0;mm_mt 挂着进程所有的 vma。

老书在这里写的是 mmap 链表加 mm_rb 红黑树,现代内核(v6.1 起)换成了 maple 树:一颗树同时管有序遍历和 O(logN) 查找,链表、红黑树连同 vma 的 vm_next 字段都删了。

vma 把本章要用的线索集齐:vm_start/vm_end 圈出区间,vm_flags 记权限,vm_file 在文件映射时一路指到 address_space,anon_vma_chain 是匿名户口的挂钩,后两者正是下一节反向查找的两本户口本。缺页异常发生时,内核先在 maple 树里查地址属于哪个 vma,再沿 pgd 下探页表,把新 PTE 装进去:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
task_struct                    // 进程描述符, 每个进程一份

│ task->mm ──指向──▶ mm_struct (内核线程的 mm 为 NULL)


mm_struct // 进程地址空间全貌
├── pgd // 页表树的根; 切换时转物理地址装入 TTBR0
├── mm_mt // maple 树, 挂所有 vma, O(logN) 查区间
│ // 旧内核: mmap 链表 + mm_rb 红黑树, v6.1 起移除
└── total_vm // 映射的虚拟页总数

│ mm_mt ──挂──▶ vm_area_struct (每个连续区间一个)


vm_area_struct (VMA) // 一段连续的虚拟地址区间
├── vm_start / vm_end // 区间 = [vm_start, vm_end)
├── vm_flags // VM_READ / VM_WRITE / VM_EXEC
├── vm_mm // 指回所属的 mm_struct
├── vm_file // 文件映射: 一路指到 address_space
├── vm_ops // 缺页回调 fault() 等
└── anon_vma_chain // 匿名反向映射的挂钩, 下一节主角

│ 缺页: 先在 mm_mt 查 VA 属于哪个 vma, 再沿 pgd 下探页表建 PTE


页表四级树 (48 位 VA, 4KB 页, 每级 512 项; 软件建表, 硬件 MMU 遍历)

VA[47:39] → pgd_t (PGD) 一级, 页全局目录

└ VA[38:30] → pud_t (PUD) 二级, 页上级目录

└ VA[29:21] → pmd_t (PMD) 三级, 页中间目录

└ VA[20:12] → pte_t (PTE) 四级, 页表项


PTE 的 OA = PFN ──指向──▶ 物理页框

正向是"从进程找到页",回收要的是反着来。

怎么找到所有指向它的 PTE

_mapcount 打回 -1,说起来一句话,做起来要先回答:物理页怎么知道谁在映射它?页表里只有"虚拟地址到物理页"的正向记录,反着查没有索引。暴力解法是扫描所有进程的所有页表,几 GB 内存配几百个进程,天文数字。

内核的解法是登记户口,而不是挨家搜查。匿名页出生时,把包含它的 vma(进程的一段连续地址区间)登记进 anon_vma;要找映射时,查户口本拿到 vma 列表。注意这个查找是两段式的:

  • 第一段是反向:rmap 数据结构从 folio 走到 vma,下一张图画的就是这段
  • 第二段是正向:拿到 vma 后,普通的页表遍历走到 PTE

为什么反向只到 vma,不直接记下每个 PTE?PTE 条数是内存大小除以 4KB,16GB 内存约 400 万条,给每页挂一串 PTE 指针,记账本身就把内存吃爆,Linux 2.5 的第一版 rmap(pte_chain 方案)正是死在这上面。而 vma 每进程只有几十上百个,从 vma 到 PTE 又可以现算:folio 的 index 记着页在映射里的偏移,配上某个 vma 的起始位置,就得到该 vma 所属进程里的虚拟地址,顺着那个进程的页表走几层就到。同一个物理页在不同进程里的虚拟地址不同,所以这一步每个 vma 各算各的。

plantuml diagram

图里的枢纽是 anon_vma_chain(avc),一头连 anon_vma,一头连 vma。fork 之后父子进程共享匿名页,每个进程的 vma 各挂一条 avc 到同一个 anon_vma 上,找到任何一个就能顺藤摸瓜找到全部。

执行清除的是 try_to_unmap(),它本身只是一层薄封装:构造一个 rmap_walk_control,挂上回调,然后 rmap_walk() 完成第一段反向遍历,对每个 (vma, 虚拟地址) 组合执行 try_to_unmap_one()。这个回调自己完成第二段,从给定地址走页表定位到 PTE,再清条目,刷 TLB,减计数。

热页的保护在这之前就完成了:第三章的 folio_check_references() 通过 folio_referenced() 做过 AF 收割(回调函数 folio_referenced_one 里清每个 PTE 的 AF 位并计数),判决为热的页根本轮不到 try_to_unmap()。判决在前,行刑在后,顺序不能反。

五、处置:有无磁盘备份定生死

映射拆干净,页和进程再无瓜葛,接下来怎么处置?全景已经预告了分叉,现在把流水线展开。先看五条编号路径的全景图:

plantuml diagram

  • ① 新页入队:缺页异常分配新页后,folio_add_lru() 挂上 inactive 链表
  • ② 尾部老化:shrink_active_list() 从 active 尾部摘页,folio_referenced() 收割 AF 位。有引用的可执行文件页留在 active,其余降级去 inactive
  • ③④⑤ 是流水线对每个候选页的三种判决结局,ACTIVATE 升回去,KEEP 留下来,RECLAIM 走完流水线

流水线内部

对判决 RECLAIM 的页,shrink_folio_list() 依次执行:

plantuml diagram

分叉的判据回到第一章埋的伏笔:mapping 最低位的类型标签。

文件页的处置分两路。干净页最好办,__remove_mapping() 把它从页缓存摘除,_refcount 归零,free_cold_page() 把页框还给伙伴系统,一步到位。脏页走另一路:回收路径不负责写回(内核 v6.19 起,commit "mm: vmscan: simplify the logic for activating dirty file folios" 改掉的老行为),只打上 PG_reclaim 标记放回 LRU,交给 writeback 子系统的 flusher 线程慢慢写文件,下轮扫描发现页变干净了再直接丢。回收线程绝不能被磁盘速度拖死,这是全流程异步化的原因。

匿名页没有磁盘原版,必须走 swap 流程:先 folio_alloc_swap() 订页槽、folio_mark_dirty() 标记必须写盘,拆完映射后 pageout() 异步写入 swap 区,I/O 完成回调收尾,页框才能释放。这条路的完整故事是第六章。

同一道 PG_dirty 判决,两种含义

流水线里那道"PG_dirty 置位?"的判决对两类页都跑,含义却完全不同。先把脏相关的标志数清楚,ARM64 上从近到远:

  • RDONLY(bit7),硬件脏管理(HD)开启时的脏指示:干净可写页带着它出生,首写被硬件清掉
  • PTE_DIRTY(bit55),PTE 的软件脏形态,HD 没开时首写触发 Permission Fault,由缺页处理置上
  • DBM(bit51),可写位(PTE_WRITE 的别名),兼作硬件脏更新的候选资格
  • PG_dirty,folio 的页级标志,判决真正读的那个

PTE 级的脏怎么判,直接看内核代码。拆映射时 try_to_unmap_one() 先摘下 PTE 的旧值,再用 pte_dirty() 判它,判中就把脏搬到页级:

1
2
3
4
5
6
7
8
9
/* arch/arm64/include/asm/pgtable.h */
#define pte_hw_dirty(pte) (pte_write(pte) && !pte_rdonly(pte)) /* DBM 且非只读 */
#define pte_sw_dirty(pte) (!!(pte_val(pte) & PTE_DIRTY)) /* bit55 软件位 */
#define pte_dirty(pte) (pte_sw_dirty(pte) || pte_hw_dirty(pte))

/* mm/rmap.c, try_to_unmap_one() 内部 */
pteval = get_and_clear_ptes(mm, address, pvmw.pte, nr_pages); /* 摘下 PTE 旧值 */
if (pte_dirty(pteval))
folio_mark_dirty(folio); /* 映射级脏 → 页级 PG_dirty */

pte_dirty() 是两种形态的,不单独依赖 DBM。两种模式下,干净可写页的初始编码是一样的:PTE_WRITE(DBM)=1 且 PTE_RDONLY=1,即"标注可写,实际只读"。差别只在首写由谁放行:HD=1 时,硬件原子地把描述符从 writable-clean 改成 writable-dirty,动作是清 RDONLY,不触发异常,pte_hw_dirty 命中;HD=0 时,首写触发 Permission Fault,缺页处理调 pte_mkdirty() 置软件位 PTE_DIRTY 并清 PTE_RDONLY,pte_sw_dirty 命中。两路殊途同归,unmap 收割时都能收到。顺带一提,pte_wrprotect() 撤写权限时发现硬件脏,会把 PTE_DIRTY 一并置上,这是硬件形态折叠进软件形态的换算点。

  • 文件页的 PG_dirty 记录一个事实:这页被改过吗?没改,直接丢;改过,写回。判决是真分叉
  • 匿名页的 PG_dirty 编码一道命令:必须写盘。订完 swap 页槽,内核立刻 folio_mark_dirty() 强制置脏,判决必然通过,匿名页没有"干净直接丢"的出口

时序上要把先后分清:强制置脏在前,PG_dirty 判断在后,中间隔着拆映射。所以对匿名页来说,这道判断近乎形式检查,真正读到状态时已经被定格为脏。它不是废话,因为这道 if 是两类页共用的同一行代码,读的只是页的当前状态,不问状态是谁写上去的。文件页走到这里,状态是真实的历史记录,判断才有分流意义;匿名页走到这里之前,内核已经把状态写好了。同一个路口,为文件页服务,匿名页提前拿了通行证。

强制置脏不是多余动作。通常拆映射时 PTE 的脏位会把页自然染脏,判决本来就能过;但 MADV_FREE 页(进程调用 madvise 主动声明这段匿名内存不要了)的 PTE 脏位已被清掉,不补这一笔的话,页槽订了、页没写盘就释放,之后换入会从页槽读到垃圾。而真正声明过不要、后来也没被重新写的页,PG_swapbacked 标志已被清掉,回收时连 swap 都不进,直接丢弃并记入 PGLAZYFREED 计数,这是匿名页唯一免写盘的出口。最后把两处 folio_mark_dirty() 的分工说清:rmap 里那处记事实,条件是 pte_dirty(),页真被写过才染脏,可写不等于写过,从未被写的可写页 RDONLY 还在、PTE_DIRTY 为 0,unmap 时不会被染脏;vmscan 里订槽后那处下命令,匿名页无差别强制置脏(兜 MADV_FREE 的漏)。

六、swap 仓库:匿名页的保命机制

现在回答开头第四个问题的前半段。匿名页在磁盘上没有备份,内存里这份就是唯一原件,这决定了它丢不起:

  • 直接丢,进程不会收到友好报错。要么缺页后拿到一个清零的空页,数据悄悄消失;要么读到被复用的垃圾数据,静默损坏
  • 干脆杀进程重开,以网络服务为例,连接全断、缓存全冷、十几 GB 的模型重新加载,重启代价极高

swap 的答案是:把冷匿名页抄一份到磁盘再释放内存,进程真要用时缺页异常自动取回,只是慢一点,状态一个不少。AgentENV 沙箱用的是同一个思想:沙箱里跑了几小时的环境(激活的 conda、加载的十几 GB 模型),直接销毁再重建代价不可接受,所以快照时把内存状态落盘、恢复时 mmap 装回。用磁盘 I/O 换进程状态的完整保留,swap 就是内核里这套机制的自动化版本,区别只是粒度:AgentENV 快照整台虚机,swap 只搬冷页。

仓库长什么样

  • swap 区:专门的磁盘分区或文件,按 4KB 一格切分,每格叫页槽
  • 第一个页槽是仓库须知(swap_header):末尾有魔数 "SWAPSPACE2",激活时校验,还有坏块列表

页换出后,它的 PTE 不能浪费。ARM64 上 Valid 位清零让描述符变无效,剩余位存换出页标识符 swp_entry_t(swap 区号加页槽号)。一个 64 位字既当地址又当失踪去向登记条,零额外内存开销。进程下次访问这个虚拟地址,Valid=0 触发 Translation Fault(缺页异常),异常处理程序照着登记条去仓库取货。

并发取存要防打架:进程 A 正把页 X 从仓库取回,同时回收流程觉得 X 冷门要送回仓库,一取一存如果落在不同页框上,数据就乱了。规则是任何换入换出前先查交换缓存(一个全局 XArray 基树,登记所有"正在路上"的匿名页),并发操作发现页已在缓存,就复用同一个页框,靠页锁排队。

换入:do_swap_page

plantuml diagram

预读值得单独说:swapin_readahead() 不只取目标页槽,还把物理相邻的一串(默认 8 页)一起取回。磁盘顺序读比随机读快一个数量级,赌的是进程马上访问隔壁页,局部性原理再次出手。

没有 swap 会怎样?get_scan_count() 里的 can_reclaim_anon_pages() 发现 swap 余量为 0,就只扫文件链表,匿名页全部跳过,压力全砸在文件页缓存上。文件页挤干之后,来的就不是回收而是 OOM Killer:按占内存多、运行时间短、优先级低、非 root 的经验公式选牺牲品,发 SIGKILL,回收它的全部页框。

全家福:围绕 folio 的一基四问

六个章节走下来,登场的角色已经有十来个:folio、PTE、物理页框、vmemmap、mm_struct、vma、anon_vma、anon_vma_chain、address_space、lruvec、swap 仓库。散在各章容易乱,这里把它们拍进同一张照片。不用背,记住一个中心、一基四问,所有结构自动归位:

  • 一基(PFN 三角):物理页框是金库,vmemmap 是登记簿,folio 是登记簿上的一格。PFN 既是柜号又是下标,把三者锁死
  • 问1 谁指着我:_mapcount 数门,PTE 是门,门在页表里,页表根在 mm_struct 的 pgd。fork 之后父子各有一扇,门的数量可以大于一
  • 问2 我冷还是热:flags 里的两位计数器记账,AF 是硬件留下的原始证据;lru 把 folio 挂上 lruvec 的 5 条链表,队列位置就是冷热的外化,回收只从 inactive 尾部下手
  • 问3 我的备份在哪:mapping 低 bit 分流。匿名页走户口链(anon_vma → avc → vma),文件页有 address_space 原版
  • 问4 我何时能走:_refcount 归零才释放;匿名页走之前要经 swap 仓库(swp_entry_t 存门牌,swap cache 防路上的并发打架)

plantuml diagram

读图的方法:箭头都是"指针或可直达的索引"关系,按方向读。两个容易踩的坑再钉一遍:PTE 的 OA 指向物理页框,不指向 folio(folio 靠 PFN 下标查到);vma 的 vm_mm 指回 mm_struct,让"户口链走到页表根"形成闭环。

速查表,遇到"这是哪章的结构"就翻这里:

结构 住哪 和谁相连 登场
物理页框 物理内存 PTE 的 OA 指它;folio 是它的账本 第一章
vmemmap 内核虚拟区 PFN 当下标,一格对一柜 第一章
folio vmemmap 的一格 中心枢纽,一基四问的主角 第一章
PTE/页表四级树 进程页表 条目逐级下指;OA 指页框,换出后存 swp_entry_t 第一章
task_struct 每个进程一份 mm 指向 mm_struct;内核线程为 NULL 第四章
mm_struct 每个进程一份 pgd 是页表根;mm_mt(maple 树)挂所有 vma;被 vm_mm 指回 第四章
lruvec 每 (node, memcg) 一份 folio 的 lru 挂它的链表 第三章
vma 进程地址空间 avc 连 anon_vma;vm_mm 连 mm_struct 第四章
anon_vma / avc 匿名页的户口 连 folio 的 mapping 和 vma 第四章
address_space 每个文件一个 文件页的 mapping;i_mmap 树挂 vma 第四章
swp_entry_t PTE 的剩余位 换出页的仓库门牌 第六章
swap cache 全局 XArray 换入换出路上防打架 第六章

静态关系就位。下面看它们怎么动起来:一个匿名页的一生。

终章:一个匿名页的一生

让前面所有机制在一个页身上依次发生。这是全文的检验:能不看文章讲完这个故事,就真的学会了。

plantuml diagram

诞生。进程第一次写一个匿名 mmap 区,缺页异常走进 do_anonymous_page(),分配新页,建立 PTE,folio_add_lru() 把它挂上 inactive 匿名链表(第五章的路径①)。此刻它 _mapcount 为 0,PG_activePG_referenced 都是 0,一个刚出生的冷页。

服务期。进程频繁读写它,硬件自动置 PTE 的 AF 位。回收扫描时 folio_referenced() 收割 AF 位,第一次访问置 PG_referenced,第二次访问 folio_mark_accessed() 把它升级进 active 链表。它是热页,判决放行,没人动得了它。

老化。进程转向别的工作,很久不碰这个页。它从 active 头部慢慢沉到尾部,shrink_active_list() 一刀把它降回 inactive(路径②),打上"曾经热过"的 workingset 标记。它开始排队等待审判。

换出。内存吃紧,shrink_folio_list() 找上了它。folio_check_references() 判决 RECLAIM,folio_alloc_swap() 在 swap 区订一个页槽,folio_mark_dirty() 标记必须写盘,try_to_unmap() 清掉 PTE(第四章),pageout() 异步写入 swap 区,I/O 完成回调收尾,页框归还伙伴系统。PTE 里 Valid=0,剩余位记着它去了哪个页槽,一张失踪登记条。

仓库。它躺在 swap 区的页槽里,不占一字节内存,只有 PTE 里那串编码记得它。

换入。某天进程又访问那个虚拟地址,Valid=0 触发 Translation Fault,do_swap_page() 从 PTE 解析出 swp_entry_t,查交换缓存,未命中则预读取回,新页框填好数据,PTE 重新指回内存,反向映射重新登记。进程恢复执行,仿佛无事发生,只是慢了一点。

旧书对照速查

《深入理解 Linux 内核》第 17 章的框架依然成立,但实现早已换代:

旧(2.6 时代) 新(v7.3) 为什么改
struct page struct folio 大页时代逐 page 处理太碎
per-zone LRU per-node + per-memcg lruvec NUMA 与容器隔离
两位计数器 LRU + MGLRU 按代分世 扫描开销更低, 冷热更准
只能释放或 swap + demotion 降级到慢速内存层 DRAM + CXL 多层硬件
被动等水位 + DAMON 主动监控 冷热检测与回收解耦
回收路径写回脏文件页 v6.19 起只打标记等 writeback 回收线程不被磁盘拖死

总结

  • 水位是水库的三条刻度线:low 叫醒 kswapd,min 才逼进程亲自下场,high 才让保姆睡觉,监控探头就藏在每次内存分配里
  • 回收只从 LRU inactive 尾部下手,两位计数器保证两次访问才升级,偶尔被摸一下的页不配霸占内存
  • _refcount 管"什么时候能释放",_mapcount 管"和进程还有没有瓜葛",页可以活着但没人映射,干净缓存页正是回收的最爱
  • 文件页丢了有原版,匿名页丢了就是丢了,swap 是匿名页的保命机制,AgentENV 快照与它同构,都是用磁盘 I/O 换状态保留
  • 换出换入共用一个 PTE:Valid=0 时剩余位就是仓库门牌,进程下次访问自然触发取回
CATALOG
  1. 全景:监控、触发与三条出路
  2. 一、两端的身份证:folio 与 PTE
    1. folio:物理页的账本
    2. PTE:虚拟端的门牌
    3. 账本与金库:folio 怎么挂在物理内存上
    4. 多对一:回收麻烦的根源
  3. 二、监控与触发:水位三线与三个入口
  4. 三、选页:LRU 冷热分离
  5. 四、拆映射:反向映射与两个计数器
    1. 被引用和被映射不是一回事
    2. 先看正向:进程怎么组织地址空间
    3. 怎么找到所有指向它的 PTE
  6. 五、处置:有无磁盘备份定生死
    1. 流水线内部
    2. 同一道 PG_dirty 判决,两种含义
  7. 六、swap 仓库:匿名页的保命机制
    1. 仓库长什么样
    2. 换入:do_swap_page
  8. 全家福:围绕 folio 的一基四问
  9. 终章:一个匿名页的一生
  10. 旧书对照速查
  11. 总结