页面回收与交换:如何腾出内存,又不丢失需要的数据

本页目录

服务反复使用的 100 MiB 热数据,被一次 2 GiB 文件扫描挤走;回收统计很好看,业务却不断重读。回收的目标不仅是释放页面,还要考虑内容怎样恢复、谁承担停顿、释放后是否很快又需要它⁠。

先按内容后备解释候选,再推演工作集、换出与并发换入,最后把压缩、整理和观测接到同一条线上。

要解释的现象阅读位置
什么页可以丢弃,什么内容必须保存?第 1 节:后备与引用
回收成功很多,业务为何更慢?第 2~3 节:refault 与停顿承担者
换出后,多个线程怎样找到同一份内容?第 4 节:swap 身份与并发恢复
压缩、整理、增加容量分别解决什么?第 5~7 节:资源账与诊断

1. 先问内容从哪里恢复,再问它有多冷

对一个未修改的普通文件页,如果后端能重新提供内容,内存副本在解除相关引用后可以丢弃。若页里含尚未写回的修改,直接丢弃会破坏文件语义,需要按写回协议推进。匿名内存则通常没有一个原始文件可重读;其中需保留的内容必须有 swap 等后备,或者已经有仍有效的后备副本。

回收物理页之前:内容是否还需要保留,能从哪里恢复? 干净普通文件页 后端已有可重新读取内容 解除引用后丢弃 未来访问可从后端再读 脏普通文件页 内存含尚未写回修改 按协议推进写回 写回期间和重脏状态需协调 需保留的匿名 / shmem 不能凭空重新生成内容 建立可恢复后备 swap / 压缩缓存等,或已有副本 还需满足映射、引用、pin 与锁条件;应用主动丢弃、不再使用的页可走不同路径。

“匿名页必须每次写磁盘才能释放”也过于绝对。已有有效 swap 副本、压缩缓存承接、应用明确放弃内容以及整个映射生命周期结束,处理路径都不同。反过来,tmpfs 虽然以文件接口呈现,内容也不能凭普通磁盘文件那样随意丢弃,它的 shmem 后备关系更接近可交换内存。

冷热、内容后备与引用,是三个独立维度:

候选页看起来有利的条件仍要解决的问题
很冷,但被设备长期 pin近期不常访问引用可能阻止回收
很热的干净文件页内容可以重读丢弃后很快重读,代价可能很高
含唯一有效内容的匿名页可能已经很冷必须保留可恢复的后备

因此候选选择不能永远固定为“干净文件、脏文件、匿名”;应同时考虑释放条件和未来恢复代价。

下面先不讨论淘汰策略,只检查“这份内容能否安全离开 RAM”。尝试保存、修改、回收与再次访问,观察新修改怎样让原来的后备副本失效。

正在呈现知识画面
回收之前,这份内容还能从哪里找回?

内容可恢复与没有阻止回收的引用,是释放驻留页的必要条件。修改会使旧后备过期;成功回收后马上恢复,也可能付出重复工作的代价。

2. 用一个工作集例子理解冷热判断

假设服务每轮反复访问 100 MiB 热数据,另有一次性扫描不断读入 2 GiB 文件。如果仅因为扫描刚发生就把所有新页都当成值得长期保留的热点,热工作集可能被挤走;下一轮业务又把它重新读回来。腾出的每一页看起来都成功了,但系统一直在做丢弃与重读的重复工作。

传统 active/inactive 机制用访问线索和队列位置近似区分使用情况,分开管理 anon/file 等类型;它不是记录每次访问的精确全局 LRU,也不是只有 5.x 才有的历史路径。MGLRU 在启用时以多代组织年龄信息,aging 通过页表访问位等线索发现近期使用,eviction 从较老候选推进,并结合 refault 等反馈调整选择。

一代不是每个页上的精确时间戳,也不等于所有页按固定秒数集体过期。扫描页表和反向映射仍有成本;MGLRU 改进了信息收集和组织方式,不是完全不扫描就知道所有页有多热。通过文件描述符发生的访问与通过页表发生的访问还会提供不同线索。见 MGLRU 设计。

Refault 为什么比一次成功回收更能说明问题

比较两个教学窗口,便能看到单看回收率会漏掉什么:

窗口扫描回收随后迅速恢复的页需要追问
A10000 页8000 页7000 页是否误伤了正在反复使用的内容?
B同样假设 10000 页3000 页很少虽然回收率低,业务是否更稳定?

A 的 scan/steal 很好看,却可能支付大量重复恢复成本;B 的较低回收量并不直接代表更差。

这不是把每次 refault 都判成错误:工作集本来就大于可用内存时,总有一些内容必须反复进出。反馈的价值在于判断候选组、类型和保护策略是否仍合适。仅把 pgsteal/pgscan 当作分配器评分,会漏掉恢复成本、页大小差异及应用延迟。

3. kswapd 与 direct reclaim:由谁支付回收时间

后台先做:为后续申请保留余量

后台 kswapd 试图在资源紧张前后恢复节点/zone 的可用余量,让未来申请更容易从快路径完成。常见简化模型是在低水位条件下唤醒,向高水位平衡,但真实目标受 order、zone 和其他策略影响。它不等于“free 一低于 high 就固定开始一轮”。

申请者自己做:回收进入本次延迟

若分配者不能从允许资源中取得页,而且 GFP 允许直接回收,它可以自己执行回收。这会把扫描、等待和其他工作算进本次申请的延迟;因此没有明显磁盘吞吐峰值,也可能出现明显内存停顿。direct reclaim 并不保证一直等待到成功,后续仍可能回退、失败或进入其他处理。

先确定资源域:全局还是 cgroup

回收范围同样重要。某个 memory cgroup 达到约束时,可以在其内部回收,即使全机还有很多空闲页。memory.high 相关节流、memory.max 的硬限制、NUMA 允许节点和普通全局内存压力不是同一问题。要先确认是哪一种资源域缺额度,再决定是否需要分析全局 LRU。

slab 对象不都位于普通页 LRU 中。内核通过 shrinker 请求具有回收能力的缓存子系统释放对象,例如不再需要的缓存项;子系统负责判断引用、锁和业务状态。若仍有活跃使用者,不能为了满足页分配就强制销毁任意对象。

4. 走完一次匿名页换出:需要同时保存内容和名字

设进程 P 的地址 A 指向物理页 F,其中有应用必须保留的数据。若只把 F 写到 swap,却让 PTE 继续允许写,应用可能在写出之后改变 F,使后备副本过期;若只清 PTE,却不记录内容在哪,之后缺页又无从恢复。

换出需要同时维护三类事实:

事实对应的工作
内容在哪里恢复建立 swap 身份并管理后备
哪些映射还能访问或修改它通过反向映射协调 PTE、写保护与 TLB
物理页何时能复用完成必要写出,确认状态和引用允许释放

这三类工作靠并发锁和重试协议衔接。先写盘再任意修改 PTE,无法保证期间没有写入,也无法保证页已经无人使用。

换出后要保留身份:虚拟地址到 swap 条目,再到内容 进程 P:地址 A PTE 编码 swap 条目 S 进程 Q:地址 B 可引用同一逻辑后备内容 swap 身份 S 设备/类型与偏移等信息 swap cache 中的 folio 若仍驻留,可协调复用 压缩或存储后备 缺失时恢复内容再映射 示意身份与内容关系,不固定现代 swap cache 的内部索引结构;swap 条目不等于已发生磁盘读。

反向映射回答“这份内容可能被哪些虚拟映射使用”,无需盲扫所有进程页表。文件映射可从 address_space 的映射索引找候选 VMA,匿名映射可借助 anon_vma 等关系;还需核对具体页表,候选 VMA 不等于已存在有效 PTE。fork、COW 和大 folio 又会改变共享与映射统计,引用计数不能代替精确枚举用户映射。

两个线程同时换入,为何不应各自得到互不协调的副本

当 PTE 带着 swap 身份 S 触发缺页,内核先需要判断相关内容是否已驻留、是否有人正在恢复。若另一个线程已把 S 的内容读到内存,后来者可以等待或复用适当结果,而不是无条件再次读存储。这样既减少重复 I/O,也保持共享内容、状态与引用的一致性。

swap cache 参与把 swap 身份与驻留 folio 联系起来。它是概念职责,不应把旧版 address_space/XArray 结构固定成所有新内核的实现。找到驻留对象也不等于可立即使用:I/O 是否完成、引用是否稳定、页是否仍对应那个身份都要确认。安装最终映射后,swap 槽何时释放还需考虑其他引用,不能按“一次换入就立刻删除后备”推理。

这解释了为什么 swap-in fault 不一定产生磁盘 I/O:内容可能仍在内存或压缩后备中。大页换出也不必永远先拆成 4 KiB,支持路径与连续后备条件随内核演进,应以当前实现为准。

5. zswap、zram 和 swappiness 各自控制什么

zswap 是 swap 路径上的压缩内存缓存,通常与已配置的 swap 后备协作。压缩成功可以用较少 RAM 保留较多内容,缓存压力下可向后备写出;不可压缩数据和达到上限时又会出现不同处理。它不是无需 swap 身份的通用文件缓存。见 zswap。

zram 暴露压缩内存块设备,可以被配置为 swap,也可用于其他块设备用途。它的逻辑 disksize 不等于已经预留了同样多 RAM;压缩数据和元数据仍占实际内存,部分配置还支持写回后备。不能把“8 GiB zram”直接加到物理内存上当作确定的额外容量。见 zram。

空间账:100 MiB 压成 40 MiB,净收益是多少

压缩 100 MiB:腾出的是差额,不是整份内容 压缩前 100 MiB 原内容 压缩后 40 MiB 仍占 RAM 60 MiB 净腾出 假设压缩后 40 MiB,忽略元数据;还需支付压缩与解压的 CPU 成本。

忽略元数据,净腾出的是 60 MiB。若只能压成 95 MiB,收益就只剩 5 MiB,却仍支付压缩/解压 CPU 成本。工作集反复访问时,这些恢复工作也可能进入请求长尾。

swappiness:比较成本,不设置使用率阈值

swappiness 表达 swap 与文件页回收成本的相对偏好,当前接口范围为 0 到 200,不能当作“内存用到百分之多少才开始 swap”。在压缩内存后备等相对较便宜的环境中,超过 100 可以有其成本依据;0 也不是关闭 swap 的开关。调节时要看真实重读、换入与 CPU 代价,不能把固定 60 或 0 当作所有工作负载的答案。见 VM sysctl。

6. 回收与内存整理解决两个不同约束

手段改变什么没有自动解决什么
页面回收让内容不再占据当前物理页,增加可用数量不保证形成所需连续块
compaction 整理移动保留内容,聚合分散空闲页通常不增加空闲总量
zswap/zram 内容压缩用较少 RAM 表示内容不等于重新排列物理页

接着仍用 16 页模型,看数量够、形状不够时会发生什么。

假设 16 页中有 8 页空闲,但空闲与占用交错。order 0 申请有很多候选,order 3 却没有现成的 8 页连续块。若把可移动的占用页迁到另一侧,可能形成一个合适块,空闲总量仍是 8 页。迁移需要保持虚拟地址语义、处理页表和相关引用;遇到 pin 或不可迁移对象时,这个理想布局未必能达到。见 页面迁移。

kcompactd 与分配者的 direct compaction 分担整理工作,类似后台/同步两种成本承担方式。高阶申请失败、compaction 活跃,不必意味着机器完全没有可用内存;而大量匿名工作集缺容量,也不能仅靠把页排列得更整齐解决。

OOM:失败路径还受申请上下文约束

当允许范围内的回收无法取得足够进展时,部分申请可能进入 OOM 处理,但并不是每次高阶或 GFP_ATOMIC 失败都触发 OOM。OOM 杀死一个任务后,页也要随着引用释放才能逐步变得可用;被外部固定的页或其他共享引用仍可能延后资源回收。判断应结合 OOM 报告中的约束域、申请上下文和受害者信息。

7. 观察一个时间窗口,而不是比较开机累计大数

下面只读取统计,间隔 0.2 秒,不主动申请大量内存、回收缓存或配置 swap。它把扫描与回收的同类字段成对展示,并读取 MGLRU 的实际启用位。

from pathlib import Path
import time

def vmstat():
    return {key: int(value) for key, value in
            (line.split() for line in Path("/proc/vmstat").read_text().splitlines())}

before = vmstat()
start = time.monotonic()
time.sleep(0.2)
after = vmstat()
elapsed = time.monotonic() - start
for suffix in ("kswapd", "direct"):
    scan_key, steal_key = "pgscan_" + suffix, "pgsteal_" + suffix
    if scan_key in before and steal_key in before:
        scan = after[scan_key] - before[scan_key]
        steal = after[steal_key] - before[steal_key]
        print(suffix, "scan=", scan, "steal=", steal,
              "ratio=", steal / scan if scan > 0 else "无扫描")
for key in ("pswpin", "pswpout", "compact_stall", "compact_success", "compact_fail"):
    if key in before and key in after:
        print(key, after[key] - before[key])
print("window_seconds", elapsed)
for name in ("/sys/kernel/mm/lru_gen/enabled", "/proc/pressure/memory"):
    try:
        print(name, Path(name).read_text().strip())
    except OSError as error:
        print(name, type(error).__name__)

计数可能因内核配置而缺失;无活动窗口的零值只说明此窗口未观测到对应事件。MGLRU 的 enabled 是位掩码,应按接口文档解释各位;目录存在仅说明接口暴露,不代表主功能已启用。见 MGLRU 管理接口。

PSI memory 的 some 描述至少有任务因该资源停顿的时间,full 描述相关范围内所有非空闲任务都受阻的时间;它测量停顿,不是 RAM 使用率。累计 total 的单位为微秒,avg10/60/300 是相应窗口平均;全局与 cgroup 范围不能混算。见 PSI。

读完计数后,按因果顺序继续定位:

  1. 确认约束域。 压力属于全局、cgroup,还是特定允许节点?
  2. 确认停顿位置。 direct reclaim 是否把工作压到了申请者路径?
  3. 比较同一窗口。 联看扫描、回收、refault、swap、compaction 与业务延迟。

anon/file 与 kswapd/direct 是不同分组维度,不能把这些计数不加区分地全部相加。只有约束、恢复代价与业务停顿对应起来,才能判断该改善局部性、扩大容量,还是减少不必要的连续分配要求。