摘要
当系统执行
malloc()、kmalloc()或alloc_pages()时,如果 Buddy Allocator 已经没有足够的空闲页,Linux 是否会立即触发 OOM Killer?
答案是否定的。
在真正发生 OOM 之前,Linux 内核还会尝试回收已经分配出去、但暂时不再使用的内存页,例如 Page Cache、匿名页(Anonymous Page)以及其他可回收页面。这套机制就是 Memory Reclaim。
Memory Reclaim 是 Linux 内存管理子系统中最复杂、也是最核心的部分之一。它连接了 Buddy Allocator、Page Cache、LRU、Swap、Writeback、OOM 等多个子系统,是整个内存管理的"中枢"。
本文以 Linux Kernel 5.15 LTS 为主线,介绍 Memory Reclaim 的整体架构,包括回收触发时机、主要调用链、核心数据结构以及与其他内存管理模块之间的关系,为后续深入分析 kswapd、Direct Reclaim、shrink_node()、MGLRU 等内容奠定基础。
阅读建议
建议读者具备以下基础知识:
Linux 内存布局
Buddy Allocator
Page Cache
LRU 链表
Swap 基础
内存回收系列路线图
Memory Reclaim
├── 总体架构 ← 本章
├── Watermark
├── alloc_pages() Slowpath
├── kswapd
├── Direct Reclaim
├── shrink_node()
├── shrink_lruvec()
├── shrink_page_list()
├── Workingset
├── Refault
├── MGLRU
└── OOM
为什么需要 Memory Reclaim?
我们直接讲一个容易理解的问题:
假设现在linux系统已经运行了几天。
内存状态如下:
16GB ██████████████████████ Free 128MB
此时,一个新的进程调用:
malloc(2 * 1024 * 1024);
或者
alloc_pages(GFP_KERNEL, 0);
Buddy Allocator 会尝试从空闲页中分配新的物理页。
如果 Free List 中仍然存在足够的空闲页,整个过程会很快完成。
但是,当系统中的空闲页已经不足时,会出现另外一种情况。
很多读者会认为:空闲页没有了 → 分配失败 → OOM。
实际上并不是这样。
Linux 会首先尝试"释放已经使用但暂时不需要的内存",再把这些页面重新交给 Buddy Allocator。
这个过程,就是 Memory Reclaim。
因此,Memory Reclaim 的本质可以概括为一句话:
Memory Reclaim 的目标不是直接为应用程序分配内存,而是将已经分配出去、但当前可以释放的页面重新变成 Buddy Allocator 可以再次分配的 Free Page。
可以把它理解成 Linux 内存管理中的"循环再利用"机制。
Buddy Allocator 为什么不能解决所有问题?
在之前 buddy allcator 文章中,我们已经介绍了 Linux 物理页面分配器的工作原理。
Buddy Allocator 负责管理系统中的所有空闲物理页,当内核需要新的物理内存时,最终都会调用 alloc_pages() 从 Buddy System 中申请页面。
整个分配流程如下图所示:
Application
│
▼
malloc()/kmalloc()
│
▼
alloc_pages()
│
▼
Buddy Allocator
│
▼
Free Area
看起来,这套机制已经能够满足所有的内存分配需求。
那么问题来了:如果 Buddy Allocator 中已经没有足够的 Free Page,会发生什么?
很多人的第一反应是:分配失败,然后触发 OOM Killer。
事实上,这只是最后的最后才会发生的事情。
Linux 内核在宣布"内存耗尽"之前,还会经历一系列复杂的自救过程。
这些过程包括:
后台回收(kswapd)
同步回收(Direct Reclaim)
页面写回(Writeback)
匿名页交换(Swap)
页面迁移(Compaction)
最终 OOM Killer
真正的 OOM,只是这一系列流程全部失败之后的最后一步。
因此,我们可以把整个内存分配流程重新画成下面这样:
alloc_pages()
│
▼
Buddy Allocator
│
┌───────────┴───────────┐
│ │
有空闲页 没有空闲页
│ │
▼ ▼
分配成功 Memory Reclaim
│
┌───────────────┴───────────────┐
│ │
回收成功 回收失败
│ │
▼ ▼
返回 Buddy OOM Killer
从这张图可以发现:
Buddy Allocator 并不是 Linux 内存管理的终点。
真正决定系统能否继续运行的,是 Memory Reclaim。它负责把已经分配出去、但暂时不再使用的页面重新释放出来,再交还给 Buddy Allocator。
换句话说:
Buddy Allocator 负责"分配",Memory Reclaim 负责"再生"。
两者共同组成了 Linux 内存管理的完整闭环。
Memory Reclaim 在 Linux MM 中的位置
理解 Reclaim,首先要理解它在整个 Memory Management 中扮演什么角色。
如果把 Linux 内存管理看成一座水库,那么可以这样理解:
+-------------------------+
| 用户申请内存 |
+------------+------------+
|
v
alloc_pages()
|
v
+-------------------+
| Buddy Allocator |
+-------------------+
|
有空闲页 | 无空闲页
+-------------+-------------+
| |
v v
返回物理页 Memory Reclaim
|
+--------------------+-------------------+
| | |
v v v
Page Cache Anonymous Page Writeback
| | |
+---------+----------+-------------------+
|
v
释放 Free Page
|
v
Buddy Allocator
从整个流程来看,Memory Reclaim 并不会直接分配页面。
它真正负责的是:
扫描已经使用的内存;
找出可以释放的页面;
将这些页面重新加入 Buddy Allocator 的 Free List;
然后让
alloc_pages()再次尝试分配。
因此,可以认为:
Buddy Allocator 是"生产者",Memory Reclaim 是"资源回收者"。
二者缺一不可。
Memory Reclaim 的目标是什么?
很多初同学可能会认为:
Reclaim 的目标就是"释放内存"。
这个说法其实并不准确。
Linux Reclaim 真正追求的是:
在尽可能不影响系统运行的前提下,释放尽可能多的可回收页面。
这里有两个关键词:
可回收(Reclaimable)
尽可能少影响性能
例如:
一个干净(Clean)的 Page Cache 页面,可以直接回收;
一个脏(Dirty)的文件页,需要先写回磁盘;
一个匿名页,需要先写入 Swap;
一个正在被访问的热点页面,则应该尽量保留。
因此,Memory Reclaim 并不是简单地"删除页面",而是在不同类型页面之间进行权衡。
也正因为如此,它才需要:
LRU(判断冷热)
Writeback(处理脏页)
Swap(保存匿名页)
Refault(避免误删热点页)
MGLRU(提高冷热识别准确性)
这些机制共同协作,完成一次完整的内存回收。
Memory Reclaim 到底在回收什么?
看到"内存回收(Memory Reclaim)"这个名字,很多人都会产生一个误解:是不是系统把已经分配出去的内存全部释放掉?
当然不是。如果 Linux 可以随意释放任何物理页,那么程序很快就会因为访问非法内存而崩溃。
事实上,Memory Reclaim 并不是回收所有页面,而是只回收"当前可以安全释放"的页面。
换句话说:
Memory Reclaim 回收的是"页面(Page)",而不是"进程(Process)"。
这也是它与 OOM Killer 最大的区别:
Memory Reclaim 尝试释放页面;
OOM Killer 则是终止进程,通过释放整个进程的地址空间来获得大量内存。
因此,在真正发生 OOM 之前,Linux 会尽可能依靠 Memory Reclaim 来解决内存压力。
哪些页面可以回收?
从 Linux 内核的角度来看,并不是所有页面都具有相同的重要性。
有些页面随时可以丢弃,有些页面则必须先保存数据,而有些页面则根本不能释放。
根据回收方式的不同,可以大致分为下面几类:
可以看到:
真正能够参与 Reclaim 的,大多数都是普通数据页(User Page)。
而内核代码段、内核静态数据、设备保留内存等区域,并不会参与页面回收。
最容易回收的是 Page Cache
假设我们执行下面的命令:
cat big_file > /dev/null
文件内容会首先进入 Page Cache。
这些缓存的存在,可以显著提升下一次文件读取的速度。
但是,如果系统突然需要更多物理内存,那么这些缓存还能重新从磁盘读取。
因此,对于 Linux 来说:
Page Cache 本质上是一份"可重建的数据副本"。
既然可以重新读取,那么自然也就可以优先释放。
这也是为什么:Page Cache 通常是 Memory Reclaim 首先考虑回收的对象。
后面的章节,我们会看到 vmscan.c 是如何扫描 File LRU,并逐步释放这些缓存页面的。
Anonymous Page 为什么不能直接释放?
相比于文件缓存,匿名页(Anonymous Page)就复杂得多。
例如:
char *buf = malloc(1024 * 1024);
程序向这块缓冲区写入了大量数据:
memset(buf, 0x5A, 1024 * 1024);
这些数据:
不属于任何文件;
没有磁盘上的备份;
只存在于物理内存中。
如果 Linux 直接释放这部分页面,那么应用程序的数据将永久丢失。
因此,匿名页不能像 Page Cache 那样直接回收。
Linux 必须先将它们写入 Swap:
Anonymous Page
│
▼
Swap Out
│
▼
释放物理页
只有完成 Swap Out 之后,这些物理页才能重新交还给 Buddy Allocator。
Dirty Page 为什么需要 Writeback?
再来看另一种情况。
假设应用程序执行:
write(fd, buffer, len);
数据首先进入 Page Cache。
此时,这些页面已经被修改,但还没有真正写入磁盘。
这种页面称为 Dirty Page(脏页)。
如果此时直接释放:
那么磁盘上的文件内容将发生丢失。
因此:
Dirty Page 必须经过下面的流程:
Dirty Page
│
▼
Writeback
│
▼
Clean Page
│
▼
Memory Reclaim
也就是说:
Memory Reclaim 并不是简单地释放页面,它还需要协调 Writeback 子系统,共同完成页面回收。
Memory Reclaim 的真正目标
看到这里,我们可以重新理解前面提出的问题:
Memory Reclaim 的目标究竟是什么?
很多资料会简单地回答:
"释放内存。"
但这个答案并不准确。
Memory Reclaim 真正的目标应该是:
在尽可能不影响系统运行的前提下,将可以回收的页面重新转换为 Buddy Allocator 可分配的 Free Page。
这里包含三个关键点:
不是所有页面都可以回收;
不同类型页面采用不同的回收策略;
最终目标不是删除数据,而是恢复 Buddy Allocator 的可分配能力。
因此,整个 Memory Reclaim 可以抽象为下面这张图:
已分配页面
│
┌─────────────┼─────────────┐
│ │ │
▼ ▼ ▼
File Page Anonymous Dirty Page
│ │ │
▼ ▼ ▼
直接释放 Swap Out Writeback
└─────────────┼─────────────┘
│
▼
Free Page
│
▼
Buddy Allocator
至此,我想大家应该已经初步理解:
Memory Reclaim 并不是一个简单的"释放内存"过程,而是一套围绕不同页面类型展开的协同机制。
而真正负责执行这些回收工作的,就是 Linux 内核中的 vmscan 子系统。
Memory Reclaim 是什么时候开始工作的?
经过前面的介绍,我们已经知道:
Memory Reclaim 的任务是将已经分配出去、但当前可以释放的页面重新转换为 Buddy Allocator 可以再次分配的 Free Page。
那么,一个新的问题随之而来:Linux 是什么时候决定开始进行 Memory Reclaim 的?
很多读者第一次接触 Linux 内存管理时,都会有一种直觉:是不是只有当系统完全没有空闲内存(Free Page)时,才会启动 Memory Reclaim?
事实上,这种理解并不正确。
Linux 并不会等到系统"无内存可用"时才开始回收,而是在内存压力(Memory Pressure)逐渐增加的过程中,就会提前启动回收机制。
这是 Linux 内存管理设计中非常重要的一项原则:
回收应该尽可能发生在"内存即将耗尽之前",而不是"内存已经耗尽之后"。
这样做的目的很简单:
如果等到系统已经没有任何可用页面,再开始回收,那么此时所有申请内存的线程都只能等待回收完成,系统延迟会急剧增加,严重时甚至会进入长时间卡顿(Stall)。
因此,Linux 更倾向于:
提前发现内存压力,并在后台逐步回收页面,从而尽可能保证后续的内存分配能够快速完成。
Memory Pressure:Linux 如何感知内存压力?
那么,Linux 如何知道系统开始出现内存压力了呢?
答案就是:
通过持续监控各个内存 Zone 中剩余的 Free Page 数量。
需要注意的是,Linux 并不是只维护一个全局的空闲内存值。
由于物理内存按照用途被划分为多个 Zone(如 ZONE_DMA、ZONE_NORMAL 等),每个 Zone 都拥有自己的空闲页面管理和回收策略。
因此,对于 Linux 来说,真正关注的是:每个 Zone 中还剩余多少可以立即分配的 Free Page。
随着系统运行:
应用程序不断申请内存;
Page Cache 持续增长;
Slab 不断分配对象;
Zone 中的 Free Page 会不断减少。
Memory Pressure 并不是"没有内存",而是"可用空闲页面正在持续减少"。
这也是 Linux 提前启动 Memory Reclaim 的依据。
为什么不能等到 Free Page 为 0?
很多人可能会想到:
既然 Memory Reclaim 能够释放页面,那么为什么不等到 Free Page 全部耗尽之后,再统一进行回收呢?
原因主要有以下几个方面。
页面回收本身需要时间
无论是扫描 LRU 链表,还是回收 Page Cache,亦或是将匿名页写入 Swap,都不是一个瞬间完成的过程。
例如:
扫描页面需要遍历 LRU;
Dirty Page 需要等待 Writeback;
Anonymous Page 可能需要 Swap Out;
某些页面还需要解除映射(Unmap)。
如果直到 Free Page 为零才开始回收,那么申请内存的线程只能一直等待整个回收过程结束。
因此,提前启动回收可以显著降低分配延迟。
高阶页面分配更加困难
Linux 中很多内存申请并不是只需要一个 4KB 页面。
例如:
网络子系统;
CMA;
Transparent Huge Page(THP);
某些驱动 DMA 缓冲区;
都可能需要连续的大块物理内存。
随着系统运行,即使 Free Page 的总数量仍然不少,也可能因为严重碎片化而无法满足高阶页面分配。
因此:
Memory Reclaim 不仅关注空闲页面数量,还需要为后续的连续页面分配创造条件。
这也是为什么后续还会涉及 Memory Compaction(内存规整)。
避免系统出现明显卡顿
如果回收发生得太晚,用户线程就不得不亲自参与回收工作。
这种情况称为:Direct Reclaim。
Direct Reclaim 最大的问题是:
执行回收工作的不再是后台线程,而是当前申请内存的用户线程。
这意味着:
一次普通的 malloc(),
甚至一次 read()、write(),
都有可能因为进入内存回收而阻塞几十毫秒,甚至更长时间。
因此,Linux 更希望由后台线程提前完成回收,而不是让前台业务线程承担回收成本。
Linux 如何决定什么时候开始回收?
虽然前面一直提到:Linux 会根据内存压力决定是否启动回收。
但是:
"内存压力"到底如何量化?
Linux 并不会简单判断:
Free Memory < 100MB
或者:
剩余内存不足 10%
因为不同设备:
总内存容量不同;
Zone 大小不同;
内核保留内存不同;
高阶页面需求不同。
因此,Linux 使用了一套更加灵活的机制:Watermark(水位线)。
Watermark 可以理解为:
每个 Zone 都维护了几条不同的"安全警戒线"。
当 Free Page 数量逐渐降低并越过这些警戒线时,Linux 会采取不同级别的措施,例如:
唤醒后台回收线程;
当前线程直接参与回收;
保留部分页面供高优先级分配使用。
需要注意的是:
本文作为 Reclaim 总览,只介绍 Watermark 的作用。
至于:
Watermark 是如何计算的?
WMARK_MIN、WMARK_LOW、WMARK_HIGH有什么区别?kswapd为什么会被唤醒?alloc_pages()为什么会进入 Slowpath?
这些问题可以在 [linux内存管理] 第023篇 watermark详解得到答案。
Memory Reclaim 的两种工作方式
当系统检测到内存压力后,Linux 并不会始终采用同一种回收策略。
根据触发时机的不同,可以分为两种主要方式:
Background Reclaim(后台回收)
由内核线程 kswapd 在后台执行。
其特点是:
提前回收;
异步执行;
尽量减少业务线程受到影响。
理想情况下,大部分页面都应该由kswapd提前回收完成。
Direct Reclaim(同步回收)
如果后台回收速度跟不上内存申请速度,那么申请内存的线程就不得不亲自参与回收。
这就是 Direct Reclaim。
它具有以下特点:
同步执行;
当前线程阻塞;
延迟明显增加;
通常意味着系统已经处于较大的内存压力之下。
因此:Background Reclaim 是"提前预防",Direct Reclaim 则是"现场救火"。
Linux 始终希望前者能够完成大部分工作,而尽量避免后者频繁发生。
源码导引
关于linux内核中内存回收的部分涉及的源码是非常庞大的,为了方便大家对这块有一个清晰的认知,这里列出一下
mm/
├── page_alloc.c
│ 物理页面分配与 Buddy Allocator 的核心实现
│
│ alloc_pages()
│ 页面分配的常用入口
│
│ __alloc_pages()
│ 页面分配的核心入口
│
│ get_page_from_freelist()
│ 根据 Zonelist 和 Watermark 从 Buddy 中寻找空闲页面
│
│ __alloc_pages_slowpath()
│ 快速分配失败后的慢速路径
│
│ wake_all_kswapds()
│ 内存水位不足时唤醒对应 Node 的 kswapd
│
│ __alloc_pages_direct_reclaim()
│ 当前线程进入 Direct Reclaim
│
│ __alloc_pages_direct_compact()
│ 尝试通过内存规整满足高阶页面分配
│
│ should_reclaim_retry()
│ 判断页面回收后是否值得继续重试
│
│ should_compact_retry()
│ 判断内存规整后是否值得继续重试
│
│ page_outside_zone_boundaries()
│ 检查页面是否位于指定 Zone 的范围内
│
│ __free_pages()
│ 将回收成功的页面重新释放给 Buddy Allocator
│
│
├── vmscan.c
│ Memory Reclaim 的核心实现
│
│ try_to_free_pages()
│ Direct Reclaim 的主要入口
│
│ do_try_to_free_pages()
│ 执行直接页面回收的主体流程
│
│ throttle_direct_reclaim()
│ 内存紧张时限制 Direct Reclaim 线程
│
│ balance_pgdat()
│ kswapd 执行后台页面回收的核心函数
│
│ kswapd()
│ 每个 NUMA Node 对应的后台回收线程
│
│ kswapd_run()
│ 创建并启动 kswapd 线程
│
│ wakeup_kswapd()
│ 根据 Zone 水位唤醒 kswapd
│
│ kswapd_try_to_sleep()
│ 判断 kswapd 是否可以重新进入睡眠
│
│ shrink_node()
│ 以 NUMA Node 为单位组织页面回收
│
│ shrink_node_memcgs()
│ 遍历 Node 中的 Memory Cgroup 执行回收
│
│ shrink_lruvec()
│ 对一个 lruvec 中的各类 LRU 链表执行扫描
│
│ get_scan_count()
│ 计算 Anonymous LRU 和 File LRU 应扫描的页面数量
│
│ shrink_list()
│ 根据 Active/Inactive 和 Anon/File 类型选择回收流程
│
│ shrink_active_list()
│ 扫描 Active LRU,并将冷页面降级到 Inactive LRU
│
│ shrink_inactive_list()
│ 从 Inactive LRU 隔离页面并尝试回收
│
│ isolate_lru_pages()
│ 从 LRU 链表中批量隔离候选页面
│
│ shrink_page_list()
│ Linux 5.15 中真正处理候选页面的核心函数
│
│ page_check_references()
│ 根据页面引用情况决定保留、激活或回收页面
│
│ pageout()
│ 在允许的条件下发起脏文件页回写
│
│ remove_mapping()
│ 从文件 Address Space 或 Swap Cache 中移除页面
│
│ putback_lru_page()
│ 将暂时无法回收的页面重新放回 LRU
│
│ reclaim_clean_pages_from_list()
│ 从给定页面链表中优先回收干净页面
│
│ shrink_slab()
│ 调用注册的 Shrinker 回收 Slab 和文件系统缓存
│
│ do_shrink_slab()
│ 执行某个 Shrinker 的对象扫描和回收
│
│ drop_slab()
│ 主动触发各个 Node 上的 Slab 缓存回收
│
│
├── swap.c
│ 页面 LRU 状态维护
│
│ lru_cache_add()
│ 将新页面延迟加入对应的 LRU 链表
│
│ lru_cache_add_inactive_or_unevictable()
│ 将页面加入 Inactive LRU 或 Unevictable LRU
│
│ lru_add_drain()
│ 将当前 CPU 的 pagevec 批量提交到全局 LRU
│
│ lru_add_drain_all()
│ 清空所有 CPU 上积累的 LRU pagevec
│
│ mark_page_accessed()
│ 页面被访问时更新其引用和活跃状态
│
│ activate_page()
│ 将页面提升到 Active LRU
│
│ deactivate_page()
│ 请求将页面从 Active LRU 降级
│
│ rotate_reclaimable_page()
│ 将可回收页面移动到 LRU 尾部
│
│ release_pages()
│ 批量释放页面引用,并在必要时将页面移出 LRU
│
│ pagevec_lru_move_fn()
│ 批量执行页面在不同 LRU 状态之间的迁移
│
│
├── swap_state.c
│ Swap Cache 管理
│
│ add_to_swap_cache()
│ 将匿名页加入 Swap Cache
│
│ delete_from_swap_cache()
│ 将页面从 Swap Cache 中移除
│
│ lookup_swap_cache()
│ 根据 Swap Entry 查询 Swap Cache
│
│ find_get_page()
│ 获取已经存在于 Swap Cache 中的页面
│
│ read_swap_cache_async()
│ Swap In 时异步读取交换页面
│
│ free_page_and_swap_cache()
│ 释放页面并尝试释放对应的 Swap Cache
│
│ free_pages_and_swap_cache()
│ 批量释放页面及其 Swap Cache
│
│
├── swapfile.c
│ Swap Area、Swap Entry 和交换槽管理
│
│ get_swap_pages()
│ 为待换出的匿名页分配 Swap Entry
│
│ swap_duplicate()
│ 增加 Swap Entry 的引用计数
│
│ swap_free()
│ 释放 Swap Entry
│
│ free_swap_and_cache()
│ 释放 Swap Entry 及其关联的 Swap Cache
│
│ try_to_free_swap()
│ 在条件允许时释放页面占用的 Swap Entry
│
│ swap_cluster_readahead()
│ Swap In 时执行基于 Cluster 的预读
│
│
├── page_io.c
│ Swap 页面底层 I/O
│
│ swap_writepage()
│ 将匿名页写入 Swap 设备
│
│ swap_readpage()
│ 从 Swap 设备读取页面
│
│ end_swap_bio_write()
│ 处理 Swap Out I/O 完成事件
│
│ end_swap_bio_read()
│ 处理 Swap In I/O 完成事件
│
│
├── workingset.c
│ Workingset Detection 与 Refault 算法
│
│ workingset_eviction()
│ 页面被逐出时生成 Shadow Entry
│
│ workingset_refault()
│ 页面重新访问时计算 Refault Distance
│
│ workingset_activation()
│ 记录因 Refault 而重新激活的页面
│
│ workingset_age_nonresident()
│ 更新非驻留页面的逻辑年龄
│
│ workingset_update_node()
│ 更新 NUMA Node 上的 Workingset 状态
│
│ shadow_lru_isolate()
│ 回收过期的 Shadow Entry
│
│
├── rmap.c
│ Reverse Mapping,查找物理页对应的用户页表映射
│
│ try_to_unmap()
│ 回收页面前尝试解除所有用户空间 PTE 映射
│
│ try_to_unmap_one()
│ 解除页面的单个 VMA 映射
│
│ page_referenced()
│ 通过反向映射检查页面近期是否被访问
│
│ page_referenced_one()
│ 检查单个 VMA 中的页面引用状态
│
│ page_mkclean()
│ 清除映射页表项中的 Dirty 状态
│
│ page_vma_mapped_walk()
│ 遍历页面在指定 VMA 中对应的页表项
│
│
├── page-writeback.c
│ 脏页统计、限速与后台回写控制
│
│ set_page_dirty()
│ 将页面标记为 Dirty
│
│ account_page_dirtied()
│ 更新脏页统计信息
│
│ balance_dirty_pages()
│ 根据脏页比例限制产生脏页的任务
│
│ balance_dirty_pages_ratelimited()
│ 按一定频率触发脏页限速检查
│
│ wb_domain_writeout_inc()
│ 更新 Writeback Domain 的写出统计
│
│ laptop_mode_timer_fn()
│ Laptop Mode 下延迟触发回写
│
│
├── backing-dev.c
│ Backing Device 与 Writeback 实例管理
│
│ bdi_register()
│ 注册 Backing Device
│
│ wb_get_create()
│ 获取或创建对应的 Writeback 实例
│
│ wb_start_background_writeback()
│ 唤醒后台 Writeback 工作
│
│ wb_wakeup()
│ 唤醒指定的 Writeback Worker
│
│
├── filemap.c
│ Page Cache 与 Address Space 的基础管理
│
│ filemap_get_pages()
│ 从 Page Cache 获取文件页
│
│ filemap_fault()
│ 处理文件映射缺页异常
│
│ add_to_page_cache_lru()
│ 将文件页加入 Page Cache 并进入 LRU
│
│ delete_from_page_cache()
│ 将页面从 Page Cache 中删除
│
│ filemap_release_folio()
│ 释放文件页关联的私有资源
│
│ invalidate_mapping_pages()
│ 使指定范围内的 Page Cache 页面失效
│
│
├── truncate.c
│ Page Cache 截断与失效处理
│
│ truncate_inode_pages()
│ 删除 Address Space 中指定范围的缓存页面
│
│ truncate_inode_pages_range()
│ 截断指定字节范围内的 Page Cache
│
│ invalidate_inode_pages2()
│ 强制失效文件对应的缓存页面
│
│ invalidate_complete_page()
│ 从 Address Space 中移除完整页面
│
│
├── memcontrol.c
│ Memory Cgroup 内存记账、LRU 与局部回收
│
│ mem_cgroup_charge()
│ 将新分配页面计入指定 Memory Cgroup
│
│ mem_cgroup_uncharge()
│ 页面释放时取消 Memcg 记账
│
│ mem_cgroup_lruvec()
│ 获得某个 Memcg 在指定 Node 上的 lruvec
│
│ mem_cgroup_iter()
│ 遍历 Memory Cgroup 层级
│
│ try_charge()
│ 检查 Memcg 限制并尝试完成内存 Charge
│
│ try_to_free_mem_cgroup_pages()
│ 对指定 Memory Cgroup 执行页面回收
│
│ mem_cgroup_oom()
│ Memcg 回收失败后进入 Memcg OOM
│
│ memory_high_write()
│ 配置 memory.high 限制
│
│ memory_max_write()
│ 配置 memory.max 限制
│
│
├── compaction.c
│ 内存规整,为高阶页面分配整理连续物理内存
│
│ try_to_compact_pages()
│ 页面分配慢速路径中的内存规整入口
│
│ compact_zone_order()
│ 针对指定 Order 对 Zone 执行规整
│
│ compact_zone()
│ 内存规整的核心控制函数
│
│ isolate_migratepages()
│ 从物理地址低端隔离可迁移页面
│
│ isolate_freepages()
│ 从物理地址高端寻找并隔离空闲页面
│
│ migrate_pages()
│ 将已使用页面迁移到新的物理位置
│
│ compaction_suitable()
│ 判断当前 Zone 是否适合执行规整
│
│
├── migrate.c
│ 页面迁移的通用实现
│
│ migrate_pages()
│ 批量迁移页面
│
│ migrate_page()
│ 迁移普通页面
│
│ migrate_page_move_mapping()
│ 将页面从旧 Address Space 迁移到新页面
│
│ move_to_new_page()
│ 将旧页面内容和状态复制到新页面
│
│ putback_movable_pages()
│ 将迁移失败的页面放回原有链表
│
│
├── migrate_device.c
│ 设备私有内存和设备页面迁移
│
│ migrate_vma_setup()
│ 建立设备页面迁移上下文
│
│ migrate_vma_pages()
│ 执行 VMA 范围内的页面迁移
│
│ migrate_vma_finalize()
│ 完成迁移并恢复页表映射
│
│
├── oom_kill.c
│ 页面回收彻底失败后的 OOM 处理
│
│ out_of_memory()
│ OOM Killer 的主要入口
│
│ oom_badness()
│ 计算进程的 OOM 评分
│
│ select_bad_process()
│ 选择需要终止的进程
│
│ oom_kill_process()
│ 向目标进程发送终止信号
│
│ oom_reaper()
│ 异步解除被杀进程的匿名页映射
│
│ wake_oom_reaper()
│ 唤醒 OOM Reaper
│
│
├── vmpressure.c
│ 内存压力监控与通知
│
│ vmpressure()
│ 根据扫描量和回收量计算内存压力
│
│ vmpressure_prio()
│ 根据 Reclaim Priority 上报压力
│
│ vmpressure_work_fn()
│ 异步向用户空间发送压力事件
│
│
├── vmstat.c
│ Reclaim 相关统计信息维护
│
│ __count_vm_event()
│ 更新 VM Event 统计
│
│ count_vm_events()
│ 批量更新 VM Event
│
│ refresh_cpu_vm_stats()
│ 将 Per-CPU 统计同步到 Zone 或 Node
│
│ vmstat_update()
│ 周期性更新 VM 统计
│
│
├── internal.h
| mm 子系统内部使用的数据结构和函数声明
|
| struct compact_control
| 描述一次内存规整操作的控制参数
|
| struct migration_target_control
| 描述页面迁移目标页的分配条件
|
| free_unref_page_list()
| 批量将页面归还给页面分配器
除了 mm/ 目录,Memory Reclaim 还会与文件系统层的以下源码发生协作:
fs/
├── fs-writeback.c
│ 文件系统后台回写的核心实现
│
│ wb_workfn()
│ Writeback Worker 的执行函数
│
│ wb_writeback()
│ 执行一轮 Backing Device 回写
│
│ writeback_sb_inodes()
│ 扫描超级块中的脏 Inode
│
│ __writeback_inodes_wb()
│ 回写指定 Writeback 实例中的脏 Inode
│
│ wakeup_flusher_threads()
│ 唤醒后台 Flusher 线程
│
│
├── inode.c
│ Inode Cache 管理
│
│ prune_icache_sb()
│ 回收指定超级块中的 Inode Cache
│
│ evict()
│ 将不再使用的 Inode 从内存中移除
│
│
├── dcache.c
│ Dentry Cache 管理
│
│ shrink_dcache_sb()
│ 回收指定超级块中的 Dentry Cache
│
│ shrink_dentry_list()
│ 回收 Dentry 链表中的对象
│
│
├── super.c
│ 文件系统超级块与缓存回收
│
│ prune_super()
│ 通过 Shrinker 回收 Dentry 和 Inode Cache
│
│
└── drop_caches.c
用户主动触发缓存回收
drop_caches_sysctl_handler()
处理 /proc/sys/vm/drop_caches 写入操作
iterate_supers()
遍历所有超级块并回收文件系统缓存
drop_slab()
回收 Slab、Dentry 和 Inode 等缓存
相关公共头文件主要包括:
include/linux/
├── mmzone.h
│ Zone、Node、LRU 和 Watermark 相关定义
│
│ struct zone
│ 描述一个内存 Zone
│
│ struct pglist_data
│ 描述一个 NUMA Node
│
│ struct lruvec
│ 描述 Node 或 Memcg 维度下的一组 LRU 链表
│
│ enum zone_watermarks
│ 定义 WMARK_MIN、WMARK_LOW 和 WMARK_HIGH
│
│ enum lru_list
│ 定义 Active/Inactive、Anon/File 等 LRU 类型
│
│
├── mm_types.h
│ 页面和进程地址空间的核心数据结构
│
│ struct page
│ Linux 5.15 中描述物理页面的核心结构
│
│ struct mm_struct
│ 描述进程的虚拟地址空间
│
│ struct vm_area_struct
│ 描述进程中的一段虚拟内存区域
│
│
├── swap.h
│ Swap、LRU 和页面回收接口声明
│
│ try_to_free_pages()
│ Direct Reclaim 接口
│
│ wakeup_kswapd()
│ kswapd 唤醒接口
│
│ lru_cache_add()
│ 页面加入 LRU 的接口
│
│ release_pages()
│ 批量释放页面接口
│
│
├── swapops.h
│ Swap Entry 编码、解析与页表转换
│
│ swp_entry()
│ 构造 Swap Entry
│
│ swp_type()
│ 获取 Swap Area 编号
│
│ swp_offset()
│ 获取 Swap Slot 偏移
│
│ pte_to_swp_entry()
│ 将 Swap PTE 转换为 Swap Entry
│
│
├── rmap.h
│ Reverse Mapping 接口
│
│ try_to_unmap()
│ 解除页面映射
│
│ page_referenced()
│ 检查页面引用状态
│
│
├── writeback.h
│ 脏页回写相关接口与控制结构
│
│ struct writeback_control
│ 描述一次页面回写操作
│
│ balance_dirty_pages_ratelimited()
│ 对产生脏页的任务进行限速
│
│
├── shrinker.h
│ Slab 缓存回收器接口
│
│ struct shrinker
│ 描述一个可注册的缓存回收器
│
│ register_shrinker()
│ 向 Reclaim 子系统注册 Shrinker
│
│ unregister_shrinker()
│ 注销 Shrinker
│
│
├── memcontrol.h
│ Memory Cgroup 内存管理接口
│
│ mem_cgroup_lruvec()
│ 获取 Memcg 对应的 lruvec
│
│ mem_cgroup_charge()
│ 执行 Memcg 页面记账
│
│
└── compaction.h
内存规整接口
try_to_compact_pages()
页面分配慢速路径中的规整入口
compaction_suitable()
判断 Zone 是否适合执行规整
从整体职责上看,可以将这些文件归纳为以下几条主线:
Linux 5.15 中最核心的页面回收主线可以概括为:
mm/page_alloc.c 发现空闲页面不足
↓
mm/vmscan.c 组织页面扫描与回收
↓
mm/swap.c 维护和选择 LRU 页面
↓
mm/rmap.c 检查引用并解除页表映射
↓
mm/page-writeback.c 处理脏文件页
或者
mm/swap_state.c 处理匿名页 Swap Cache
↓
mm/vmscan.c 从映射关系中移除页面
↓
mm/page_alloc.c 将页面重新归还 Buddy Allocator
本章总结
经过前面的介绍,我们已经对 Linux Memory Reclaim 建立了整体认识。
Memory Reclaim 并不是一个独立的函数,而是一套由 Buddy Allocator、LRU、Page Cache、Writeback、Swap、RMAP、Memory Cgroup 以及 OOM 等多个模块协同工作的页面回收机制。
当 Buddy Allocator 无法满足新的页面分配请求时,Linux 并不会立即触发 OOM,而是首先尝试回收已经分配出去、但当前可以安全释放的页面,将它们重新转换为 Buddy Allocator 可再次分配的 Free Page。
整个 Memory Reclaim 子系统涉及多个源码文件,其中:
page_alloc.c决定何时进入页面回收;vmscan.c负责组织整个页面扫描与回收流程;swap.c、workingset.c、rmap.c、page-writeback.c等模块分别负责页面状态维护、热点识别、解除映射和脏页回写等工作;memcontrol.c和oom_kill.c则负责 Memory Cgroup 回收和最终的 OOM 处理。
本文主要介绍了 Memory Reclaim 的整体架构和各模块之间的职责关系,为后续源码分析建立整体认知框架。
从下一篇开始,我们将正式进入 Linux 5.15 的页面分配路径,分析 Buddy Allocator 是如何判断内存不足,以及 Watermark 又是如何决定何时启动页面回收的。
PS:其实大家可以发现,本文中的部分知识点甚至后续内存回收系列的知识点在前面的章节已经出现过。不过,当时我们关注的是各个模块本身,而从这一篇开始,我们将切换到 Memory Reclaim 的视角,重新审视 Buddy Allocator、LRU、Page Cache、Swap、Writeback 等模块在整个页面回收流程中的职责与协作关系。希望通过这种"纵向串联"的方式,帮助大家建立对 Linux 内存回收机制更加完整的认识。