图 1:本章导图——页框回收是虚拟内存子系统的收官之战
这一篇在干嘛?
前面各章讲了内核如何”分”内存:伙伴系统发页框、slab 分对象、缺页中断按需分配。但缓存和进程只拿不还,内存总有耗尽的一天。本章讲内核如何把页框”收”回来——页框回收算法(PFRA)、反向映射、LRU 链表,以及为匿名页兜底的交换(swap)子系统。不懂它,你就无法理解 Linux 为什么敢”慷慨”分配内存,也无法读懂
free、swappiness、OOM 杀进程这些日常现象背后的机制。
回收页框算法 | 反向映射 | LRU 链表 | 内存不足时的回收 | 可收缩的磁盘缓存 | 周期性回收 | OOM Killer 与交换令牌 | 交换区与页槽 | 交换缓存与换入换出
回收页框算法
Linux 分配内存时检查相当”敷衍”:不严格限制一个用户总共能用多少内存,也不限制各种磁盘缓存能长多大。这是故意的设计——内存闲着也是浪费,系统负载低时让缓存填满 RAM 提升性能,负载高时再把缓存缩掉腾地方。
但问题在于:缓存和进程都只借不还。磁盘/内存缓存不知道进程什么时候会重用数据,所以不敢主动释放;请求调页(demand paging,见 /linux内核/lk09)只管给进程发页框,也没有任何机制强迫进程归还。于是自由内存迟早被瓜分干净。
页框回收算法(Page Frame Reclaiming Algorithm,PFRA)就是来解决这个问题的:它从用户进程和内核缓存手里”偷”页框,把它们还给伙伴系统的空闲块链表(见 /linux内核/lk08)。
回收必须赶在内存彻底耗尽之前进行,否则会陷入死锁式的怪圈:释放一个页框需要先把它的内容写到磁盘,而写磁盘本身又要分配页框(比如缓冲区首部)——没有空闲页框,就永远腾不出空闲页框。所以 PFRA 的一个核心目标是始终保持一个最小的空闲页框储备池,让内核能安全地从”内存紧张”状态中恢复。
给页框分类:谁能收,怎么收
PFRA 按页框内容把它们分成四类,处理方式完全不同:
| 页类型 | 包含哪些页 | 回收动作 |
|---|---|---|
| 不可回收(Unreclaimable) | 空闲页、保留页(PG_reserved)、内核动态分配的页、内核态栈、临时锁定的页(PG_locked)、mlock 锁定的内存区(VM_LOCKED) | 不允许也不需要回收 |
| 可交换(Swappable) | 用户地址空间中的匿名页、tmpfs 文件系统的映射页(如 IPC 共享内存页) | 把内容存进交换区 |
| 可同步(Syncable) | 用户地址空间中的映射页、页缓存中的文件数据页、块设备缓冲页、部分磁盘缓存页 | 必要时与磁盘上的映像同步 |
| 可丢弃(Discardable) | 内存缓存(slab)中未使用的页、dentry 缓存中未使用的页 | 无需任何操作,直接扔 |
两个关键术语:**映射页(mapped)**指映射了某个文件一部分的页,它有磁盘映像,回收前只需检查脏位、必要时写回;**匿名页(anonymous)**属于匿名内存区(比如进程的堆和栈),磁盘上没有它的映像,回收前必须把内容存进专门的”交换区”(swap area),所以匿名页都是可交换页。特殊文件系统里的页一般不可回收,唯一例外是 tmpfs——它的页可以存入交换区(第 19 章会看到,IPC 共享内存就建在 tmpfs 上)。
PFRA 的几条总体设计规则
设计一个好的回收算法基本靠经验摸索,没什么理论支撑,而且代码变化很快——这里讲的是 Linux 2.6.11 的实现,但总体思路长期有效。PFRA 遵循这几条启发式规则:
- 先收”无害”的页。 磁盘/内存缓存中没被任何进程引用的页先回收——改它们不需要动任何页表项。
- 让用户进程的所有页都可回收。 除锁定页外,连匿名页也要能被”偷”走。这样睡了很久的进程会逐渐失去全部页框。
- 回收共享页框时一次性解除所有映射。 先清掉所有指向它的页表项,再回收。
- 只回收”未使用”的页。 用简化的 LRU(最近最少使用)原则判断:很久没被访问的页,近期被访问的概率也低。这是局部性原理(
/linux内核/lk02)的又一次应用。
理想情况下应该给每页配一个”年龄”计数器记录距上次访问的时间,可惜 80×86 处理器不提供这种硬件支持。Linux 的替代方案是:利用页表项里的 Accessed 位(硬件访问时自动置位),加上用页描述符在两条 LRU 链表中的位置来粗略代表页的”新旧”。
总之 PFRA 是多种启发式的混合体:仔细安排检查缓存的顺序、按新旧程度排序、区分脏页(要写盘)和干净页(不用写盘,是更好的候选)。
反向映射
要回收一个被多个进程共享的页框,必须找到所有指向它的页表项并清掉——这个活动叫反向映射(reverse mapping)。
最笨的办法是在每个页描述符里挂一条链表,把所有引用它的页表项串起来。但维护这条链表开销太大。Linux 2.6 采用基于对象(object-based)的反向映射:不为每个页表项建链,而是记录”哪些内存区包含了这页”。内存区描述符里有指向内存描述符的指针,内存描述符里又有页全局目录(PGD)的地址——顺着这条链,就能推导出所有相关页表项。内存区数量远小于页数量,更新这种反向链接便宜得多。
判断共享与匿名:_mapcount 和 mapping
内核靠页描述符的两个字段判断页的状态:
_mapcount:引用该页框的页表项数量,从 -1 起步(-1 表示没有任何页表项引用)。为 0 是非共享页,大于 0 是共享页。page_mapcount()函数返回_mapcount + 1。mapping:区分映射页与匿名页,利用地址对齐玩了个”脏技巧”:- 为 NULL:页在交换缓存中;
- 非 NULL 且最低位为 1:匿名页,字段编码的是
anon_vma描述符指针; - 非 NULL 且最低位为 0:映射页,字段指向文件的
address_space对象。
PageAnon() 函数就是检查 mapping 最低位。之所以敢用最低位做标志,是因为所有 address_space 对象都按 4 字节对齐,最低两位必然是 0——而内核里页描述符数以百万计,能省一个字段是一个字段。
统一的入口是 try_to_unmap() 函数:
int try_to_unmap(struct page *page)
{
int ret;
if (PageAnon(page))
ret = try_to_unmap_anon(page);
else
ret = try_to_unmap_file(page);
if (!page_mapped(page))
ret = SWAP_SUCCESS;
return ret;
}返回值三选一:SWAP_SUCCESS(0,所有引用都清掉了)、SWAP_AGAIN(1,部分引用清不掉)、SWAP_FAIL(2,出错)。匿名页和映射页走两条不同的路。
匿名页的反向映射:anon_vma 链表
匿名页也会共享——最常见的情形就是 fork():父进程的匿名页(和所有其他页)也被子进程继承(写时复制,见 /linux内核/lk09)。
方案很直接:把包含该页框的所有匿名内存区收集进一条双向循环链表。注意,即使一个匿名内存区包含很多页,整个区也只对应一条反向映射链表。
图 2:匿名页的基于对象反向映射——anon_vma 把多个进程的内存区串成一条链
具体机制:当内核给某个匿名区分配第一个页框时,创建一个 anon_vma 结构,只含两个字段——保护链表的 lock 自旋锁,和链表头 head。内存区描述符 vm_area_struct 为此加了两个字段:anon_vma_node(链表前后指针)和 anon_vma(指向所属 anon_vma)。同时内核把 anon_vma 的地址(带标志位)存进匿名页描述符的 mapping 字段。之后 fork() 让第二个进程也引用这个页框时,内核只是把第二个进程的匿名区也插进同一条 anon_vma 链表——所以这条链表上通常挂着属于不同进程的内存区。
有了这条链,内核要找某匿名页的全部页表项就轻车熟路:沿链表逐个取内存区,用 vm_mm 字段拿到内存描述符,再从 PGD 一路走到底,用区起始地址和页的 index 字段算出页表项位置。
try_to_unmap_anon() 的工作流程:加锁 → 遍历 anon_vma 链表,对每个内存区调用 try_to_unmap_one()(若返回 SWAP_FAIL 或 _mapcount 显示已找齐所有引用则提前收工)→ 解锁 → 返回结果。注意链表里可能混着不含目标页的区,所以必须逐一检查。
核心的 try_to_unmap_one() 函数
这个函数同时服务于匿名页和映射页,参数是目标页描述符和一个内存区描述符,主要步骤:
- 由区的起始线性地址
vm_start、区在文件中的偏移vm_pgoff、页在文件中的偏移page->index推算出目标页的线性地址。 - 若是匿名页,检查算出的地址是否落在区内;不在就返回 SWAP_AGAIN(因为 anon_vma 链表里可能有无关的区)。
- 从
vma->vm_mm拿到内存描述符,加page_table_lock自旋锁。 - 依次调用
pgd_offset()、pud_offset()、pmd_offset()、pte_offset_map()定位页表项。 - 一系列可回收性检查,任一失败就跳到收尾:
- 页表项确实指向目标页(否则 SWAP_AGAIN)。不匹配可能因为:页框是 COW 分配的但内存区还挂在原页框的 anon_vma 链上;
mremap()直接改过页表项;文件映射是非线性的(见/linux内核/lk16)——这些情况下靠index推地址的把戏会失灵。 - 内存区没有被锁定(VM_LOCKED)或保留(VM_RESERVED),否则 SWAP_FAIL。
- 页表项的 Accessed 位已清零;若还置着,说明页”正在使用”,清掉该位后返回 SWAP_FAIL(下次再来看它还置不置——这就是一种简单的老化机制)。
- 页属于交换缓存且正被
get_user_pages()处理时返回 SWAP_FAIL,避免竞争。
- 页表项确实指向目标页(否则 SWAP_AGAIN)。不匹配可能因为:页框是 COW 分配的但内存区还挂在原页框的 anon_vma 链上;
- 可以回收了:若页表项的 Dirty 位置位,设置页描述符的 PG_dirty 标志。
- 清空页表项并刷新对应 TLB。
- 若是匿名页,把一个换出页标识符写回页表项(之后访问这页会触发换入),并递减内存描述符的
anon_rss计数。 - 递减内存描述符的
rss计数(进程占用的页框数)。 - 递减页描述符的
_mapcount。 - 递减页框引用计数
_count;若变负,把页从 LRU 链表摘除并调free_hot_page()归还伙伴系统。 pte_unmap()释放第 4 步可能建立的高端内存临时内核映射(见/linux内核/lk08)。- 释放
page_table_lock自旋锁,返回结果。
映射页的反向映射:优先搜索树
映射页被共享的频率远高于匿名页——想想几乎每个进程都共享着 C 标准库的代码页。线性扫描链表太慢,Linux 2.6 改用优先搜索树(priority search tree,PST)。每个文件一棵 PST,树根存在该文件 inode 内嵌 address_space 对象的 i_mmap 字段里;而映射页描述符的 mapping 字段正好指向这个 address_space,所以找到树根毫不费力。
PST 基于 McCreight 1985 年提出的数据结构——堆和平衡搜索树的混血,擅长回答”哪些区间包含/相交给定区间”。每个节点代表一个内存区,即文件页的一个区间,用两个索引刻画:radix 索引(区间起点,即文件内起始页位置)作搜索树的键,heap 索引(区间终点)满足堆性质——父节点的 heap 索引不小于任何子节点。
Linux 做了两处改造:一是不保持平衡(平衡算法太贵);二是增加一个 size 索引(区间大小减一)。为什么需要它?因为内存区倾向于从文件第 0 页开始——大量区间起点相同,而 McCreight 原始结构不允许存起点相同的区间。size 索引区分了这些起点相同的区。若同起点的区多到放不下,还会在叶子处挂溢出子树。另外不同进程可能映射同一文件的同一部分(三个索引全相同),这时新区描述符直接挂到已有 PST 节点根部的双向循环链表里。
图 3:优先搜索树示例——左侧是 7 个内存区(radix, size, heap 三个索引),右侧是对应的 PST
查找示例:假设要找所有包含文件第 5 页的内存区。从根 (0,5,5) 开始——区间包含第 5 页,命中;访问左子 (0,4,4),heap 索引 4 小于 5,不含目标页,而且由于堆性质,它的所有子孙也不含,直接跳过;转到右子 (2,3,5),命中;再查 (1,2,3) 和 (2,0,2),都不含。搜索完毕。
PST 节点用 prio_tree_node 结构表示,内嵌在内存区描述符的 shared.prio_tree_node 字段;重复链表则复用 shared.vm_set。vma_prio_tree_insert() / vma_prio_tree_remove() 负责增删,vma_prio_tree_foreach 宏对覆盖指定线性地址范围的所有内存区做循环。
try_to_unmap_file() 处理线性映射时相当简单:拿 i_mmap_lock 自旋锁 → 用 vma_prio_tree_foreach 遍历 PST,对每个内存区调用 try_to_unmap_one() → 解锁返回。但非线性映射是个麻烦:页在文件中的位置(index)不再对应它在区中的位置,try_to_unmap_one() 算不出页的线性地址。唯一办法是对文件的所有非线性内存区做穷举扫描(区描述符链在 i_mmap_nonlinear 字段下),对每个区调 try_to_unmap_cluster() 扫页表项。为了不扫太久,只做有限扫描(vm_private_data 字段保存扫描游标)——代价是有时会漏掉目标页,这时 try_to_unmap() 发现页仍被映射,返回 SWAP_AGAIN,下回再试。
常见坑:反向映射不是万能的
基于”对象”的反向映射依赖
page->index能推算出页的线性地址。COW、mremap()、非线性映射都会打破这个前提,内核只能靠返回 SWAP_AGAIN 保守处理或穷举扫描兜底。理解这一点,才能明白为什么回收某些页会”暂时失败”,以及为什么非线性映射在实践中不受待见。
LRU 链表
PFRA 的核心数据结构是两条链表:active 链表(最近被访问过的页)和 inactive 链表(很久没被访问的页),合称 LRU 链表。显然,页框应该从 inactive 链表里偷。
这两条链表按内存区(zone)组织:每个区描述符里有 active_list 和 inactive_list 链表头、nr_active/nr_inactive 计数器,以及保护它们的 lru_lock 自旋锁。页描述符这边:PG_lru 置位表示在 LRU 链表里;PG_active 置位/清零表示在 active/inactive 链表;lru 字段是链表指针。
辅助函数一目了然:add_page_to_active_list() / add_page_to_inactive_list() / del_page_from_active_list() / del_page_from_inactive_list() 负责增删并维护计数;del_page_from_lru() 按 PG_active 判断从哪条链删;activate_page() 把页从 inactive 挪到 active;lru_cache_add() / lru_cache_add_active() 把新页插入 inactive/active 链表。
一个小优化:后两个函数并不立即动链表,而是把页先攒在 pagevec 临时结构里(最多攒 14 个页描述符指针),攒满了才批量搬进 LRU 链表——这样自旋锁只在真正修改链表时才被获取,SMP 系统上省了不少锁竞争。
页在两条链表间的流动
两种状态够用吗?不够。想象一个日志进程每小时往某页写一次数据:这页 99% 的时间是”inactive”的,但每次访问都让它变成”active”,从而免于回收——尽管接下来一小时它都不会再被访问。这个问题没有通用解(内核无法预测进程行为),但至少可以做到:不要每次单独访问就改变页的状态。
这正是 PG_referenced 标志的用途——它让升级和降级都变得”需要两次确认”。例如 inactive 链上的页第一次被访问:置 PG_referenced,但页原地不动;第二次访问发现标志已置,才真正把它升入 active 链表。若第二次访问迟迟不来,PFRA 可能把标志清掉重新计数。降级同理。
图 4:页在 LRU 链表间的迁移——由 PG_active 和 PG_referenced 两个标志共同决定
内核在三个时机动用这套路数:
mark_page_accessed()——每当内核确认某页被访问时调用(缺页异常加载匿名页、加载映射文件页、读文件、换入页、在页缓存中查找缓冲页等场景)。核心逻辑:
if (!PageActive(page) && PageReferenced(page) && PageLRU(page)) {
activate_page(page);
ClearPageReferenced(page);
} else if (!PageReferenced(page))
SetPageReferenced(page);即:只有 PG_referenced 已置位时,本次访问才真正触发”升入 active”;否则只是置标志等着下次确认。
page_referenced()——PFRA 每扫描一页就调用它一次。返回 1 表示”最近被引用过”:先检查并清掉 PG_referenced,再借助反向映射检查并清掉所有用户态页表项的 Accessed 位(用 page_referenced_anon() / page_referenced_file() / page_referenced_one() 三个辅助函数,结构与 try_to_unmap 系列如出一辙)。注意它从不真正移动页——搬家是下一个函数的事。
refill_inactive_zone()——把页从 active 挪到 inactive,是整个机制里最微妙的一环。挪过去就意味着这页迟早会被 PFRA 收割:太激进,大量页被错杀,系统性能崩盘;太保守,inactive 链表补充不上,PFRA 无米下锅。所以它实现了自适应行为:平时每次只扫少量 active 页,PFRA 越艰难(priority 越低),每次扫的页越多。
swap tendency:决定收不收进程的页
active/inactive 链表里混着两种页:属于进程用户地址空间的页,和只属于页缓存的页。PFRA 的偏好是优先收缩页缓存、放过进程的页,但没有一成不变的黄金法则。于是引入一个启发式值 swap tendency:
- mapped_ratio:所有内存区中属于用户地址空间的页占可分配页框总数的百分比。值高说明内存主要被进程占着,值低说明主要被页缓存占着。
- distress:衡量上一轮 PFRA 在这个区收得多费劲,由区描述符的
prev_priority查表得出——上一轮优先级 12~7 时 distress 为 0,一路恶化到优先级 0 时 distress 高达 100。 - swappiness:管理员可调的常数,默认 60,可通过
/proc/sys/vm/swappiness或 sysctl() 修改。
规则:swap tendency ≥ 100 时,才允许从进程地址空间里收页。所以 swappiness 设为 0,几乎永远不收进程的页(除非上一轮 priority 已到 0 的绝境);设为 100,则每次都收。
refill_inactive_zone() 的完整流程:先把 pagevec 里积压的页倒进 LRU 链表(lru_add_drain())→ 拿 lru_lock → 第一轮循环从 active 链表尾部向前扫(尾部是访问时间最久远的),把每页引用计数加一后摘下放进临时链表 l_hold(引用计数为零的页放回去——它其实属于伙伴系统,只是还没来得及从 LRU 摘除)→ 释放锁、计算 swap tendency → 第二轮把 l_hold 分拣成 l_active 和 l_inactive 两堆:属于进程地址空间的页(_mapcount 非负)在 swap tendency 小于 100、或虽匿名但无交换区、或 page_referenced() 返回正值(最近被访问过)时归入 l_active,否则归入 l_inactive → 重新拿锁,第三、四轮循环分别把两堆页搬回区的 inactive/active 链表,并撤销第一轮加上的引用计数 → 解锁返回。
注意一个细节:PG_referenced 只对进程地址空间的页检查(第二轮分拣时);纯页缓存的页不检查——它们本来就在 active 链表尾部,很久没被访问了,近期被访问的可能性很低,直接降级没商量。
常见坑:swappiness 不是越低越好
把 swappiness 调到 0 并不能”禁止使用 swap”,只是让内核尽量先收页缓存。如果内存确实不够,distress 飙到 100 后照样会收进程的匿名页。反过来 swappiness 过高会让进程页过早被换出,交互体验变差。60 的默认值是权衡之选,调参前先看看
free命令里缓存和进程各占多少。
内存不足时的回收
PFRA 有三个触发入口:内存不足时回收(分配失败触发)、休眠回收(进入 suspend-to-disk 前腾内存)、周期性回收(内核线程定期干,见下节)。本节讲第一种。
图 5:PFRA 主要函数调用关系——try_to_free_pages() 是核心入口
内存不足回收在三种场合被触发:grow_buffers() 分配缓冲页失败、alloc_page_buffers() 分配缓冲区首部失败(内核此时调 free_more_memory())、以及 __alloc_pages() 从伙伴系统分配页框失败(此时直接调 try_to_free_pages())。
free_more_memory() 与 try_to_free_pages()
free_more_memory() 做三件事:唤醒一个 pdflush 内核线程去写 1024 个脏页(脏页写盘后,装着缓冲区和 VFS 数据结构的页框才可能被释放);调用 sched_yield() 服务例程让 pdflush 有机会跑;然后遍历所有内存节点,对每个节点调 try_to_free_pages()。
try_to_free_pages() 接收 zones 链表和分配掩码 gfp_mask,目标是至少释放 32 个页框。它的策略是反复调用 shrink_caches() 和 shrink_slab(),每一轮比上一轮更”不择手段”:优先级从 12(最温和)一路降到 0(最激进),共最多 13 轮。具体步骤:
- 分配并初始化
scan_control描述符(记录本次回收操作的参数,字段见下)。 - 把每个区的
temp_priority设为 12,统计各区 LRU 链表总页数。 - 循环最多 13 次(priority 从 12 递减到 0):更新 scan_control →
shrink_caches()扫描各区 inactive 页 →shrink_slab()收缩可收缩磁盘缓存 → 累加 slab 回收的页数 → 达到 32 页就收工 → 否则,若已扫了至少 49 页就唤醒 pdflush 写脏页 → 若已连续 4 轮没达标,调blk_congestion_wait()等待写请求队列疏通(最多 100ms)。 - 把最后使用的优先级写进各区的
prev_priority(供下轮 distress 计算用)。 - 返回成功与否。
若 13 轮下来 32 页都凑不齐,PFRA 就到了绝路——只能杀进程了,这是 out_of_memory() 的活(见后文)。
scan_control 描述符贯穿整个回收过程:
| 字段 | 含义 |
|---|---|
| nr_to_scan | 本轮要在 active 链表扫描的目标页数 |
| nr_scanned | 本轮已扫描的 inactive 页数 |
| nr_reclaimed | 本轮已回收的页数 |
| nr_mapped | 用户地址空间中已映射页的总数 |
| nr_to_reclaim | 要回收的目标页数 |
| priority | 扫描优先级 12~0,越低扫得越多 |
| gfp_mask | 调用者传入的分配掩码 |
| may_writepage | 是否允许写脏页回盘(仅 laptop mode) |
逐层下钻:shrink_caches → shrink_zone → shrink_cache → shrink_list
shrink_caches() 很薄:对 zones 列表里每个区调 shrink_zone(),之前先更新区的 temp_priority,必要时同步 prev_priority。另外,若区描述符的 all_unreclaimable 标志置位(PFRA 已判定这区全是不可回收页,扫它纯属浪费时间)且当前优先级低于 12,就跳过这个区。
shrink_zone() 的目标是从区的 inactive 链表回收 32 页。它按批干活,每批 32 页;若某条 LRU 链表页数不够一批,就先欠着——欠账记在区描述符的 nr_scan_active / nr_scan_inactive 字段里,下次一并算。步骤:按当前优先级把 active 链表的 1/2^12 ~ 全部页数累加进 nr_scan_active(inactive 同理)→ 攒够 32 就取出来清零计数 → 设 sc->nr_to_reclaim = 32 → 若 active 有欠账,先调 refill_inactive_zone() 补充 inactive 链表:
sc->nr_to_scan = min(nr_active, 32);
nr_active -= sc->nr_to_scan;
refill_inactive_zone(zone, sc);若 inactive 有欠账,再调 shrink_cache() 真正回收:
sc->nr_to_scan = min(nr_inactive, 32);
nr_inactive -= sc->nr_to_scan;
shrink_cache(zone, sc);没回收满 32 页就回到开头继续循环。
shrink_cache() 负责从 inactive 链表摘页并交给 shrink_list():先把 pagevec 积压页倒回链表 → 拿锁 → 从 inactive 链表取最多 32 页(每页引用计数加一,跳过正在被释放回伙伴系统的页)移入本地链表 → 更新计数、放锁 → 调 shrink_list() 干真活 → 把 shrink_list 释放不掉的页按 PG_active 标志放回 inactive 或 active 链表(用 pagevec 批量操作)→ 若没回收够且扫够了页数,再循环一轮。
shrink_list():回收的心脏
前面的函数都是”选人”,shrink_list() 才是真正”动手”的。它遍历传入的 page_list,逐页尝试回收;回不动的放回链表。每个页框只有三种结局:
- 成功回收——调
free_cold_page()交还伙伴系统; - 暂时不收(INACTIVE)——页保持 PG_active 清零,放回 inactive 链表,期望近期能收;
- 转正(ACTIVE)——页正在使用或短期内收不了,置 PG_active 放回 active 链表。
shrink_list() 从不碰锁定页(PG_locked)和正在回写中的页(PG_writeback)。对每页的处理流程:
- 调
page_referenced()查”最近是否被引用”,是则转 ACTIVE; - 匿名页要先加入交换缓存并在交换区预留一个页槽(这就是交换子系统存在的意义);
- 若页被进程映射(
_mapcount≥ 0),调try_to_unmap()清所有页表项——不成功则收不成; - 若页是脏的,调
pageout()写盘,写不完就收不成; - 若页含 VFS 缓冲,调
try_to_release_page()释放缓冲区首部; - 最后查引用计数:等于 2 意味着页只剩两个主人——页缓存(或交换缓存)和 PFRA 自己——且不脏,这时把它从缓存里摘掉,调
free_cold_page()释放。
pageout() 只干一件事:把脏页写回磁盘。它依次检查:页确实在页缓存/交换缓存里且只被缓存和 PFRA 持有(否则返回 PAGE_KEEP,写了也白写);address_space 的 writepage 方法存在(否则 PAGE_ACTIVATE);当前进程有资格向对应的块设备请求队列发写请求(kswapd/pdflush 总是可以,普通进程在队列拥塞时不行);页现在还是脏的(否则 PAGE_CLEAN)。都通过后设置 writeback_control 描述符、调用 writepage 方法启动回写。成功返回 PAGE_SUCCESS。
图 6:shrink_list() 的页回收判定逻辑流程图
可收缩的磁盘缓存
除了页缓存,内核还有 dentry 缓存、inode 缓存等其他磁盘缓存(见 /linux内核/lk12),PFRA 也应该能收缩它们。
机制:每个想被 PFRA 照顾的磁盘缓存必须在初始化时注册一个 shrinker 函数。该函数接收”目标页框数 + GFP 分配掩码”两个参数,尽力回收后返回缓存中剩余的可回收页数。set_shrinker() 函数负责注册:分配一个 shrinker 描述符,挂进全局链表 shrinker_list,并初始化 seeks 字段——它粗略表示”重建一个缓存项的代价”,代价越高的缓存越少被收缩。
Linux 2.6.11 里注册了 shrinker 的缓存不多:dentry 缓存、inode 缓存、磁盘配额层、文件系统元信息块缓存(扩展属性)、XFS 日志文件系统。
负责调度它们的函数叫 shrink_slab()(名字有点误导,它和 slab 分配器缓存关系不大)。它被 try_to_free_pages() 和 balance_pgdat() 调用,思路是在”收缩磁盘缓存的代价”和”从 LRU 链表收页的代价”之间做平衡:先遍历一遍 shrinker 列表问每个缓存”你有多少可回收页”,再按可回收页数、重建代价(seeks)、LRU 链表页数启发式地算出各缓存应回收的目标页数,调用 shrinker 函数,每次至少收缩 128 页。
dentry 与 inode 缓存的收缩
shrink_dcache_memory() 是 dentry 缓存的 shrinker,寻找并释放未使用的 dentry 对象(没有被任何进程引用的,见 /linux内核/lk12)。dentry 对象由 slab 分配器分配,释放它们可能让整个 slab 变空,从而被后文的 cache_reap() 收走页框;而且 dentry 缓存”管辖”着 inode 缓存——dentry 被释放后,其 inode 的页也可能随之闲置。函数先检查 gfp_mask 的 __GFP_FS 位,没开就直接返回 -1(释放 dentry 可能触发磁盘文件系统操作)。实际释放由 prune_dcache() 完成:扫描 dentry_unused 链表,对每个近期未被引用的对象依次摘出哈希表和各链表、释放其 inode 引用、调 d_release 方法、用 call_rcu() 注册延迟销毁回调、递减父目录引用计数。
返回值有个小机关:未使用 dentry 数 × 100 ÷ sysctl_vfs_cache_pressure。这个变量默认 100,管理员可通过 /proc/sys/vm/vfs_cache_pressure 调节——小于 100 让 PFRA 少收 dentry/inode 缓存、多收 LRU 链表;大于 100 则相反。
shrink_icache_memory() 是 inode 缓存的 shrinker,套路相同:“未使用”指 inode 已没有管辖它的 dentry。实际释放由 prune_icache() 扫描 inode_unused 链表:释放 inode 的私有缓冲、把页缓存中属于它且不再使用的干净页作废,最后用 clear_inode() / destroy_inode() 销毁 inode 对象。
周期性回收
被动等内存分配失败才回收是不够的。设想所有内存请求都来自中断处理程序或已持有临界资源、不能发起 I/O 的内核控制路径——它们不能睡眠等页框释放,内核就永远腾不出内存。于是需要周期性回收,由两套机制承担:kswapd 内核线程和 cache_reap 函数。
kswapd 内核线程
每个内存节点(NUMA,见 /linux内核/lk08)有一个专属 kswapd 线程,平时睡在节点描述符 kswapd_wait 字段开头的等待队列里。当 __alloc_pages() 发现所有合适的内存区空闲页框数都低于”警告”水位线(由 pages_low 和 protection 字段算出)时,唤醒对应节点的 kswapd——趁情况还没恶化提前干活,顺带在机器空闲时腾内存,让进程之后拿页更快。
回忆三个水位线(/linux内核/lk08):pages_min 是必须死守的最低储备,pages_low 是触发线,pages_high 是”安全线”——自由页框超过它就可以停止回收了。
kswapd 线程执行 kswapd() 函数,初始化时把自己绑定到能访问该内存节点的 CPU、设置 current->reclaim_state、打上 PF_MEMALLOC 和 PF_KSWAPD 标志(表示正在回收内存,且干活时可以动用所有自由内存)。每次被唤醒后:从等待队列移除自己 → 调 balance_pgdat() 回收 → prepare_to_wait() 置 TASK_INTERRUPTIBLE 重新入睡 → schedule() 让出 CPU。
balance_pgdat() 的骨架和 try_to_free_pages() 非常像:建 scan_control → 各区 temp_priority 置 12 → 最多 13 轮循环(priority 12→0),每轮先用 zone_watermark_ok() 从 ZONE_DMA 到 ZONE_HIGHMEM 找出水线不达标的最高区,再从 ZONE_DMA 起对途径各区更新 prev_priority 并调 shrink_zone() + shrink_slab() → 回收满 32 页即止 → 收尾时更新各区 prev_priority;若仍有区水线不达标且需要调度,就 schedule() 后从头再来。
cache_reap() 函数
slab 分配器缓存占用的页也要收(/linux内核/lk08),交给 cache_reap()——它大约每两秒被调度进预定义 events 工作队列(/linux内核/lk04)执行一次。流程:尝试拿保护 slab 缓存描述符链表的 cache_chain_sem 信号量(拿不到就把下次调用推迟后返回)→ 遍历 cache_chain 链表上的每个缓存描述符:跳过 SLAB_NO_REAP 置位的缓存;排空 slab 本地缓存(可能腾出空 slab);检查 next_reap “收割时间”未到就跳过;否则把下次收割时间定为 4 秒后;多处理器系统上再排空 slab 共享缓存;若该缓存最近刚加入过新 slab(free_touched 置位)就跳过;按启发式公式算出该释放的 slab 数——它取决于缓存空闲对象上限和每个 slab 打包的对象数;对空闲 slab 链表反复调 slab_destroy() 直到目标达成或链表空;中途用 cond_resched() 礼貌让 CPU。收尾:放信号量,安排下一次调用。
OOM Killer 与交换令牌
Out of Memory Killer
尽管 PFRA 竭尽全力,虚拟内存的压力仍可能大到所有内存被榨干:交换区满了、磁盘缓存缩到头了,内核还在拼命想腾内存却腾不出——没有任何进程能继续执行,也就没有进程能释放它占的页框。系统彻底冻住。
这时 OOM killer 登场,挑一个进程果断杀掉来释放它的页框。书里的比喻很传神:像外科医生为救人而截肢——截肢不好受,但有时没有更好的选择。
out_of_memory() 由 __alloc_pages() 在内存极低且 PFRA 颗粒无收时调用。它先调 select_bad_process() 选出”祭品”,再调 oom_kill_process() 行刑。选人绝不是随机抽签,祭品应符合:
- 占有大量页框,杀掉能释放可观的内存(还要把它的子进程占的内存算上,防止 fork 炸弹钻空子);
- 杀掉损失的未完成工作少——杀一个跑了几天几夜的批处理进程是大忌;
- 静态优先级低——用户本来就把不重要的进程放低优先级;
- 不是 root 特权进程(它们通常干重要的事);
- 不直接操作硬件设备(如 X Window 服务器),否则硬件可能留在不可预测的状态;
- 不是 swapper(0 号进程)、init(1 号进程)或任何内核线程。
select_bad_process() 扫描全系统进程,用经验公式给每个进程打分,返回最佳祭品。oom_kill_process() 优先把致命信号(通常是 SIGKILL,见 /linux内核/lk11)发给它的某个子进程,做不到才杀进程本身,并连带杀掉共享同一内存描述符的所有克隆。
交换令牌(Swap Token)
PFRA 太复杂,行为难以预测,存在病态情形——交换颠簸(swap thrashing):内存不足时 PFRA 拼命把页写盘、从进程手里抢页框;进程同时拼命想运行、拼命访问自己的页;内核又把刚抢来的页框发回去、从盘上把内容读回来。净效果:页在磁盘和内存之间永不休止地来回搬运,大部分时间耗在磁盘 I/O 上,所有进程都寸步难行。
缓解方案是 Jiang 和 Zhang 在 2004 年提出的交换令牌(2.6.9 内核实现):全系统唯一的一个进程持有令牌,持有者豁免于页框回收——于是它能真正取得进展,有望在内存紧张时也跑完。
实现很轻巧:一个全局指针 swap_token_mm 指向持令牌进程的内存描述符。豁免的方式优雅至极——page_referenced() 在检查”页是否最近被引用”时,若页属于持令牌进程的内存区,就直接返回 1(被引用过),于是这页永远不会被降级到 inactive 链表。仅两个例外:PFRA 正在为持令牌进程本人服务时,以及 PFRA 已降到最狠的 0 级优先级时。
grab_swap_token() 决定把令牌发给谁,它在每次主缺页时被调用(两处:文件映射缺页发现页不在页缓存;换入缺页刚从交换区读了新页)。授予需同时满足:距上次调用已过至少 2 秒;现任持有者自上次以来没有再发生主缺页,或已持有超过 swap_token_default_timeout 个节拍;令牌最近没发给过当前进程。理想情况下持有时长应以分钟计——目的是让进程跑完。但 2.6.11 的默认值小得可怜:1 个节拍。管理员可通过 /proc/sys/vm/swap_token_default_timeout 调节。进程被杀时 mmput() 会检查并归还令牌。
通关标准
不看书面答出:PFRA 把页分成哪四类、各怎么处理?反向映射为什么用”对象”而不是页表项链表?LRU 两条链表靠哪两个标志协作完成页的升降级?swap tendency 公式三项分别是什么?OOM killer 选祭品的六条准则?交换缓存解决了哪两类竞争?——全部答出,本章过关。
交换区与页槽
交换(swapping)为没有磁盘映像的页提供磁盘备份,PFRA 才敢回收任何用户进程页——而不只是有映像的映射页。需要交换子系统处理三类页:匿名内存区的页(栈、堆)、私有内存映射中的脏页、IPC 共享内存区的页。
交换必须对程序透明——代码里不需要任何特殊指令。诀窍在页表项的 Present 位(/linux内核/lk02):该位清零表示页不在 RAM;剩下的位被内核征用来存放换出页标识符,编码页在磁盘上的位置。缺页异常发生时,处理程序一看就知道该去磁盘上哪儿找这页。
交换子系统的四大功能:建立交换区;管理页槽的分配与释放;提供换出/换入函数;用页表项里的换出页标识符跟踪数据位置。要提醒的是:swap 能”扩容”进程可用地址空间,让总需求超过物理 RAM 的程序也能跑,但磁盘访问比内存慢好几个数量级——性能攸关时,加内存条永远是正解,swap 只是最后的缓冲垫。
交换区
换出的页存在交换区里——可以是一块独立磁盘分区,也可以是大分区里的一个普通文件。最多可有 MAX_SWAPFILES(通常 32)个交换区;多区让管理员把交换空间铺到多块盘上并发访问,也支持运行时动态扩容。
每个交换区是一串页槽(page slot)——每个 4,096 字节,装一页。第一个页槽存控制信息,格式由 swap_header 联合体描述,分两部分:magic 结构含 10 字符魔数 “SWAPSPACE2”,固定在第一个页槽末尾,让内核能明确认出”这是一块交换区”;info 结构含 bootbits(前 1024 字节,存分区数据/磁盘标签,交换算法不用它)、version(算法版本)、last_page(最后一个可用页槽)、nr_badpages 和 badpages[1](最多 637 个坏页槽的位置)。
交换区的数据只在系统开机期间有意义——关机时进程全灭,交换区内容作废。所以控制信息极少,一个 4KB 页就装下了。管理员的操作流程:建分区(或文件)→ mkswap 命令初始化第一个页槽并检查坏块(此时交换区处于未激活状态)→ 系统启动脚本里或运行中用 swapon 激活。
激活时内核构建交换区的交换延伸(swap extent)列表,每个 swap_extent 描述符对应一串物理上相邻的页槽,记录起始页槽索引、页数和起始磁盘扇区号。分区式交换区只有一个 extent;文件式交换区可能有多个——文件系统不一定把文件放在连续块里。
多交换区的优先级策略
换出时内核尽量把页放进连续的页槽以减少磁盘寻道。有多个交换区时:快的交换区(在快盘上)优先级高;找空闲页槽从最高优先级的区开始,同优先级的区轮转使用避免偏心;高优先级区找不到了才降级去次高优先级的区。
交换区描述符
每个活动交换区有一个 swap_info_struct 描述符,主要字段:
| 字段 | 含义 |
|---|---|
| flags | 标志:SWP_USED(已激活)、SWP_WRITEOK(可写)、SWP_ACTIVE(两者皆备) |
| sdev_lock | 保护交换区数据结构的自旋锁 |
| swap_file | 交换区所在文件/设备文件的对象指针 |
| bdev | 所在块设备的描述符 |
| extent_list / nr_extents / curr_swap_extent | 延伸链表及最近使用的延伸 |
| old_block_size | 分区原始块大小 |
| swap_map | 每个页槽一个的计数器数组 |
| lowest_bit / highest_bit | 可能空闲的页槽扫描范围下界/上界 |
| cluster_next / cluster_nr | 簇分配游标 / 距下次从头重扫前还能分配几个 |
| prio | 交换区优先级 |
| pages / max | 可用页槽数 / 总页数 |
| inuse_pages | 已用页槽数 |
| next | 下一个交换区描述符的下标(注意不是指针!) |
swap_map 是核心:计数器为 0 表示页槽空闲;为正表示装着换出页,数值即共享这个换出页的进程数;等于 SWAP_MAP_MAX(32767)表示”永久”驻留不可移除;等于 SWAP_MAP_BAD(32768)表示页槽有缺陷不可用。
图 7:交换区数据结构——swap_info 数组、交换区与 swap_map 计数器数组的对应关系
内核用 swap_info 数组存放所有描述符;nr_swapfiles 存的是”最后使用过的数组下标”——别被名字骗了,它不是活动交换区的数量。活动描述符按优先级排序串成链表(用 next 字段存数组下标),由 swap_list 变量访问:head 是链首下标,next 是”下一个该轮到谁换出”的下标(实现同优先级区的轮转),swaplock 自旋锁护体。另有全局计数:nr_swap_pages(所有活动区空闲且完好的页槽总数)、total_swap_pages(完好页槽总数)。
换出页标识符
一个换出页由两个数唯一定位:交换区在 swap_info 数组中的下标 + 区内页槽下标。由于 0 号页槽被 swap_header 占用,第一个可用页槽下标是 1。标识符格式(32 位):
| 31 | 8 | 7 | 1 | 0 |
|---|---|---|---|---|
| 页槽下标 | 交换区号 | 0 |
即高位是页槽下标,中间是区号,最低位恒为 0——正好对应页表项清零的 Present 位。swp_entry(type, offset) 由区号和页槽下标构造标识符;swp_type / swp_offset 反向拆解。
据此,页表项的值可分三种情形:全 0——页不属于进程地址空间或尚未分配(请求调页);最高 31 位非全 0 且最低位为 0——页已换出;最低位为 1——页在 RAM 里。80×86 上标识符只有 24 位编码页槽,所以单个交换区最大 2^24 槽 = 64 GB。
一页可能属于多个进程的地址空间,所以可能被”换出”多次——当然实际只存一份,后续每次”换出”只是递增 swap_map 计数。swap_duplicate() 负责此事:拆解标识符 → 验证区号有效且区已激活 → 验证页槽有效(计数大于 0 且小于 SWAP_MAP_BAD)→ 计数未达 SWAP_MAP_MAX 则加一 → 返回有效。
激活与去激活:sys_swapon() 与 sys_swapoff()
拥有 CAP_SYS_ADMIN 能力的用户用 swapon / swapoff 程序(走 sys_swapon() / sys_swapoff() 系统调用)管理交换区。
sys_swapon() 接收路径名 specialfile 和优先级标志 swap_flags,验证 swap_header 魔数后激活。要点:在 swap_info 数组里找第一个 SWP_USED 清零的空闲槽位(找不到就占用 nr_swapfiles 位置);未指定优先级时自动取”现有最低优先级减一”——假设最后激活的区在最慢的盘上;filp_open() 打开文件;确认这个区没被重复激活(比对各活动描述符的 swap_file->f_mapping 地址);若是块设备文件,用 bd_claim() 独占设备、把设备块大小改为 4096 字节;若是普通文件,检查并设置 inode 的 S_SWAPFILE 标志;读第一个页槽、校验 “SWAPSPACE2” 魔数;vmalloc() 分配 swap_map 数组并按坏页槽表初始化;构建延伸链表;置 SWP_ACTIVE;更新全局计数变量并插入优先级链表。
sys_swapoff() 去激活一个交换区,难得多也慢得多——区里可能还有多个进程的页,必须全部换入。要点:filp_open() 打开后在 swap_list 链表里按文件对象地址比对找到该区;cap_vm_enough_memory() 粗查内存够不够把所有页换入(不够就干脆别折腾,省得大量无谓磁盘活动);把描述符摘出链表、清 SWP_WRITEOK(禁止再往里换出);调 try_to_unuse() 把区里的页全部换入并修正各进程的页表——期间当前进程带着 PF_SWAPOFF 标志,若此时闹内存饥荒,OOM killer 会被迫选它当祭品(杀掉 swapoff 总好过系统僵死);失败则一切还原并报错;成功则释放 swap_map 内存、还原块设备块大小并用 bd_release() 放还设备、清 S_SWAPFILE、关闭文件。
try_to_unuse() 扫描 swap_map,找到在用的页槽就先换入(read_swap_cache_async() 分配页框、读入、放进交换缓存并锁定)、再遍历所有进程的页表把换出页标识符替换成物理地址(unuse_process();同时递减页槽计数、递增页框引用计数),并调 shmem_unuse() 处理 IPC 共享内存的情形。棘手的是幽灵进程问题:do_munmap() 执行中会把部分内存区暂时摘出进程链表再插回,扫描可能恰好错过引用某页槽的区。对策是反复扫描 swap_map 直到所有计数归零——幽灵区终会重新出现。期间的”危险窗口”其实无害:幽灵进程此时换入该页,会通过写时复制拿到自己的新页框,完全合法;但函数会把页标记为脏(否则日后 shrink_list() 可能在不另存副本的情况下直接丢弃它,造成数据丢失)。
页槽的分配与释放
换出高峰期内核要在短时间内换出大量页,必须让它们落在相邻页槽上以减少寻道。两个极端策略都不可取:“永远从头找”会让换出时寻道时间飙升;“永远从上次分配处继续”在区几乎全空时(常态)会让换入寻道变慢。Linux 取混合策略:默认从上次分配处继续,但遇到两种情况之一就重新从头开始——到达区末尾,或自上次重扫以来已分配了 SWAPFILE_CLUSTER(通常 256)个连续页槽。cluster_nr 记录已分配数(重扫时清零),cluster_next 记录下次扫描起点;lowest_bit/highest_bit 持续收窄”可能空闲”的范围。
scan_swap_map() 在单个区内找空闲页槽:优先用当前簇(cluster_nr 为正时从 cluster_next 扫到计数为 0 的槽,找到则 cluster_nr 减一);簇耗尽则重新从 lowest_bit 起找一整段 SWAPFILE_CLUSTER 的连续空槽;连一段都没有就退而求其次找单个空槽(全满则把 lowest_bit 置最大、highest_bit 置 0 并返回 0);找到后计数置 1、递减 nr_swap_pages、递增 inuse_pages、必要时更新 lowest_bit/highest_bit、cluster_next 指向下一槽。
get_swap_page() 跨所有活动区找页槽,返回换出页标识符(全满返回 0)。为省时做两遍扫描:第一遍只在”同优先级区”里轮转找;找不到才开始第二遍从头扫全表。逻辑要点:从 swap_list.next 指的区开始调 scan_swap_map();成功后把 swap_list.next 推进到同优先级的下一个区(实现轮转),若下一区优先级不同则回卷到链首;当前区不可写或无空槽时换下一区;两遍扫完颗粒无收返回 0。
swap_free() 在换入时递减页槽计数:拆解标识符 → 区未激活直接返回 → 计数小于 SWAP_MAP_MAX 才递减(永久页槽除外)→ 计数归 0 则递增 nr_swap_pages、递减 inuse_pages、更新 lowest_bit/highest_bit。注意交换缓存也算页槽的”一个主人”。
交换缓存与换入换出
交换缓存:化解竞争的中转站
页和交换区之间的传输充满竞争,两大典型场景:
- 多重换入:两个进程并发换入同一个共享匿名页;
- 换入换出并发:进程换入一页时,PFRA 正把它换出。
**交换缓存(swap cache)**专治这两类问题,铁律是:任何换入或换出开始前,必须先查交换缓存。这样并发操作同一页时总是作用于同一个页框,内核可以放心用页描述符的 PG_locked 标志来同步——先到者锁定页框做 I/O,后到者睡眠等待。
多重换入的例子:进程 1 访问共享匿名页触发换入,发现交换缓存里没有,就分配页框、插入交换缓存、启动读盘 I/O;此时进程 2 也来访问同一页,同样触发换入,但这次查缓存命中——直接拿到页框,睡等 PG_locked 清零(I/O 完成)。
换入换出并发的例子更有意思,见图 8 的五个阶段。进程 A、B 共享页 P,各自页表项都指向同一页框(a)。PFRA 选中 P,shrink_list() 把页框插入交换缓存——页框现在有三个主人(A、B、交换缓存),而交换区页槽只被交换缓存引用(b)。接着 try_to_unmap() 清掉 A、B 的页表项——页框只剩交换缓存一个主人,页槽反而被 A、B 和交换缓存三方引用(c)。若写盘期间进程 B 访问了这页:缺页处理程序在交换缓存里找到页框,把物理地址写回 B 的页表项(d)。反之若写盘顺利完成、无人打断,shrink_list() 把页框从交换缓存摘除并交还伙伴系统(e)。
图 8:交换缓存的作用——换出过程中的五个阶段
可以把交换缓存理解为正在换入/换出途中的匿名页的”候车厅”:操作彻底完成(共享页要等所有进程都处理完)后,页描述符才可能离开。
实现上,交换缓存复用页缓存的机制(/linux内核/lk15):所有交换缓存页共用一个 swapper_space 地址空间,页通过其基数树(radix tree)快速定位。特殊之处:页描述符 mapping 字段置 NULL、PG_swapcache 置位、private 字段存换出页标识符;页放入时页框引用计数和页槽计数都加一(交换缓存同时占用着页框和页槽)。
常用辅助函数:lookup_swap_cache() 按标识符查页(radix_tree_lookup);add_to_swap_cache() 插入页并锁定(先 swap_duplicate 验证页槽再加计数,然后 radix_tree_insert,加引用计数,置 PG_swapcache 和 PG_locked);__add_to_swap_cache() 同上但不做 swap_duplicate;delete_from_swap_cache() 摘除页并递减页槽计数和页引用计数;free_page_and_swap_cache() / free_pages_and_swap_cache() 释放页;free_swap_and_cache() 释放页槽并顺手检查对应页在不在交换缓存——若除了 current 没有别的进程引用它,或超过半数页槽已被占用,就把它从缓存里摘掉。
换出:三步走
换出的完整流程(由 shrink_list 驱动):
第一步,插入交换缓存。 判定页是匿名的(PageAnon 返回 1)且不在交换缓存(PG_swapcache 清零)后,调 add_to_swap():get_swap_page() 分配页槽(失败即返回 0)→ __add_to_page_cache() 插入交换缓存 → 置 PG_uptodate 和 PG_dirty(故意置脏,逼 shrink_list 接下来写盘)→ 返回成功。
第二步,更新页表项。 shrink_list 调 try_to_unmap(),借助匿名页反向映射把每个引用该页的用户态页表项改写成换出页标识符。
第三步,写入交换区。 shrink_list 看到 PG_dirty 置位就调 pageout(),后者调用 swapper_space 的 writepage 方法——即 swap_writepage():先复查是否还有进程引用该页(可能有人和 PFRA 赛跑提前释放了它;没有就直接摘出缓存收工)→ get_swap_bio() 分配 bio 描述符(/linux内核/lk14):由标识符找到区描述符,遍历延伸链表算出页槽的起始扇区,完成回调设为 end_swap_bio_write() → 置 PG_writeback 和基数树回写标记、清 PG_locked → submit_bio(WRITE, ...) 提交 I/O。I/O 完成后 end_swap_bio_write() 唤醒等待 PG_writeback 清零的进程、清标志和标记、释放 bio。
最后 shrink_list 若确认 I/O 期间无进程访问该页,调 delete_from_swap_cache() 摘除——交换缓存曾是唯一主人,页框就此回归伙伴系统。
换入:缺页异常驱动
换入发生在进程访问已被换出的页时。缺页异常处理程序判定换入的条件有三:出错地址所在的页是有效的(属于当前进程的某个内存区);页不在内存(页表项 Present 位清零);页表项非空且 Dirty 位清零——这正是”存的是换出页标识符”的特征(/linux内核/lk09)。三条件齐备,handle_pte_fault() 调 do_swap_page()。
do_swap_page() 参数包括内存描述符、内存区描述符、出错线性地址、页表项地址、页中间目录地址、页表项原值 orig_pte、读写标志。返回值:1 = 页已在交换缓存(次缺页),2 = 确实从盘上读了(主缺页),-1 = 出错。主要步骤:
- 从 orig_pte 取换出页标识符;
pte_unmap()释放临时内核映射;释放调用者拿的 page_table_lock。 lookup_swap_cache()查缓存;命中跳到第 4 步。- 未命中则
swapin_readahead()预读:从交换区一次读最多 2^n 页(n 存在page_cluster变量,通常为 3,即最多 8 页),把请求页连同它后面的邻居一起读进来——利用了交换区页槽分配的局部性。 - 再调一次
read_swap_cache_async()精确读请求的那一页。看似冗余实则必要:预读可能失败(page_cluster 为 0,或读的页组里混进了空闲/坏页槽)。 - 若这一页还是没进缓存:可能是兄弟进程的内核控制路径已经换入了它——重拿 page_table_lock 比对页表项与 orig_pte,不同则返回 1,相同则返回 -1(真失败)。
- 页已在缓存。若是主缺页,调
grab_swap_token()试图抢交换令牌;mark_page_accessed()标记访问并锁页。 - 重拿 page_table_lock;再查一遍是否有别的内核控制路径已替本进程的克隆换入了页(是则解锁放回,返回 1)。
swap_free()递减页槽计数。- 若交换缓存占用已超过半数(nr_swap_pages < total_swap_pages 的一半)且页只属于出错进程(或其克隆),把它从交换缓存摘除——用不着再候着了。
- 递增 rss;把页的物理地址和区的保护位写进页表项;若是写访问且进程是页的唯一主人,连 Dirty 和 Read/Write 位一起置上,免得马上再吃一次无谓的写时复制缺页。
- 解锁页;
page_add_anon_rmap()把页登记进匿名页反向映射结构;若 write_access 为 1 调do_wp_page()做写时复制(/linux内核/lk09);释放锁并返回。
read_swap_cache_async() 是一切换入的底层:先用 radix_tree_lookup 在 swapper_space 基数树里按标识符找页(命中则加引用计数直接返回描述符);没找到就 alloc_pages() 分配新页框(分配失败返回 0,内存耗尽);add_to_swap_cache() 插入并锁页(若插入时发现别人抢先插入了同一页——比如本进程在分配页框时阻塞了——就释放刚分配的页框从头再来);lru_cache_add_active() 挂进 LRU active 链表;最后 swap_readpage() 读盘——清 PG_uptodate、get_swap_bio 建 bio、submit_bio 提交读请求——返回页描述符地址。
常见坑:换出页标识符与空页表项别混淆
页表项全 0 = 页从未分配或属于按需分配;非 0 且 Present 位清 0 = 页已换出,低位藏着区号和页槽号。混淆这两种情况会导致把”缺页要分配新页框”误判成”缺页要去磁盘换入”(或反之)。另外记住 swap 区 0 号页槽永远被 swap_header 占用,第一个可用页槽下标是 1。
为什么内核回收页框必须在内存耗尽之前,而不是等真的用完了再收?
因为释放一个页框本身可能需要新的页框:写页内容到磁盘要分配缓冲区首部等 I/O 结构。若自由内存已为零,就陷入”要释放页框必须先有页框”的死循环。所以 PFRA 的目标是维持一个最小空闲页框储备池(各区的 pages_min 水位线),让内核能从”低内存”状态安全恢复。
匿名页和映射页的反向映射为什么用不同数据结构?各自是什么?
因为共享频率不同。映射页(如 C 库代码页)被大量进程频繁共享,线性扫描链表太慢,所以用优先搜索树(PST)——radix 索引(区间起点)做搜索树键、heap 索引(区间终点)满足堆性质、size 索引区分同起点的区,每文件一棵,根在 address_space 的 i_mmap 字段。匿名页共享不频繁,用anon_vma 双向循环链表就够了:第一个页框分配时创建 anon_vma,fork 等共享发生时把各进程的匿名内存区描述符都挂进它的链表。
swap tendency 公式的三项各是什么含义?何时才允许从进程地址空间收页?
swap_tendency = mapped_ratio/2 + distress + swappiness。mapped_ratio 是用户地址空间页占可分配页框的比例(进程页占比越高,越倾向收进程的页);distress 由上一轮回收的优先级查表得出(上轮越艰难,本轮越激进);swappiness 是管理员可调常数(默认 60,经 /proc/sys/vm/swappiness)。只有 swap tendency ≥ 100 时,refill_inactive_zone() 才把进程地址空间的页降级到 inactive 链表。
交换缓存解决什么问题?换出过程中页框和页槽的"主人"是如何演变的?
解决两类竞争:多个进程并发换入同一共享页;换入与换出并发进行。铁律是任何换入/换出前必须先查交换缓存,使并发操作落在同一页框上,靠 PG_locked 同步。演变(以 A、B 共享页为例):初始页框主人是 A、B;插入交换缓存后页框主人变为 A、B、交换缓存,页槽只被交换缓存引用;try_to_unmap 清掉 A、B 页表项后,页框只剩交换缓存引用,页槽被 A、B、交换缓存三方引用;若 B 期间访问该页,从缓存取回页框;若顺利写完,页框从缓存摘除归还伙伴系统。
OOM killer 选"祭品"时看重什么?为什么 swapoff 进程反而最危险?
六条准则:占用页框多(含子进程,防 fork 炸弹)、杀掉损失的工作少、静态优先级低、非 root 进程、不直接控制硬件设备、不是 swapper/init/内核线程。swapoff 期间当前进程带着 PF_SWAPOFF 标志——因为 sys_swapoff 要把整个交换区的页换入 RAM,可能正是它把内存逼到绝境;此时若闹内存饥荒,select_bad_process() 被强制选它当祭品,牺牲 swapoff 总好过系统整体僵死。