页面回收(Page Reclaim)是内核在内存不足时腾挪页框的机制:
- 内存低于水位线 -> 唤醒 kswapd
- 从LRU inactivate链表挑出冷页
- 借助rmap反向映射机制找到哪些进程映射了该页,然后unmap解除掉该页在所有进程页表中的映射关系
- 把物理页框归还伙伴系统。
常见误会是把回收等同于 swap。实际上 swap 只是回收对匿名页的处置分支,一个被选中的页有三条出路:
- 文件页:在磁盘上有备份,干净的直接丢,脏的写回文件;
- 匿名页:没有备份,必须先抄到 swap 区,没有"干净直接丢"这个选项
- 实在挤不出页框,才轮到 OOM Killer 杀进程。
先带着四个问题往下读,全文结束时应能全部回答:
- 内核怎么知道内存不够了,又该在什么时候出手?
- 凭什么抢这一页,而不是那一页?
- 抢之前,要不要跟正在用它的进程打个招呼?
- 进程下次访问被抢走的页,会发生什么?
全景:监控、触发与三条出路
整套机制按内存紧张程度分三档,先看全景:
三个角色值得先记住:
- 监控不是专职的。内核没有专门巡逻内存的线程,每次内存分配都会顺手查一下水位,分配路径本身就是监控探头
- 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_active和PG_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 | /* include/asm-generic/memory_model.h, ARM64 走 SPARSEMEM_VMEMMAP 分支 */ |
注意 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 | vmemmap: struct page array (virtual, contiguous) physical memory (sparse) |
这张图还留了个问题: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 | pgd page --entry--> pud page --entry--> pmd page --entry--> pte page --entry--> data page |
一本账的开销: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 字节):
多对一:回收麻烦的根源
多个 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 才睡,高低水位之间的缓冲带避免了频繁醒睡的抖动。
直接回收和 kswapd 最终汇聚到同一个函数
shrink_node(),另外还有用户主动发起的第三个入口:
1 | /* 入口一: 直接回收, 要内存的进程自己动手 */ |
shrink_node() 内部还有一次分叉。MGLRU(多代
LRU,第三章末尾展开)开启时走按世代组织的新算法,否则走传统 LRU
路径,两条路最终都落到第五章的流水线:
1 | shrink_node /* mm/vmscan.c */ |
顺带一提,现代内核还有更主动的 DAMON
子系统:它不等水位,而是抽样统计各内存区域的访问频率,把冷得彻底的区域直接交给
reclaim_pages()
处理。它解决的是"冷热检测和回收耦合在一起导致检测不准"的问题,可以理解为把图1
全景里"选页"这一步外包给了一个专职侦探。
三、选页:LRU 冷热分离
触发了回收,下一个问题是挑谁。依据是局部性原理:很久没人访问的页,将来被访问的概率也小。内核用
LRU
链表给页排队,像图书馆的借阅排序,最近被翻过的书放前排,最久没人碰的自然沉到队尾,回收只从队尾下手。排队用的家当,lru
链表节点和 flags
里的两位计数器,第一章已经认过,现在看它们怎么转。
链表本身按"匿名/文件"和"活跃/不活跃"分成 4 条,外加一条不可回收链表:
1 | /* include/linux/mmzone.h */ |
注意新页一出生就挂 inactive,不是
active。进哪条链,查的还是第一章的身份证,folio_lru_list()(include/linux/mm_inline.h)三步定夺:
1 | static __always_inline enum lru_list folio_lru_list(const struct folio *folio) |
有个细节值得单独说:队别判断读 PG_swapbacked 而不是
mapping 低 bit,为的是让 shmem/tmpfs 这类"文件长相、匿名命运"的页(有
address_space,但没有磁盘原版,脏了只能走 swap)挂进匿名队:
1 | PG_swapbacked = 0 PG_swapbacked = 1 |
链表本体是 lruvec 里的一个数组:
1 | /* include/linux/mmzone.h */ |
lruvec 本身每个 (NUMA 节点, 内存 cgroup) 组合一份:无 memcg 时用
pg_data_t->__lruvec,有 memcg 时用
mem_cgroup->nodeinfo[nid]->lruvec。每个 cgroup
一套独立账本,容器之间互不偷页,谁超了限额就收谁的。
冷热怎么判断?硬件在进程访问页时自动置 PTE 的 AF 位(Access Flag),内核回收扫描时收割这些位,折算成软件标志。判断规则是第二次机会法,像缓刑制度:第一次被抓(被访问)不处决,清掉标记放回,再犯才处理。
为什么两次才升级?设想一个每小时写一次日志的进程,一次访问就标热的话,它会永远霸占 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 | 时刻1: read() 读文件, 数据进页缓存 |
分叉全在时刻 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 | task_struct // 进程描述符, 每个进程一份 |
正向是"从进程找到页",回收要的是反着来。
怎么找到所有指向它的 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 各算各的。
图里的枢纽是 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()。判决在前,行刑在后,顺序不能反。
五、处置:有无磁盘备份定生死
映射拆干净,页和进程再无瓜葛,接下来怎么处置?全景已经预告了分叉,现在把流水线展开。先看五条编号路径的全景图:
- ①
新页入队:缺页异常分配新页后,
folio_add_lru()挂上 inactive 链表 - ② 尾部老化:
shrink_active_list()从 active 尾部摘页,folio_referenced()收割 AF 位。有引用的可执行文件页留在 active,其余降级去 inactive - ③④⑤ 是流水线对每个候选页的三种判决结局,ACTIVATE 升回去,KEEP 留下来,RECLAIM 走完流水线
流水线内部
对判决 RECLAIM 的页,shrink_folio_list() 依次执行:
分叉的判据回到第一章埋的伏笔: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 | /* arch/arm64/include/asm/pgtable.h */ |
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
预读值得单独说: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 防路上的并发打架)
读图的方法:箭头都是"指针或可直达的索引"关系,按方向读。两个容易踩的坑再钉一遍: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 | 换入换出路上防打架 | 第六章 |
静态关系就位。下面看它们怎么动起来:一个匿名页的一生。
终章:一个匿名页的一生
让前面所有机制在一个页身上依次发生。这是全文的检验:能不看文章讲完这个故事,就真的学会了。
诞生。进程第一次写一个匿名 mmap 区,缺页异常走进
do_anonymous_page(),分配新页,建立
PTE,folio_add_lru() 把它挂上 inactive
匿名链表(第五章的路径①)。此刻它 _mapcount 为
0,PG_active 和 PG_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 时剩余位就是仓库门牌,进程下次访问自然触发取回