Linux 内核页面回收与 LRU 管理机制
- 1. 概要
- 2. 背景与设计目标
- 3. 数据结构
- 4. 页面进入 LRU(新增页面到 LRU 的路径)
- 5. 页面热度感知机制
- 6. 页面在 LRU 链表间的迁移
- 7. 页面回收
- 8. workingset 与 refault 机制
- 9. file page cache 的 I/O 路径(与 LRU 的关联)
- 10. memcg 对 LRU 的影响
- 11. 总结:LRU 管理的整体视图
1. 概要
Linux 内核的 LRU 页面管理,本质上是在解决一个问题:有限的物理内存如何在众多进程之间高效分配,同时保证热数据尽量留在内存中。
内核的解法是维护一套双时钟(Double clock)链表(active/inactive),通过页面的访问历史来估算其热度,让热页留在内存、冷页被回收。整个机制由两条方向相反的路径共同驱动:用户访问推动页面晋升,内存压力推动页面降级和回收。
理解 LRU 管理需要把握三个核心问题:
- 页面如何进出 LRU:新页从哪里来,回收后去哪里
- 热度如何感知:内核如何知道一个页面最近是否被访问过
- 回收如何决策:在什么压力下回收,回收哪些页面
2. 背景与设计目标
2.1 为什么需要 LRU 管理
物理内存是有限的,而系统中同时运行的进程、文件缓存、内核数据结构都在竞争这块空间。当内存不足时,内核必须决定:把哪些页面换出或丢弃,为新的分配请求腾出空间。
这个决策的核心难题在于:内核无法预知未来,只能根据过去的访问历史来推断一个页面将来是否还会被用到。最朴素的假设是,最近被访问过的页面,将来被访问的概率更高,这就是 LRU(Least Recently Used,最近最少使用)策略的出发点。
然而严格的 LRU 实现代价极高 —— 每次访问都需要更新全局有序链表,在多核系统上锁竞争会成为严重瓶颈。Linux 内核采用的是 LRU 的近似实现,用可接受的精度换取可扩展的性能。
2.2 双时钟链表模型(Double CLOCK Lists)
Linux 的 LRU 实现基于双时钟链表模型,每个 NUMA 节点针对文件页和匿名页各维护两条链表:inactive 链表和 active 链表。
其工作方式类似两个时钟指针:
fault ------------------------+
|
+--------------+ | +-------------+
reclaim <- | inactive | <-+-- demotion <-- | active | <--+
+--------------+ +-------------+ |
| |
+---------------- promotion --------------------+
- 新页从 inactive 链表头部进入
- 回收扫描从 inactive 链表尾部进行
- 在 inactive 链表上被多次访问的页面晋升到 active 链表
- active 链表过大时,其尾部的页面降级回 inactive 链表
这个模型的关键特性是:页面需要被访问两次才能进入 active 链表。第一次访问只是进入 inactive 并打上 referenced 标记,第二次访问才触发晋升。这个两次访问门槛有效过滤了只被顺序读一次的流式 I/O 页面,避免它们污染 active 链表、挤占真正的热页。
2.3 active / inactive 的设计意图
active / inactive 两条链表承担不同的职责:
-
inactive 链表是回收页面的候选池。新页默认进入这里,经过一段时间的观察期 —— 如果在被扫描到之前再次被访问,说明它是热页,应当晋升保护;如果一直没有被访问,说明它是冷页,可以回收。inactive 链表的长度决定了这个观察窗口的大小。
-
active 链表是热页的保护区。进入这里的页面已经证明自己被访问过不止一次,受到保护不会被直接回收。但 active 链表不能无限增长,当它相对 inactive 链表过大时,尾部的页面会被降级回 inactive,重新接受观察。
这个设计带来两个重要性质:
- 避免污染 active 链表:一次性顺序读取的大量页面不会因为单次访问就占据 active 链表,不会挤压真正的热页
- 自适应平衡:active 和 inactive 的比例不是固定的,而是根据内存压力和访问模式动态调整,系统在不同 workload 下都能找到合适的平衡点
这两条链表共同构成了一个连续的热度谱:从 inactive 尾部(最冷,即将被回收)到 active 头部(最热,刚被访问),页面在这个热度谱上根据访问行为不断流动。
3. 数据结构
3.1 struct lruvec:LRU 管理的基本单元
LRU 管理的核心数据结构是 struct lruvec,定义在 include/linux/mmzone.h:
struct lruvec {
struct list_head lists[NR_LRU_LISTS]; // 5条 LRU 链表
struct zone_reclaim_stat reclaim_stat; // 扫描/旋转统计
atomic_long_t inactive_age; // workingset 计数器,驱逐 和 晋升 的 inactive file page 总数
unsigned long refaults; // 上次回收周期的 refault 数
#ifdef CONFIG_MEMCG
struct pglist_data *pgdat;
#endif
};
lruvec 挂在 struct pglist_data(NUMA 节点管理结构)上:
// include/linux/mmzone.h
typedef struct pglist_data {
...
spinlock_t lru_lock; // 保护该节点所有 LRU 链表操作的自旋锁
struct lruvec lruvec; // 该节点的 LRU 向量
...
};
组织层次如下:
系统
└── NUMA node(struct pglist_data)
├── lru_lock ← 保护以下所有链表
└── lruvec
├── lists[0] LRU_INACTIVE_ANON
├── lists[1] LRU_ACTIVE_ANON
├── lists[2] LRU_INACTIVE_FILE
├── lists[3] LRU_ACTIVE_FILE
└── lists[4] LRU_UNEVICTABLE
如果开启了 CONFIG_MEMCG,每个 NUMA 节点的每个 memcg 有自己独立的 lruvec,形成 per-node × per-memcg 的矩阵结构。
zone_reclaim_stat 记录了最近扫描和晋升的页面数,用于 get_scan_count() 决策 anon/file 页的扫描比例:
struct zone_reclaim_stat {
unsigned long recent_rotated[2]; // [0]=anon, [1]=file,最近晋升数
unsigned long recent_scanned[2]; // [0]=anon, [1]=file,最近扫描数
};
3.2 五条链表:类型划分与含义
enum lru_list 定义了五种链表类型:
enum lru_list {
LRU_INACTIVE_ANON = 0, // 匿名页 inactive
LRU_ACTIVE_ANON = 1, // 匿名页 active
LRU_INACTIVE_FILE = 2, // 文件页 inactive
LRU_ACTIVE_FILE = 3, // 文件页 active
LRU_UNEVICTABLE = 4, // 不可回收页
NR_LRU_LISTS = 5
};
- 匿名页(anon):进程的堆、栈、私有匿名 mmap 等,没有对应的磁盘文件,回收时需要 swap out 到交换分区。
- 文件页(file):文件 page cache、可执行文件的代码段等,有对应的磁盘文件,回收时直接丢弃(脏页需先写回)。
- unevictable:被 mlock() 锁定的页面,或其他不可回收的特殊页面。这条链表存在的意义是让回收扫描跳过这些页面,避免无效扫描。
回收扫描只遍历前四条链表(for_each_evictable_lru),unevictable 链表不参与回收。
3.3 struct page 中的关键 flags
每个物理页面对应一个 struct page,其 flags 字段中有几个与 LRU 管理直接相关的标记位(定义在 include/linux/page-flags.h):
| flag | 含义 | 相关操作 |
|---|---|---|
| PG_lru | page 当前在某条 LRU 链表上 | SetPageLRU() / ClearPageLRU() / PageLRU() |
| PG_active | page 在 active 链表上(否则在 inactive) | SetPageActive() / ClearPageActive() / PageActive() |
| PG_referenced | page 最近被访问过一次(软件标记) | SetPageReferenced() / ClearPageReferenced() / TestClearPageReferenced() |
| PG_unevictable | page 不可回收 | SetPageUnevictable() / PageUnevictable() |
| PG_reclaim | page 已被标记为待回收(writeback 路径) | SetPageReclaim() / PageReclaim() |
这几个 flag 共同描述了一个 page 在 LRU 体系中的完整状态:
PG_lru=0 → 不在任何 LRU 链表上(刚分配或已释放)
PG_lru=1, PG_active=0 → 在 inactive 链表上
PG_lru=1, PG_active=1 → 在 active 链表上
PG_lru=1, PG_unevictable=1 → 在 unevictable 链表上
PG_referenced 是 inactive 链表上的第一次访问标记,与 PG_active 共同实现两次访问晋升机制:
inactive + PG_referenced=0 →(第一次访问)→ inactive + PG_referenced=1
inactive + PG_referenced=1 →(第二次访问)→ active + PG_referenced=0
active + PG_referenced=0 →(访问) → active + PG_referenced=1
3.4 per-cpu pagevec:批量操作的缓冲层
直接操作 LRU 链表需要持有 lru_lock,在多核系统上频繁加锁会造成严重竞争。为此内核引入了 per-cpu 的 pagevec 作为缓冲层。
struct pagevec 是一个简单的定长数组,每个 CPU 独立持有(即 per-cpu 数据),无需加锁:
#define PAGEVEC_SIZE 14
struct pagevec {
unsigned long nr; // 当前存放的 page 数
unsigned long cold; // 是否为冷页(释放时用)
struct page *pages[PAGEVEC_SIZE]; // page 指针数组
};
mm/swap.c 中定义了 5 个 per-cpu pagevec,每个对应一种 LRU 操作:
static DEFINE_PER_CPU(struct pagevec, lru_add_pvec); // 新增入 LRU
static DEFINE_PER_CPU(struct pagevec, lru_rotate_pvecs); // 移到 inactive 尾部
static DEFINE_PER_CPU(struct pagevec, lru_deactivate_file_pvecs); // 文件页降级
static DEFINE_PER_CPU(struct pagevec, lru_lazyfree_pvecs); // 匿名页 lazyfree
#ifdef CONFIG_SMP
static DEFINE_PER_CPU(struct pagevec, activate_page_pvecs); // 晋升到 active
#endif
工作方式:page 先被加入当前 CPU 的 pagevec,当 pagevec 满(14个)或遇到复合页时,批量刷入 NUMA 节点的 LRU 链表(此时才加锁)。lru_add_drain() 可以强制将当前 CPU 的所有 pagevec 刷入 LRU,回收路径在扫描前会调用它以确保 LRU 状态是最新的。
这一层设计将锁竞争从每 page 操作降低到每 14 个 page 操作,在高并发场景下效果显著,代价是 LRU 链表的状态存在短暂的不一致窗口 —— pagevec 里的 page 还没有反映到 LRU 链表上。
4. 页面进入 LRU(新增页面到 LRU 的路径)
4.1 文件页:add_to_page_cache_lru() 路径
文件页在加入 page cache radix tree 的同时进入 LRU,入口是 add_to_page_cache_lru():
// mm/filemap.c
int add_to_page_cache_lru(struct page *page, struct address_space *mapping,
pgoff_t offset, gfp_t gfp_mask)
{
void *shadow = NULL;
// 1. 加入 page cache radix tree
ret = __add_to_page_cache_locked(page, mapping, offset, gfp_mask, &shadow);
// 2. 决定进入 active 还是 inactive
if (!(gfp_mask & __GFP_WRITE) &&
shadow && workingset_refault(shadow)) {
// 该页曾被驱逐,且 refault 距离满足条件 → 直接进 active
SetPageActive(page);
workingset_activation(page);
} else {
ClearPageActive(page); // 进入 file inactive LRU
}
// 3. 加入 LRU
lru_cache_add(page);
}
这里有一个重要细节:绝大多数文件页进入 inactive (ClearPageActive()),但如果该页之前被驱逐过且 refault 距离满足条件(workingset 机制),则直接进入 active,跳过 inactive 的观察期。
调用 add_to_page_cache_lru() 的典型场景:
do_sync_mmap_readahead()/__do_page_cache_readahead()— 预读(read ahead)page_cache_read()— page fault 时同步读入 (file_fault() -> page_cache_read())generic_perform_write()— 写文件时分配新 page
4.2 匿名页:fault 路径
匿名页没有对应的文件,在 page fault 时由内核分配并加入 LRU。以 do_anonymous_page() 为例(简化代码,只保留主干逻辑):
// mm/memory.c
static int do_anonymous_page(struct vm_fault *vmf)
{
// 1. 从 buddy 分配物理页
page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
// 2. 建立 rmap
page_add_new_anon_rmap(page, vma, vmf->address, false);
// 3. 加入 LRU(active 或 unevictable)
lru_cache_add_active_or_unevictable(page, vma);
// 4. 建立 PTE 映射
set_pte_at(vma->vm_mm, vmf->address, vmf->pte, entry);
}
注意匿名页直接进入 active 链表(通过 lru_cache_add_active_or_unevictable() 中的 SetPageActive()),而不是 inactive。这与文件页的默认行为不同,原因是匿名页没有磁盘备份,回收代价更高(需要 swap),因此给予更强的保护。
类似路径还有:
- do_wp_page() — COW 写时复制,新分配的页进 active
- do_swap_page() — swap in,从交换分区读回的页通过 lru_cache_add_anon() 进 inactive
4.3 特殊情况:直接进入 active 的场景
并非所有新页都从 inactive 开始,以下情况会直接进入 active:
| 场景 | 函数 | 原因 |
|---|---|---|
| 匿名页 fault(非 mlock) | lru_cache_add_active_or_unevictable() | 匿名页回收代价高,给予保护 |
| 文件页 | workingset refault | add_to_page_cache_lru() + SetPageActive() |
| mlock 区域的页 | add_page_to_unevictable_list() | 进 unevictable,不参与回收 |
4.4 __lru_cache_add() 与 pagevec 的刷入
无论哪条路径,最终都通过 __lru_cache_add() 进入 per-cpu pagevec 缓冲:
static void __lru_cache_add(struct page *page)
{
struct pagevec *pvec = &get_cpu_var(lru_add_pvec);
get_page(page); // 增加引用计数
if (!pagevec_add(pvec, page) || PageCompound(page))
__pagevec_lru_add(pvec); // pagevec 满或复合页,立即刷入
put_cpu_var(lru_add_pvec);
}
pagevec 满(14个)时,__pagevec_lru_add() 批量将 page 刷入 NUMA 节点的 LRU 链表,核心逻辑在 __pagevec_lru_add_fn() 中(保留核心逻辑的简化版):
static void __pagevec_lru_add_fn(struct page *page, struct lruvec *lruvec, ...)
{
enum lru_list lru = page_lru(page); // 根据 PG_active、PG_unevictable 决定目标 LRU 链表
SetPageLRU(page); // 设置 PG_lru 标记
add_page_to_lru_list(page, lruvec, lru); // 插入链表头部
}
page_lru() 的决策逻辑:
PG_unevictable=1 → LRU_UNEVICTABLE
PG_unevictable=0, file 页
PG_active=1 → LRU_ACTIVE_FILE
PG_active=0 → LRU_INACTIVE_FILE
PG_unevictable=0, anon 页
PG_active=1 → LRU_ACTIVE_ANON
PG_active=0 → LRU_INACTIVE_ANON
所以 PG_active 标记在 page 进入 pagevec 时就已经决定了它最终落入哪条链表,__pagevec_lru_add_fn() 只是执行这个决定。
pagevec 的强制刷入时机除了满之外,还有:
- lru_add_drain() — 回收路径扫描前主动调用,确保 LRU 状态最新
- lru_add_drain_all() — 刷入所有 CPU 的 pagevec
- 进程调度或睡眠时,内核会自动 drain plug(block 层),类似机制也适用于 pagevec
5. 页面热度感知机制
5.1 页面热度两种标记方式的分工
内核标记页面访问热度有两种方式,分工明确:
-
软件显式标记:在已知的 I/O 路径上主动调用
mark_page_accessed(),适用于文件页通过 read()/write() 系统调用访问的场景。内核完全掌握这些路径,可以精确标记。 -
PTE accessed flag bit:CPU 通过 MMU 访问页面时,在 page fault 中通过软件、或硬件自动将 PTE 中 accessed flag 标记为 1。内核定期扫描 PTE accessed flag bit 标记来判断页面是否被访问过。适用于所有通过 PTE 映射访问的场景(mmap 文件页、匿名页)。
两者互补:
| 访问场景 | 标记方式 |
|---|---|
| 文件页,read()/write() 路径 | 软件显式标记:mark_page_accessed() |
| 文件页,mmap 映射访问 | 软件 或 硬件 设置 PTE accessed flag bit |
| 匿名页,用户态指针访问 | 软件 或 硬件 设置 PTE accessed flag bit |
5.2 软件显式标记:mark_page_accessed()
mark_page_accessed()(调整过的版本)实现了两次访问晋升的状态机:
// mm/swap.c
void mark_page_accessed(struct page *page)
{
if (!PageActive(page) && !PageUnevictable(page) && PageReferenced(page)) {
// inactive + referenced → 第二次访问 → 晋升到 active
activate_page(page);
ClearPageReferenced(page);
if (page_is_file_cache(page))
workingset_activation(page);
} else if (!PageReferenced(page)) {
// 第一次访问 → 打上 referenced 标记
SetPageReferenced(page);
}
}
状态转换:
inactive, unreferenced →(第一次)→ inactive, referenced
inactive, referenced →(第二次)→ active, unreferenced
active, unreferenced →(访问) → active, referenced
典型调用场景:
- filemap_fault():mmap 文件页 fault 时
- do_generic_file_read():read() 系统调用读文件时
- mm/gup.c:get_user_pages() 函数 pin 住页面时
5.3 PTE accessed bit 的通用原理
page_referenced() 通过 rmap 遍历页面的所有 PTE,检查并清除 accessed flag bit,返回发现的引用数:
// mm/rmap.c
int page_referenced(struct page *page, int is_locked,
struct mem_cgroup *memcg, unsigned long *vm_flags)
{
...
struct page_referenced_arg pra = {
.mapcount = total_mapcount(page),
.memcg = memcg,
};
struct rmap_walk_control rwc = {
.rmap_one = page_referenced_one,
.arg = (void *)&pra,
.anon_lock = page_lock_anon_vma_read,
};
...
// 遍历所有映射该 page 的 PTE
// 对每个 PTE 调用 ptep_clear_flush_young()
// 返回 young bit 为 1 的 PTE 数量
rmap_walk(page, &rwc);
...
return pra.referenced;
}
page_referenced_one()
...
ptep_clear_flush_young()
ptep_clear_flush_young() 的实现:
// mm/pgtable-generic.c
int ptep_clear_flush_young(struct vm_area_struct *vma,
unsigned long address, pte_t *ptep)
{
int young = ptep_test_and_clear_young(vma, address, ptep);
if (young)
flush_tlb_page(vma, address); // 清除 TLB 中的缓存
return young;
}
page_referenced() 在两个地方被调用:
- shrink_inactive_list() -> page_check_references():决定 inactive 页是否晋升或回收
- shrink_active_list():决定 active 页是否降级
5.4 ARM32 Short Descriptor(2级分页)
5.4.1 Linux PTE 与硬件 PTE 的分离设计
ARM32 short descriptor 格式的硬件 PTE 没有 accessed flag bit,Linux 内核通过维护两套 PTE 来解决这个问题:
同一物理页(4KB)内:
[0 ~ 2047] Linux PTE 表(512项 × 4字节) ← 软件专用
[2048 ~ 4095] ARM 硬件 PTE 表(512项 × 4字节) ← ARM MMU 使用
5.4.2 L_PTE_YOUNG:纯软件标记
// arch/arm/include/asm/pgtable-2level.h
#define L_PTE_YOUNG (_AT(pteval_t, 1) << 1) // Linux PTE bit 1
这是纯软件定义的标记,ARM MMU 硬件不认识它。内核用它来模拟 accessed flag bit 的语义。
5.4.3 cpu_v7_set_pte_ext:一次调用同时写两张表(Linux PTE + ARM PTE)
set_pte_at() 最终调用 cpu_v7_set_pte_ext():
// arch/arm/mm/proc-v7-2level.S
ENTRY(cpu_v7_set_pte_ext)
str r1, [r0] @ 写 Linux PTE(含 L_PTE_YOUNG 等软件标记)
@ 将 Linux PTE bits 转换为硬件 PTE 格式...
@ 关键:检查 L_PTE_YOUNG
tst r1, #L_PTE_YOUNG
...
moveq r3, #0 @ 如果 Linux PTE 的 L_PTE_YOUNG=0 时,ARM MMU 硬件 PTE 清零(页面不可访问)
str r3, [r0, #2048] @ 写硬件 PTE
...
ENDPROC(cpu_v7_set_pte_ext)
核心设计:Linux PTE 的 L_PTE_YOUNG=0 时,ARM MMU 硬件 PTE 的 access flag bit 被清零,CPU 访问该页会触发 page fault。

5.4.4 软件模拟 accessed flag bit 的完整流程
1. 建立映射时
L_PTE_YOUNG=1 → ARM MMU 硬件 PTE 有效 → 页面可正常访问
2. 内存回收扫描过程调用 page_referenced() 清除 young bit (access flag bit, L_PTE_YOUNG)
ptep_clear_flush_young()
→ ptep_test_and_clear_young()
→ 清除 Linux PTE 的 L_PTE_YOUNG
→ cpu_v7_set_pte_ext() 将 ARM MMU 硬件 PTE 清零
→ flush_tlb_page() ← 使 TLB 中的旧映射失效
3. 用户进程再次访问
→ 页面的 ARM MMU 硬件 PTE 为 0 → CPU 触发 Data Abort(page fault)
→ handle_pte_fault()
→ 发现 Linux PTE 存在(L_PTE_PRESENT=1)但 ARM MMU 硬件 PTE 无效(为 0)
→ ptep_set_access_flags()
→ pte_mkyoung() ← 重新设置 L_PTE_YOUNG
→ set_pte_at() ← 恢复硬件 PTE
→ 内核记录:此页最近被访问过
这个机制的代价是:每次 page_referenced() 清除 young bit (access flag bit, L_PTE_YOUNG) 后,下一次访问都会产生一次额外的 page fault,这是用 fault 开销换取页面访问感知能力的权衡。
5.5 ARM32 LPAE / Long Descriptor(3级分页)
5.5.1 Linux PTE 与硬件 PTE 合并
LPAE 使用 64 位 PTE,有足够的 bit 空间,Linux PTE 和硬件 PTE 合并为同一张表,不再需要分离设计。
5.5.2 L_PTE_YOUNG = AF bit
// arch/arm/include/asm/pgtable-3level.h
#define L_PTE_YOUNG (_AT(pteval_t, 1) << 10) // 硬件 AF(Access Flag) bit
L_PTE_YOUNG 直接映射到 ARM 架构定义的 Access Flag(bit 10),不再是纯软件标记。
5.5.3 与 Short Descriptor 的异同
机制本质相同:清除 AF → 触发 fault → fault 处理中重置 AF。区别在于:
- Short descriptor:L_PTE_YOUNG 是软件专用 bit,通过清零整个硬件 PTE 来触发 fault,走 do_page_fault() 处理
- LPAE:L_PTE_YOUNG 就是硬件 AF bit,清除后 ARM 硬件会因 AF=0 产生 Access Flag Fault,对 Access Flag Fault 的处理也是走 do_page_fault()
5.6 ARM64
5.6.1 硬件 AF bit 的原生支持
ARM64 使用 LPAE 格式的 PTE,PTE_AF(bit 10)是硬件定义的 Access Flag:
// arch/arm64/include/asm/pgtable-hwdef.h
#define PTE_AF (_AT(pteval_t, 1) << 10) // Access Flag
// arch/arm64/include/asm/pgtable.h
#define pte_young(pte) (!!(pte_val(pte) & PTE_AF))
static inline pte_t pte_mkold(pte_t pte)
{
return clear_pte_bit(pte, __pgprot(PTE_AF));
}
static inline pte_t pte_mkyoung(pte_t pte)
{
return set_pte_bit(pte, __pgprot(PTE_AF));
}
ARM64 没有分离的 Linux PTE 和硬件 PTE,pte_young() 直接读取硬件 PTE 的 AF bit,实现简洁。
5.6.2 ptep_set_access_flags() 的原子操作
ARM64 的 ptep_set_access_flags() 使用 cmpxchg_relaxed() 原子操作更新 PTE,原因是 ARM64 支持硬件自动更新 AF(HAFDBS 特性),需要防止软件更新与硬件更新之间的竞争:
// arch/arm64/mm/fault.c
int ptep_set_access_flags(struct vm_area_struct *vma,
unsigned long address, pte_t *ptep,
pte_t entry, int dirty)
{
...
// 使用 cmpxchg 原子更新,防止与硬件 AF 更新竞争
pteval = READ_ONCE(pte_val(*ptep));
do {
old_pteval = pteval;
pteval |= pte_val(entry); // 设置 AF 等标记
pteval = cmpxchg_relaxed(&pte_val(*ptep), old_pteval, pteval);
} while (pteval != old_pteval);
...
}
5.6.3 FEAT_HAFDBS:硬件自动更新 AF
ARMv8.1 引入了 FEAT_HAFDBS(Hardware management of the Access Flag and Dirty state),通过 TCR_EL1.HA(bit 39)启用。启用后,CPU 在首次访问页面时自动将 AF 置 1,不再需要触发 fault 来感知访问。
// arch/arm64/include/asm/pgtable-hwdef.h
#define TCR_HA (UL(1) << 39) // Hardware Access flag update
#define TCR_HD (UL(1) << 40) // Hardware management of Dirty state
在笔者分析的 Linux 4.14 代码中,内核尚未启用 TCR_HA,ARM64 仍采用与 ARM32 LPAE 相同的 fault 模拟机制。
| 架构 | accessed bit 来源 | 配置方式 | 额外 fault 开销 |
|---|---|---|---|
| ARM32 Short Descriptor | 纯软件 L_PTE_YOUNG | 清零硬件 PTE → fault → 重置 | 有 |
| ARM32 LPAE | 硬件 AF bit(bit 10) | 清除 AF → fault → 重置 | 有 |
| ARM64(无 HAFDBS) | 硬件 AF bit PTE_AF | 清除 AF → fault → 重置 | 有 |
| ARM64(有 HAFDBS) | 硬件自动置位 AF | 直接读取/清除 | 无 |
6. 页面在 LRU 链表间的迁移
6.1 晋升(inactive → active):activate_page()
晋升由 mark_page_accessed() 触发(见第 5 章),最终调用 activate_page():
// mm/swap.c
void mark_page_accessed(struct page *page)
{
page = compound_head(page);
if (!PageActive(page) && !PageUnevictable(page) &&
PageReferenced(page)) {
/* inactive + referenced → 第二次访问 → 晋升到 active */
if (PageLRU(page))
activate_page(page); /* 页面经 activate_page_pvecs 晋升到 active LRU */
else
__lru_cache_activate_page(page);
ClearPageReferenced(page); /* 清除 PG_referenced */
if (page_is_file_cache(page))
workingset_activation(page);
} else if (...) {
...
}
...
}
activate_page()
...
__activate_page()
static void __activate_page(struct page *page, struct lruvec *lruvec, void *arg)
{
if (PageLRU(page) && !PageActive(page) && !PageUnevictable(page)) {
...
int lru = page_lru_base_type(page); // 确定 anon 还是 file
del_page_from_lru_list(page, lruvec, lru); // 从 inactive 移除
SetPageActive(page); // 设置 PG_active
lru += LRU_ACTIVE;
add_page_to_lru_list(page, lruvec, lru); // 加入 active 头部
...
}
}
在 SMP 系统上,activate_page() 先将 page 放入 per-cpu 的 activate_page_pvecs,批量刷入时才真正执行 __activate_page(),避免频繁加锁。
页面晋升时同时调用 workingset_activation(),递增 lruvec->inactive_age,用于 workingset refault 机制的计数:
void workingset_activation(struct page *page)
{
...
atomic_long_inc(&lruvec->inactive_age); // lruvec->inactive_age++
...
}
6.2 降级(active → inactive):shrink_active_list()
页面降级发生在页面回收路径,核心函数是 shrink_active_list()(简化版,只关注页面降级的相关逻辑)。
完整流程:
// mm/vmscan.c
static void shrink_active_list(unsigned long nr_to_scan,
struct lruvec *lruvec,
struct scan_control *sc,
enum lru_list lru)
{
...
// 1. 从 active 链表尾部 isolate 一批页
isolate_lru_pages(nr_to_scan, lruvec, &l_hold, ...);
// 2. 逐页判断
while (!list_empty(&l_hold)) {
page = lru_to_page(&l_hold);
if (page_referenced(page, 0, ...)) {
// 有 PTE 引用,且是 VM_EXEC 文件页 → 给一次额外机会,留在 active
if ((vm_flags & VM_EXEC) && page_is_file_cache(page)) {
list_add(&page->lru, &l_active);
continue;
}
}
// 其余情况:降级到 inactive
ClearPageActive(page);
list_add(&page->lru, &l_inactive);
}
// 3. 批量写回 LRU
move_active_pages_to_lru(lruvec, &l_active, ..., lru); // 留在 active
move_active_pages_to_lru(lruvec, &l_inactive, ..., lru - LRU_ACTIVE); // 降级到 inactive
}
降级决策逻辑:
| 条件 | 结果 |
|---|---|
| page_referenced() 返回 true,且是 VM_EXEC 文件页 | 留在 active(旋转到头部) |
| page_referenced() 返回 true,其他情况 | 降级到 inactive |
| page_referenced() 返回 false | 降级到 inactive |
注意:
匿名页即使有 PTE 引用也会被降级,只有可执行文件页才享有额外保护,原因是匿名页不太可能被 streaming I/O 污染。所谓 streaming I/O 指的是顺序读取大量数据、每个页面只访问一次就不再使用的 I/O 模式。典型场景:
- cat 一个大文件
- 视频播放器顺序读取视频文件
- 数据库做全表扫描
- 拷贝复制大文件
这类访问的特点是:每个页面被读入 page cache 后,PTE young bit 会被置 1(有访问),但之后再也不会被访问。如果内核把这些页面都晋升到 active 链表,会把真正的热页挤出去,造成 cache 污染。
另外,JVM(Java 虚拟机)的 JIT 编译器会在运行时动态生成机器码,这些代码存放在通过 mmap(PROT_EXEC) 分配的匿名内存区域里,因此这些匿名页带有 VM_EXEC 标记。如果对匿名页也做"VM_EXEC 则保留在 active"的保护,JVM 进程会产生大量受保护的 VM_EXEC 匿名页,占据 active 链表,挤压其他进程的内存。而且这些 JIT 代码页的访问模式和真正的可执行文件代码段不同,不一定值得长期保护。
所以内核的决策是:只保护 VM_EXEC 的文件页(如 .so、可执行文件的代码段),匿名页无论是否 VM_EXEC 都不给额外保护 —— 如果不排除匿名页,JVM 这类应用会让这个保护机制失效。
6.3 其他迁移操作
- rotate_reclaimable_page()
writeback 完成后,如果页面仍然可回收(未被锁定、未变脏),将其移到 inactive 链表尾部,加速回收:
void rotate_reclaimable_page(struct page *page)
{
// 条件:!PageLocked && !PageDirty && !PageUnevictable && PageLRU
// 操作:通过 lru_rotate_pvecs 将 page 移到 inactive 尾部
if (!PageLocked(page) && !PageDirty(page) &&
!PageUnevictable(page) && PageLRU(page)) {
struct pagevec *pvec;
unsigned long flags;
get_page(page);
local_irq_save(flags);
pvec = this_cpu_ptr(&lru_rotate_pvecs);
/* 添加 page 导致 per-cpu LRU 向量满了 或 page 为 复合页, 都要将 page 迁移到 NUMA 的 LRU 列表 */
if (!pagevec_add(pvec, page) || PageCompound(page))
pagevec_move_tail(pvec);
local_irq_restore(flags);
}
}
static void pagevec_move_tail_fn(struct page *page, struct lruvec *lruvec, void *arg)
{
del_page_from_lru_list(page, lruvec, page_lru(page));
ClearPageActive(page);
add_page_to_lru_list_tail(page, lruvec, page_lru(page)); // 插入尾部
}
将页面移到 LRU 链表尾部意味着它会更快被回收扫描到,这是对 writeback 刚完成的干净页的主动加速回收。
- deactivate_file_page()
强制将文件页降级到 inactive,用于标识页面是良好的候选回收页面的提示场景,例如页面无效化失败时:
static void lru_deactivate_file_fn(struct page *page, struct lruvec *lruvec, void *arg)
{
// 条件:PageLRU && !PageUnevictable && !page_mapped
del_page_from_lru_list(page, lruvec, lru + active);
ClearPageActive(page);
ClearPageReferenced(page); // 同时清除 referenced,加速老化
add_page_to_lru_list(page, lruvec, lru);
if (PageWriteback(page) || PageDirty(page))
SetPageReclaim(page); // 脏页/回写页:标记为待回收
else
list_move_tail(&page->lru, &lruvec->lists[lru]); // 干净页:移到 inactive 尾部
}
- mark_page_lazyfree()
将匿名页转换为 lazyfree 状态,移入 inactive file 链表,使其可以像文件页一样被直接丢弃(不需要 swap out):
static void lru_lazyfree_fn(struct page *page, struct lruvec *lruvec, void *arg)
{
// 条件:PageLRU && PageAnon && PageSwapBacked && !PageSwapCache
del_page_from_lru_list(page, lruvec, LRU_INACTIVE_ANON + active);
ClearPageActive(page);
ClearPageReferenced(page);
ClearPageSwapBacked(page); // 清除 swap 标记,变为[类文件页]
add_page_to_lru_list(page, lruvec, LRU_INACTIVE_FILE); // 进入 file inactive
}
这是 MADV_FREE 的底层实现 —— 用户态告知内核这块内存可以随时丢弃,内核将其标记为 lazyfree,在内存压力时直接释放,无需写入 swap。
6.4 active / inactive 的平衡:inactive_list_is_low()
shrink_list() 在决定是否压缩 active 链表时,调用 inactive_list_is_low() 检查平衡状态:
static unsigned long shrink_list(enum lru_list lru, ...)
{
if (is_active_lru(lru)) {
if (inactive_list_is_low(...))
shrink_active_list(...); // inactive 不足时才压缩 active
return 0;
}
return shrink_inactive_list(...);
}
inactive_ratio 的计算:
// mm/vmscan.c
static bool inactive_list_is_low(...)
{
...
if (file && actual_reclaim && lruvec->refaults != refaults) {
inactive_ratio = 0;
} else {
// inactive * inactive_ratio < active → inactive 不足,需要降级
gb = (inactive + active) >> (30 - PAGE_SHIFT); // 总内存换算为 GB
if (gb)
inactive_ratio = int_sqrt(10 * gb); // 随内存增大而增大
else
inactive_ratio = 1;
}
...
}
对应关系:
| 总内存 | inactive_ratio | inactive 目标占比 |
|---|---|---|
| < 1GB | 1 | 50% |
| 1GB | 3 | 25% |
| 10GB | 10 | ~9% |
| 100GB | 31 | ~3% |
| 1TB | 101 | ~1% |
内存越大,inactive 链表相对越小,active 链表占比越高。这是合理的:大内存系统的 working set 更大,需要更多 active 空间来保护热页。
特殊情况:workingset 激活时禁用保护,即 inactive_list_is_low() 里的代码片段:
if (file && actual_reclaim && lruvec->refaults != refaults) {
inactive_ratio = 0; // 强制 inactive_list_is_low() 返回 true
}
当检测到 refault 活动(说明有新的 working set 正在建立),强制将 inactive_ratio 设为 0,使 inactive_list_is_low() 始终返回 true,从而持续压缩 active 链表,快速淘汰旧的 working set,为新的 working set 腾出空间。
7. 页面回收
7.1 回收触发机制:watermark 与 kswapd / direct reclaim
内核通过三条水位线(watermark)来决定何时触发回收,定义在每个 内存 zone(struct zone) 上:
struct zone {
/* Read-mostly fields */
/* zone watermarks, access with *_wmark_pages(zone) macros */
unsigned long watermark[NR_WMARK]; /* 内存 zone 的各种水位线 */
...
};
free pages
│
├── high watermark(WMARK_HIGH) ← 高于此:内存充裕,不回收
│
├── low watermark(WMARK_LOW) ← 低于此:唤醒 kswapd 异步回收
│
└── min watermark(WMARK_MIN) ← 低于此:触发 direct reclaim 同步回收
触发路径在页面分配器 __alloc_pages_slowpath() 中:
// mm/page_alloc.c
// 分配失败,进入慢速路径
// 1. 唤醒 kswapd(异步,不阻塞分配者)
wake_all_kswapds(order, &ac);
→ wakeup_kswapd(zone, order, ...)
→ wake_up_interruptible(&pgdat->kswapd_wait)
// 2. 再次尝试分配(使用 WMARK_MIN)
page = get_page_from_freelist(..., ALLOC_WMARK_MIN, ...);
// 3. 仍然失败 → direct reclaim(同步,阻塞分配者)
progress = try_to_free_pages(ac->zonelist, order, gfp_mask, ...);
kswapd 是每个 NUMA 节点一个的内核线程(kswapd<N>),被唤醒后调用 balance_pgdat() 持续回收,直到所有 zone 的 free pages 恢复到 high watermark 以上,然后重新睡眠。
direct reclaim 在分配者的上下文中同步执行,会阻塞分配请求,是内存压力极大时的最后手段。
7.2 shrink_inactive_list():从 inactive 尾部扫描
回收的核心入口是 shrink_inactive_list(),核心流程如下(对源码进行过编辑):
// mm/vmscan.c
shrink_inactive_list(nr_to_scan, lruvec, sc, lru)
{
lru_add_drain(); // 确保 pagevec 已刷入 LRU
// 1. 从 inactive 尾部 isolate 一批页
isolate_lru_pages(nr_to_scan, lruvec, &page_list, ...);
// 2. 核心回收逻辑
nr_reclaimed = shrink_page_list(&page_list, pgdat, sc, ...);
// 3. 未能回收的页放回 LRU
putback_inactive_pages(lruvec, &page_list);
}
isolate_lru_pages() 从链表尾部取页,清除 PG_lru,将页从 LRU 摘出放入临时列表,此后这些页处于隔离状态,不在任何 LRU 链表上。
7.3 page_check_references():回收决策的核心逻辑
shrink_page_list() 对每个 isolated 页调用 page_check_references() 决定是否要被回收(代码有裁剪):
enum page_references page_check_references(struct page *page, ...)
{
referenced_ptes = page_referenced(page, ...); // 扫描 PTE young bit (L_PTE_YOUNG)
referenced_page = TestClearPageReferenced(page); // 检查并清除 PG_referenced
if (vm_flags & VM_LOCKED)
return PAGEREF_RECLAIM; // mlock 页,走回收流程(try_to_unmap 会移入 unevictable)
if (referenced_ptes) {
if (PageSwapBacked(page))
return PAGEREF_ACTIVATE; // 匿名页有 PTE 引用 → 晋升
SetPageReferenced(page);
if (referenced_page || referenced_ptes > 1)
return PAGEREF_ACTIVATE; // 文件页多次引用 → 晋升
if (vm_flags & VM_EXEC)
return PAGEREF_ACTIVATE; // 可执行文件页 → 晋升
return PAGEREF_KEEP; // 文件页单次引用 → 留在 inactive 再观察
}
if (referenced_page && !PageSwapBacked(page))
return PAGEREF_RECLAIM_CLEAN; // 有 PG_referenced 但无 PTE 引用的文件页 → 回收
return PAGEREF_RECLAIM; // 无任何引用 → 回收
}
结果处理:
PAGEREF_ACTIVATE → SetPageActive() → 放回 active 链表
PAGEREF_KEEP → 放回 inactive 链表(不回收)
PAGEREF_RECLAIM → 继续走回收流程
PAGEREF_RECLAIM_CLEAN → 继续走回收流程(文件页直接丢弃)
7.4 脏页处理:writeback 与回收的协作
shrink_page_list() 遇到脏页时,不能直接回收,需要先写回磁盘:
static unsigned long shrink_page_list(...)
{
...
if (PageDirty(page)) {
if (page_is_file_cache(page) &&
(!current_is_kswapd() || !PageReclaim(page) || ...)) {
// 非 kswapd,或 kswapd 首次遇到此脏页:
// 标记 PG_reclaim,移回 inactive 头部,等 flusher 写回
SetPageReclaim(page);
goto activate_locked;
}
// kswapd 且页面已标记 PG_reclaim(说明 flusher 已在处理):
// 调用 pageout() 触发写回
switch (pageout(page, mapping, sc)) {
case PAGE_KEEP: goto keep_locked;
case PAGE_ACTIVATE: goto activate_locked;
case PAGE_SUCCESS: ... // 写回成功,继续回收
}
}
...
}
关键设计:只有 kswapd 才能主动触发文件页的 writeback,避免 direct reclaim 路径上的栈溢出风险。普通进程的 direct reclaim 遇到脏页只能标记 PG_reclaim 后放回,等 kswapd 或 flusher 处理。
rotate_reclaimable_page() 在 writeback 完成后将干净页移到 inactive 尾部,加速其被回收扫描到。
7.5 isolate / putback 模式:并发安全的标准操作
LRU 上的所有操作都遵循 isolate → 处理 → putback 的三步走:
isolate_lru_page(page)
→ spin_lock(lru_lock)
→ ClearPageLRU(page) // 清除 PG_lru
→ del_page_from_lru_list() // 从链表摘出
→ get_page() // 增加引用计数
→ spin_unlock(lru_lock)
返回:page 不在任何 LRU 上,PG_lru=0
... 处理 page(可能耗时,不持锁)...
putback_lru_page(page)
→ 判断 page_evictable()
→ 若可回收:lru_cache_add() // 经 pagevec 放回合适的 LRU
→ 若不可回收:add_page_to_unevictable_list()
这个并发安全的标准操作的意义:
- isolate 后,page 不在 LRU 上,其他路径(如另一个 CPU 的回收扫描)不会并发操作它
- 处理期间不持 lru_lock,避免长时间持锁
- putback 时重新判断 evictability,处理 isolate 期间状态变化的情况(如 mlock)
isolate_lru_page()(单页,需要调用者持有引用)和 isolate_lru_pages()(批量,回收路径内部使用)是两个不同的函数,前者用于外部调用(如 compaction、migration),后者用于 shrink_*() 函数内部。
7.6 扫描压力分配:get_scan_count() 与 anon/file 比例
mm/vmscan.c 的 get_scan_count() 决定每次回收扫描各条 LRU 链表多少页,输出 nr[4](anon inactive、anon active、file inactive、file active 各自的扫描数)。
决策优先级(从高到低):
1. SCAN_FILE:无 swap 空间,或 memcg 禁用 swap
→ 只扫描文件页
2. SCAN_EQUAL:接近 OOM(priority=0)且有 swappiness
→ anon 和 file 等比例扫描
3. SCAN_ANON:文件页 + 空闲页已低于 high watermark,且 inactive anon 充足
→ 只扫描匿名页(防止文件 LRU 过小导致抖动)
4. SCAN_FILE:文件 inactive 链表足够大(> active)
→ 优先扫描文件页
5. SCAN_FRACT:默认情况
→ 按 swappiness 和最近回收效率的加权比例分配
SCAN_FRACT 的计算(代码经过简化):
static void get_scan_count(...)
{
...
anon_prio = swappiness; // 默认 60
file_prio = 200 - swappiness; // 默认 140
// 根据最近扫描/晋升比例调整(扫描多但晋升少 → 该类型页更"冷",扫描压力更大)
ap = anon_prio * (recent_scanned[anon] + 1) / (recent_rotated[anon] + 1);
fp = file_prio * (recent_scanned[file] + 1) / (recent_rotated[file] + 1);
// 各链表扫描数 = 基础扫描数 × 对应比例
nr[lru] = scan * fraction[file] / (ap + fp + 1);
...
}
swappiness(/proc/sys/vm/swappiness,默认 60)是用户可调的:
- 设为 0:完全不扫描匿名页(不使用 swap)
- 设为 100:anon 和 file 优先级相同
- 默认 60:偏向扫描文件页,减少 swap 使用
8. workingset 与 refault 机制
8.1 thrashing 问题的定义
当系统内存不足时,会出现一种恶性循环:页面被频繁访问,但每次访问之前它已经被驱逐出内存,导致不断地缺页、读磁盘、再驱逐,这种状态称为 thrashing(抖动)。
抖动的根本原因是:workload 的 working set(活跃使用的页面集合)大于可用内存。但有一种情况可以优化:working set 虽然大于 inactive 链表,却小于整个内存(inactive + active)。此时问题不是内存总量不够,而是 active 链表占用了本可用于 inactive 的空间,而 active 链表中可能存在大量已经不再活跃的页面。
+-memory available to cache-+
| |
+-inactive------+-active----+
a b | c d e f g h i | J K L M N |
+---------------+-----------+
如果能识别出哪些页面是真正的热页(即使被驱逐后很快又被访问),就可以让它们直接进入 active 链表,与现有 active 页面竞争,把真正冷的 active 页面挤出去。这就是 workingset refault 机制要解决的问题。
8.2 inactive_age 计数器:E 与 R 的含义
workingset 机制的核心是 lruvec->inactive_age,定义在 include/linux/mmzone.h:
struct lruvec {
...
atomic_long_t inactive_age; // 文件页 inactive 链表的驱逐+晋升事件计数
...
};
这是一个单调递增的原子计数器,每次文件页 inactive 链表发生以下两类事件时递增:
- 驱逐(eviction):页面被回收出内存,workingset_eviction() 中 atomic_long_inc_return()
- 晋升(activation):页面从 inactive 晋升到 active,workingset_activation() 中 atomic_long_inc()
两类事件都会将 inactive 链表上的其他页面向尾部推移,因此都计入同一个计数器 lruvec->inactive_age。
E 和 R 是这个计数器 lruvec->inactive_age 在两个不同时刻的读数:
- E(Eviction):页面被驱逐时的 inactive_age 快照,存入 shadow entry
- R(Refault):该页面再次缺页时读取的 inactive_age 当前值
R - E 即为 refault 距离,表示该页面不在缓存期间,inactive 链表上发生的事件总数,也就是这段时间内 inactive 链表被推进了多少的度量。
8.3 shadow entry:驱逐时的记录
(文件)页面被驱逐时,workingset_eviction() 在 page cache radix tree 原来的槽位留下一个 shadow entry:
shrink_page_list()
__remove_mapping()
workingset_eviction()
// mm/workingset.c
void *workingset_eviction(struct address_space *mapping, struct page *page)
{
struct mem_cgroup *memcg = page_memcg(page);
struct pglist_data *pgdat = page_pgdat(page);
int memcgid = mem_cgroup_id(memcg);
unsigned long eviction;
struct lruvec *lruvec;
...
lruvec = mem_cgroup_lruvec(pgdat, memcg);
eviction = atomic_long_inc_return(&lruvec->inactive_age); // 先递增,取值为 E
return pack_shadow(memcgid, pgdat, eviction); // 打包成 shadow entry
}
pack_shadow() 将 memcgid、node id、E 值压缩编码进一个指针大小的值,存入 radix tree 的 exceptional entry 槽位。这个槽位原本存放 page 指针,page 被驱逐后 page 指针消失,shadow entry 占据其位置:
static void *pack_shadow(int memcgid, pg_data_t *pgdat, unsigned long eviction)
{
eviction >>= bucket_order;
eviction = (eviction << MEM_CGROUP_ID_SHIFT) | memcgid;
eviction = (eviction << NODES_SHIFT) | pgdat->node_id;
eviction = (eviction << RADIX_TREE_EXCEPTIONAL_SHIFT);
return (void *)(eviction | RADIX_TREE_EXCEPTIONAL_ENTRY);
}
shrink_page_list()
__remove_mapping()
shadow = workingset_eviction(mapping, page);
__delete_from_page_cache(page, shadow);
page_cache_tree_delete(mapping, page, shadow);
__radix_tree_replace(&mapping->page_tree, ..., shadow, workingset_update_node, mapping);
shadow entry 的生命周期:随 inode 存在而存在,inode 被回收时一起清除。workingset.c 中有专门的 shrinker(workingset_shadow_shrinker)在内存压力下回收过多的 shadow node。
8.4 refault distance 的计算与激活判断
页面再次缺页时,add_to_page_cache_lru() 检查是否存在 shadow entry,若有则调用 workingset_refault():
int add_to_page_cache_lru(...)
{
void *shadow = NULL;
__SetPageLocked(page);
ret = __add_to_page_cache_locked(page, mapping, offset, gfp_mask, &shadow);
if (unlikely(ret))
...
else {
if (!(gfp_mask & __GFP_WRITE) &&
shadow && workingset_refault(shadow)) { // refault distance 的计算与激活判断
...
} else
...
lru_cache_add(page); /* 加入 文件页 LRU */
}
...
}
// mm/workingset.c
bool workingset_refault(void *shadow)
{
...
unpack_shadow(shadow, &memcgid, &pgdat, &eviction); // 解出 E
...
lruvec = mem_cgroup_lruvec(pgdat, memcg);
refault = atomic_long_read(&lruvec->inactive_age); // 读取当前值为 R
active_file = lruvec_lru_size(lruvec, LRU_ACTIVE_FILE, MAX_NR_ZONES);
refault_distance = (refault - eviction) & EVICTION_MASK; // R - E
if (refault_distance <= active_file) // 判断条件
return true; // 应该激活
return false;
}
- 判断条件的推导
页面的完整最小访问距离 = 在缓存内的距离 + 在缓存外的距离:
NR_inactive + (R - E)
如果这个距离 ≤ 整个缓存大小(NR_inactive + NR_active),说明该页面本可以在两次访问之间持续驻留:
NR_inactive + (R - E) ≤ NR_inactive + NR_active
化简得:
(R - E) ≤ NR_active
即:refault 距离不超过 active 链表大小时,激活该页面。
8.5 乐观激活策略
workingset_refault() 返回 true 时,add_to_page_cache_lru() 直接页面放入 active 链表:
// mm/filemap.c
int add_to_page_cache_lru(...)
{
...
if (!(gfp_mask & __GFP_WRITE) &&
shadow && workingset_refault(shadow)) {
SetPageActive(page); // 直接进 active
workingset_activation(page); // inactive_age++
} else {
ClearPageActive(page); // 正常进 inactive
}
lru_cache_add(page);
...
}
这是一种乐观激活:内核假设 active 链表中有 (R - E) 个页面的访问频率低于这个 refault 页面,将 refault 页面直接激活,让它与现有 active 页面竞争:
-
如果判断正确:那些真正不活跃的 active 页面在降级后被驱逐,refault 页面得以留在缓存中,thrashing 得到缓解。
-
如果判断错误:被挤出的 active 页面其实仍然活跃,它们会在降级到 inactive 后很快再次被访问,触发晋升重新回到 active。系统会自我纠正,代价只是多了几次晋升操作。
workingset 机制与 inactive_list_is_low() 的联动:workingset_activation() 在激活时递增 lruvec->inactive_age,lruvec->refaults 记录上次回收周期的激活数。inactive_list_is_low() 检测到 refault 活动时,会将 inactive_ratio 强制设为 0,持续压缩 active 链表,加速淘汰旧 working set:
// mm/vmscan.c
static bool inactive_list_is_low(...)
{
...
if (file && actual_reclaim && lruvec->refaults != refaults) { // 检测到 refault 活动
inactive_ratio = 0; // 强制返回 true,持续降级 active 页面
}
...
}
static unsigned long shrink_list(...)
{
if (is_active_lru(lru)) {
if (inactive_list_is_low(lruvec, is_file_lru(lru), memcg, sc, true))
shrink_active_list(nr_to_scan, lruvec, sc, lru); /* inactive 不足时才压缩 active */
return 0;
}
// return shrink_inactive_list(nr_to_scan, lruvec, sc, lru);
}
static void shrink_node_memcg(...)
{
...
if (inactive_list_is_low(lruvec, false, memcg, sc, true))
shrink_active_list(SWAP_CLUSTER_MAX, lruvec, sc, LRU_ACTIVE_ANON);
}
这形成了一个完整的反馈回路:refault 被检测到 → 乐观激活 → 触发 active 链表压缩 → 旧 working set 被淘汰 → 新 working set 建立。
9. file page cache 的 I/O 路径(与 LRU 的关联)
9.1 readahead:预读与 LRU 的关系
文件预读(readahead)是内核在用户进程实际访问之前,提前将磁盘数据读入 page cache 的机制,目的是将 major fault(需要等待磁盘 I/O)转化为 minor fault(page cache 命中)。
文件预读(readahead)流程:
do_sync_mmap_readahead() / __do_page_cache_readahead()
→ 从 buddy 分配 page,加入临时 page_pool 链表
→ read_pages()
→ ext4_readpages() // 文件系统层
→ ext4_mpage_readpages()
→ submit_bio() // 提交异步 I/O
→ 函数返回(不等待 I/O 完成)
预读分配的 page 在 read_pages() 内部通过 add_to_page_cache_lru() 加入 page cache radix tree,同时进入 inactive LRU 链表。预读页默认不调用 mark_page_accessed(),因为此时还不确定用户进程是否真的会访问这些页面 —— 只有当用户进程实际触发 fault 访问时,才会在 filemap_fault() 中调用 mark_page_accessed(),完成第一次访问标记。
预读页的特殊标记:
// __do_page_cache_readahead()
if (page_idx == nr_to_read - lookahead_size)
SetPageReadahead(page); // 标记为预读触发点
访问到带有 PG_readahead 标记的页面时,内核会触发下一轮预读,形成流水线效果。
9.2 PTE 映射的建立时机:filemap_fault() 与 alloc_set_pte()
预读只负责把数据读入 page cache,不建立任何 PTE 映射。PTE 映射的建立发生在用户进程访问 mmap 地址触发 page fault 时:
用户进程访问 mmap 地址
→ Data Abort / Page Fault
→ do_page_fault()
→ handle_mm_fault()
→ handle_pte_fault()
→ do_fault()
→ do_read_fault()
→ __do_fault()
→ vma->vm_ops->fault() = filemap_fault()
→ find_get_page() // 在 radix tree 找到预读好的 page
→ vmf->page = page // 返回 page,不调用 mark_page_accessed()
→ finish_fault()
→ alloc_set_pte() // 建立 PTE 映射
// mk_pte() 构造时已含 L_PTE_YOUNG
filemap_fault() 的核心逻辑:
int filemap_fault(struct vm_fault *vmf)
{
page = find_get_page(mapping, offset); // 查找 page cache
if (likely(page)) {
// page cache 命中(预读成功)
do_async_mmap_readahead(...); // 触发下一轮异步预读
} else {
// page cache 未命中(预读未覆盖或未预读)
do_sync_mmap_readahead(...); // 同步预读,major fault
}
// 等待 I/O 完成(如果数据还没读好)
lock_page_or_retry(page, ...);
if (!PageUptodate(page))
goto page_not_uptodate; // I/O 出错,重试
vmf->page = page;
return VM_FAULT_LOCKED; // 返回 page,由 finish_fault() 建立 PTE
}
三种访问层面的对比:
| 访问方式 | 是否经过 MMU | 页表类型 | 是否需要动态建立 |
|---|---|---|---|
| DMA 写 page(I/O 完成) | 否,物理地址直达 | 无 | 无 |
| 内核访问 page cache | 是,内核直接映射 | 内核页表(boot 时建好) | 不需要 |
| 用户进程访问 mmap | 是,进程用户态页表 | 进程页表 | 需要,fault 时按需建立 |
9.3 读写路径的对称性
文件页的读和写走的是完全对称的块层路径,区别只在触发时机和数据方向:
读路径:
page fault / read() 触发
→ submit_bio(READ)
→ bio → request → I/O 调度器 → 驱动 → DMA(磁盘 → page 物理内存)
写路径:
dirty page 积累后由 flusher 延迟触发
→ submit_bio(WRITE)
→ bio → request → I/O 调度器 → 驱动 → DMA(page 物理内存 → 磁盘)
两者都经过完整的块层路径(bio → request → 调度器 → 驱动),DMA 只是最后一步的数据搬运手段。
9.4 writeback 线程:flusher 与 kswapd 的分工
flusher(flush-<dev>)
基于 workqueue 机制,每个 backing_dev_info(块设备)对应一个 delayed work wb->dwork,工作函数为 fs-writeback.c 中的 wb_workfn():
//
// mm/backing-dev.c — wb_init()
INIT_DELAYED_WORK(&wb->dwork, wb_workfn);
// 全局 writeback workqueue
bdi_wq = alloc_workqueue("writeback", WQ_MEM_RECLAIM | WQ_FREEZABLE | ...);
worker 线程名为 flush-8:0 等(设备号命名)。触发场景:
| 触发者 | 场景 |
|---|---|
| balance_dirty_pages() | 进程写文件使脏页超过阈值,主动唤醒 |
| wb_wakeup_delayed() | 定期唤醒(dirty_writeback_interval,默认 5 秒) |
| wakeup_flusher_threads() | 内存压力、sync 系统调用 |
flusher 的写回路径:
wb_workfn()
→ wb_do_writeback()
→ writeback_inodes_wb()
→ writeback_sb_inodes()
→ __writepage() / ext4_writepages()
→ submit_bio() → 块层 → 设备
- kswapd 的附带 writeback
kswapd 主职是页面回收,但在 shrink_page_list() 中遇到脏文件页时,会调用 pageout() → mapping->a_ops->writepage() 触发单页写回。这是 kswapd 的附带行为,不是主要 writeback 路径。
分工原则:
- flusher 负责主动的、批量的 writeback,是正常路径
- kswapd 的 writeback 是被动的、单页的,只在回收压力下触发
- direct reclaim 路径不触发文件页 writeback(避免栈溢出),遇到脏页只标记 PG_reclaim 后放回
9.5 块层路径:bio → request → 调度器 → 驱动
- bio 到 request 的转换
submit_bio() 将 bio 转换为 request,或合并到已有 request:
submit_bio(bio)
→ generic_make_request()
→ blk_queue_bio()
→ 尝试合并到已有 request(地址连续则合并)
→ 合并失败:bio 转为新 request
→ 有 plug:加入 plug->list 缓冲
→ 无 plug:直接插入调度器队列 + 触发 __blk_run_queue()
- plug 机制
blk_start_plug() / blk_finish_plug() 提供批量提交能力:
blk_start_plug(&plug); // 开始积攒
ext4_readpages() → submit_bio() // 多个 bio 积攒到 plug->list
blk_finish_plug(&plug); // 批量排序后插入调度器,触发调度
blk_finish_plug() 内部对 request 按设备队列 + 磁盘扇区排序,有利于顺序 I/O 合并,然后调用 queue_unplugged() → __blk_run_queue() 触发驱动取 request。
__blk_run_queue() 的调用场景:
| 场景 | 触发时机 |
|---|---|
| 新 request 无 plug 入队 | submit_bio() 路径 |
| plug 刷入 | blk_finish_plug() |
| 队列重启 | I/O 完成后 blk_start_queue(),或延迟重试 blk_delay_work() |
| 电源管理恢复 | blk_post_runtime_resume() |
- I/O 完成回调
DMA 完成后,设备驱动触发中断,bio 完成回调执行:
bio 完成回调
→ end_bio_bh_io_sync() / mpage_end_io()
→ SetPageUptodate(page) // 标记数据已就绪
→ unlock_page(page) // 唤醒等待该页的进程
此后 filemap_fault() 中等待的进程被唤醒,PageUptodate() 检查通过,alloc_set_pte() 建立 PTE,fault 处理完成。
9.6 与 LRU 的关联总结
file page cache 的 I/O 路径与 LRU 管理的交汇点:
磁盘数据读入
→ add_to_page_cache_lru()
→ 加入 page cache radix tree
→ 检查 shadow entry(workingset refault)
→ 进入 inactive LRU(或直接 active)
用户进程访问
→ filemap_fault() → mark_page_accessed()
→ 第一次:SetPageReferenced
→ 第二次:activate_page() → 晋升到 active
内存压力回收
→ shrink_inactive_list() → page_check_references()
→ 无引用:回收,workingset_eviction() 留 shadow entry
→ 有引用:晋升或保留
脏页写回
→ flusher writeback → 页面变干净
→ rotate_reclaimable_page() → 移到 inactive 尾部,加速回收
10. memcg 对 LRU 的影响
10.1 per-memcg lruvec
- 数据结构
没有 memcg 时,整个 NUMA 节点只有一个 lruvec,挂在 pglist_data 上。开启 CONFIG_MEMCG 后,每个 memcg 在每个 NUMA 节点上都有独立的 lruvec,通过 mem_cgroup_per_node 结构承载:
// include/linux/memcontrol.h
struct mem_cgroup_per_node {
struct lruvec lruvec; // NUMA per-memcg 独立的 LRU 向量
unsigned long lru_zone_size[MAX_NR_ZONES][NR_LRU_LISTS]; // 各链表大小统计
struct mem_cgroup_reclaim_iter iter[DEF_PRIORITY + 1]; // 回收迭代器
struct mem_cgroup *memcg; // 关联的 memcg
...
};
struct mem_cgroup {
...
struct mem_cgroup_per_node *nodeinfo[0]; // per-node 信息数组
};
整体组织结构:
系统
└── NUMA node(pglist_data)
├── lruvec ← root memcg 或禁用 memcg 时使用
└── (各 memcg 的 per-node 信息)
├── memcg_A → nodeinfo[node_id] → lruvec_A
├── memcg_B → nodeinfo[node_id] → lruvec_B
└── memcg_C → nodeinfo[node_id] → lruvec_C
- lruvec 的查找
每次 LRU 操作都需要找到 page 所属的 lruvec,通过mem_cgroup_page_lruvec()实现:
struct lruvec *mem_cgroup_page_lruvec(struct page *page, struct pglist_data *pgdat)
{
if (mem_cgroup_disabled()) {
return &pgdat->lruvec; // 未开启 memcg:直接用节点 lruvec
}
memcg = page->mem_cgroup; // page 所属的 memcg(charge 时绑定)
if (!memcg)
memcg = root_mem_cgroup;
mz = mem_cgroup_page_nodeinfo(memcg, page); // 找到 per-node 信息
return &mz->lruvec;
}
page->mem_cgroup 在页面被 charge 时绑定,在页面被 uncharge 时清除。这意味着每个 page 在其生命周期内归属于固定的 memcg,其 LRU 操作始终在该 memcg 的 lruvec 上进行。
- page 的 charge 时机
page 进入 LRU 之前必须先完成 charge,以 do_anonymous_page() 为例:
// mm/memory.c
page = alloc_zeroed_user_highpage_movable(vma, vmf->address);
// 1. charge:将 page 计入 memcg 的内存使用量
mem_cgroup_try_charge(page, vma->vm_mm, GFP_KERNEL, &memcg, false);
// 2. 建立 rmap
page_add_new_anon_rmap(page, vma, vmf->address, false);
// 3. commit charge:正式绑定 page->mem_cgroup
mem_cgroup_commit_charge(page, memcg, false, false);
// 4. 加入 LRU(此时 page->mem_cgroup 已设置)
lru_cache_add_active_or_unevictable(page, vma);
charge 分为 try 和 commit 两步,中间如果发生错误可以 cancel,避免 page 在未完成 charge 的情况下进入 LRU。
- memcg 限制触发的回收
当 memcg 的内存使用量超过 limit 时,try_charge() 会触发针对该 memcg 的回收:
static int try_charge(struct mem_cgroup *memcg, gfp_t gfp_mask, unsigned int nr_pages)
{
// 尝试 charge,失败说明超过 limit
if (!page_counter_try_charge(&memcg->memory, batch, &counter)) {
mem_over_limit = mem_cgroup_from_counter(counter, memory);
// 触发针对超限 memcg 的回收
nr_reclaimed = try_to_free_mem_cgroup_pages(mem_over_limit,
nr_pages, gfp_mask, may_swap);
goto retry;
}
}
try_to_free_mem_cgroup_pages() 最终调用 shrink_node(),但 sc->target_mem_cgroup 被设置为超限的 memcg,回收扫描只针对该 memcg 的 lruvec,不影响其他 memcg。
- 回收路径中的 memcg 遍历
全局回收(kswapd / direct reclaim)需要遍历所有 memcg 的 lruvec,在 shrink_node() 中通过 mem_cgroup_iter() 实现:
// mm/vmscan.c — shrink_node()
memcg = mem_cgroup_iter(root, NULL, &reclaim);
do {
shrink_node_memcg(pgdat, memcg, sc, &lru_pages);
// 回收 slab cache
shrink_slab(sc->gfp_mask, pgdat->node_id, memcg, ...);
// 记录 vmpressure
vmpressure(sc->gfp_mask, memcg, false, ...);
} while ((memcg = mem_cgroup_iter(root, memcg, &reclaim)) != NULL);
mem_cgroup_iter() 按 cgroup 层级树的顺序遍历,每个 memcg 都有独立的 reclaim_iter 记录上次扫描位置,避免每次都从头开始扫描。
shrink_node_memcg() 对单个 memcg 执行 get_scan_count() + shrink_list(),逻辑与无 memcg 时完全相同,只是操作的 lruvec 是该 memcg 的。
10.2 cgroup writeback(cgwb)
-
问题背景
在没有 cgwb 之前,所有 memcg 的脏页都由同一个 flusher(bdi->wb)写回。这带来一个问题:memcg_A 产生大量脏页,会占用 flusher 的带宽,影响 memcg_B 的写回,导致 memcg_B 的脏页积压,进而触发 memcg_B 的内存压力。memcg 的隔离性被破坏。 -
cgwb 的设计
CONFIG_CGROUP_WRITEBACK 为每个 (memcg, blkcg) 组合创建独立的 writeback worker(bdi_writeback),称为 cgwb:
// mm/backing-dev.c — cgwb_create()
static int cgwb_create(struct backing_dev_info *bdi,
struct cgroup_subsys_state *memcg_css, gfp_t gfp)
{
wb = kmalloc(sizeof(*wb), gfp);
ret = wb_init(wb, bdi, blkcg_css->id, gfp); // 初始化独立的 wb,含独立的 dwork
// 注册到 bdi->cgwb_tree(以 memcg_css->id 为 key 的 radix tree)
// 同时挂入 memcg->cgwb_list 和 blkcg->cgwb_list
}
cgwb 的组织结构:
struct backing_dev_info(块设备)
├── wb(root writeback worker,对应 root memcg)
└── cgwb_tree(radix tree,key = memcg_css->id)
├── cgwb_A(memcg_A 的 writeback worker)
├── cgwb_B(memcg_B 的 writeback worker)
└── cgwb_C(memcg_C 的 writeback worker)
每个 cgwb 有独立的 dwork(delayed work),独立的脏页链表(b_dirty、b_io、b_more_io),独立的带宽统计。
- cgwb 的触发与 inode 归属
inode 的 writeback 由其归属的 cgwb 负责。inode 在首次被写脏时,根据写入进程所属的 memcg 和 blkcg 确定归属的 cgwb,后续该 inode 的所有写回都由这个 cgwb 处理。
当进程的 memcg 发生变化(如进程被移入另一个 cgroup),inode 的 cgwb 归属也会异步迁移(inode_switch_wbs()),保证 writeback 带宽计入正确的 memcg。
- cgwb 与 LRU 的关联
cgwb 的隔离使得 memcg 的脏页写回与 LRU 回收形成完整的隔离闭环:
memcg_A 的脏页
→ 由 cgwb_A 写回(不占用其他 memcg 的写回带宽)
→ 写回完成后 rotate_reclaimable_page() 移到 inactive 尾部
→ shrink_node_memcg() 只扫描 memcg_A 的 lruvec 进行回收
没有 cgwb 时,memcg_A 的脏页写回慢会导致其 inactive 链表中脏页积压,回收效率下降,进而影响 memcg_A 的内存限制执行。有了 cgwb,写回和回收都在 memcg 粒度上独立运作,隔离性得到保证。
11. 总结:LRU 管理的整体视图
11.1 完整状态机回顾
page 在 LRU 体系中的完整生命周期:
┌─────────────────────────────────────────────────────┐
│ buddy allocator │
└──────────────────────────┬──────────────────────────┘
│ 分配
┌────────────────────┼────────────────────┐
│ 文件页 │ │ 匿名页
▼ │ ▼
add_to_page_cache_lru() │ do_anonymous_page()
lru_cache_add_file() │ lru_cache_add_active_or_unevictable()
│ │ │
│ workingset refault?│ │
┌─────┴──────┐ │ │
│ 是 │ 否 │ │
▼ ▼ │ ▼
[active FILE] [inactive FILE] │ [active ANON]
│ │ │ │
│ │ mark_page_accessed() ×2 │
│ ├─────────────────────────────────►│
│ │ │
│◄───────────┘ 晋升(activate_page) │
│ │
│◄───────────────────────────────────────────────┘
│
[active LRU]
│
│ shrink_active_list()
│ page_referenced() == false
│ 或非 VM_EXEC 文件页
▼
[inactive LRU]
│
│ shrink_inactive_list()
│ page_check_references()
├──── PAGEREF_ACTIVATE ──────────────────► [active LRU]
├──── PAGEREF_KEEP ───────────────────────► 留在 inactive
│
│ PAGEREF_RECLAIM / PAGEREF_RECLAIM_CLEAN
▼
workingset_eviction()
shadow entry → radix tree
│
▼
[释放回 buddy]
│
│ 再次缺页(refault)
│ workingset_refault()
│ refault_distance <= NR_active?
├──── 是 ──────────────────────────────► [active LRU](跳过 inactive)
└──── 否 ──────────────────────────────► [inactive LRU](正常路径)
page flags 状态对应:
| LRU 位置 | PG_lru | PG_active | PG_referenced |
|---|---|---|---|
| 不在 LRU | 0 | - | - |
| inactive,未访问 | 1 | 0 | 0 |
| inactive,访问一次 | 1 | 0 | 1 |
| active | 1 | 1 | 0/1 |
| unevictable | 1 | 0 | - |
| isolate 中 | 0 | 0/1 | - |
11.2 两条驱动路径的协作关系
LRU 管理由两条方向相反的路径共同驱动,形成动态平衡:
用户态访问(推动页面"升温")
─────────────────────────────────────────►
page fault / read()
→ lru_cache_add_*() 新页进入 inactive 头部
→ mark_page_accessed() inactive → active 晋升
→ workingset_refault() refault 页直接进 active
◄─────────────────────────────────────────
内核回收(推动页面"降温")
内存压力
→ shrink_active_list() active → inactive 降级
→ shrink_inactive_list() inactive → 回收或保留
→ workingset_eviction() 驱逐时记录 shadow entry
两条路径的触发完全独立:
- 访问路径由用户态行为驱动,随时可能发生
- 回收路径由内存水位触发,只在内存压力下激活
两者通过 LRU 链表这个共享数据结构交汇,lru_lock 保证并发安全。pagevec 作为访问路径的缓冲层,将锁竞争从"每次操作"降低到"每 14 次操作"。
11.3 各组件的职责边界
| 组件 | 职责 | 核心函数 |
|---|---|---|
| swap.c | LRU 链表操作的入口层,pagevec 管理 | lru_cache_add_*(), activate_page(), mark_page_accessed() |
| vmscan.c | 回收决策与执行 | shrink_active_list(), shrink_inactive_list(), page_check_references(), get_scan_count() |
| workingset.c | working set 检测,refault 激活 | workingset_eviction(), workingset_refault(), workingset_activation() |
| rmap.c | PTE accessed bit 采样 | page_referenced() |
| filemap.c | 文件页进入 LRU 的入口 | add_to_page_cache_lru(), filemap_fault() |
| memcontrol.c | per-memcg LRU 隔离,limit 回收 | mem_cgroup_page_lruvec(), try_charge() |
| backing-dev.c | per-memcg writeback 隔离 | cgwb_create(), wb_workfn() |
11.4 设计权衡与局限
权衡一:精度 vs 性能
严格 LRU 要求每次访问都更新全局有序链表,代价极高。Linux 的近似实现用以下手段换取性能:
- per-cpu pagevec 批量操作,减少锁竞争
- PTE accessed bit 采样(而非精确计数),感知"有没有访问"而非"访问了多少次"
- 两次访问晋升门槛,过滤一次性流式 I/O
代价是 LRU 顺序不精确,可能出现"应该被回收的页面暂时留在内存"或"应该保留的页面被提前回收"的情况。
权衡二:active 链表的保护强度
active 链表提供保护,但保护不是无限的:
- inactive_ratio 随内存增大而增大,大内存系统的 active 链表占比更高
- workingset refault 检测到 thrashing 时,强制压缩 active 链表,牺牲保护换取新 working set 的建立速度
权衡三:匿名页 vs 文件页的回收策略
文件页回收代价低(干净页直接丢弃,磁盘上有备份),匿名页回收代价高(需要 swap out)。get_scan_count() 通过 swappiness 和最近回收效率的加权,在两者之间动态平衡,但这个平衡依赖历史统计,对突发 workload 变化的响应有延迟。
局限一:workingset 机制仅覆盖文件页
shadow entry 依赖 page cache radix tree 的槽位,匿名页没有对应的 radix tree,因此 workingset refault 机制在笔者分析的 Linux 4.14 代码中不适用于匿名页。匿名页的 thrashing 无法被检测和优化。
局限二:PTE accessed bit 的采样粒度
两次 page_referenced() 调用之间的多次访问被合并为"有访问"这一个结论,无法区分访问频率的高低。高频访问和低频访问的页面在 LRU 中的待遇相同,只要在扫描窗口内被访问过一次就能晋升。
局限三:NUMA 感知不足
lruvec 是 per-node 的,但回收时的扫描压力分配(get_scan_count())不感知 NUMA 拓扑,可能在远端 NUMA 节点上回收页面,而本地节点仍有冷页可回收。
11.5 总结
Linux LRU 管理的本质是:用访问历史近似估算页面热度,通过双时钟链表在"保护热页"和"回收冷页"之间动态平衡,在可接受的精度损失下实现可扩展的内存管理。

浙公网安备 33010602011771号