
在Linux内核的内存管理子系统(MM)中,页面回收(Page Reclaim)机制直接决定了系统在高内存负载下的吞吐底线。如果操作系统简单地采用教科书式的单链表LRU(Least Recently Used,最近最少使用)算法,系统将面临灾难性的“缓存污染”(Cache Pollution):一次偶发的全盘扫描(如grep -r或tar -czf流式打包备份),就会瞬间将整机DRAM塞满一次性文件页,并将数据库或核心服务长期常驻的热点物理页全部踢出,引发系统级剧烈颠簸(Thrashing)与致命抖动。
为了彻底解决缓存污染并实现纳秒级冷热分离,Linux内核在每个内存节点(pg_data_t)与内存控制组(memcg)的lruvec中,构建了一套精妙的active_list(活跃链表)与inactive_list(非活跃链表)双链表LRU置换架构。
为什么朴素LRU无法胜任现代通用内核
朴素LRU的基本假设是:越是最近被访问的页面,在未来被访问的概率越高。然而在真实的复杂生产负荷下,访存模式存在本质分化:
- 周期性热点:进程的代码段、堆栈、B-Tree索引页,每秒被反复读取数万次。
- 一次性流式访问:大文件拷贝、日志流水写入、视频流推拉,数据只在几毫秒内经过CPU一次,随后永远沉睡。
在单链表LRU中,流式数据会盲目占据链表头部,将周期性热点强制向后挤出并最终逐出物理内存。为了实现对“瞬时突发访问”与“持续高频访问”的精准区分,Linux引入了两次访问原则(Two-Touch Strategy):新分配或缺页载入的页面默认只能进入冷区(inactive链表),只有当其在冷区驻留期间被再次访问时,才有资格晋升为热区(active链表)。
双链表拓扑与页面标志位状态机
在Linux内核源码中(以mm/swap.c与mm/vmscan.c为核心),物理页(struct page或现代内核的struct folio)被细分为五种LRU链表类型:
冷热状态的流转完全依托于页描述符中的两个标志位:
- PG_active:该页当前处于active链表还是inactive链表。
- PG_referenced:该页近期是否被引用(软件逻辑访问或硬件MMU置位)。
在实际运转中,物理页面在冷热双链表之间形成了清晰的晋升与降级闭环:
- 冷区准入与末位淘汰(Inactive 链表):新分配或缺页载入的页面默认插入 Inactive 链表头部。若后续没有二次访问,页面会随推进滑至链表尾部,在内存紧张时由 shrink_inactive_list() 直接回收、丢弃或换出。
- 二次命中晋升(Inactive → Active):当驻留冷区的页面迎来二次访问时,触发 mark_page_accessed(),内核将其标志位翻转并移入 Active 链表头部,免遭瞬时淘汰。
- 收缩回退降级(Active → Inactive):当内存水位吃紧触发回收时,内核通过 shrink_active_list() 扫描 Active 尾部较久未访问的页面,清除激活状态并降级打回 Inactive 链表重新考察。
内核源码核心函数行为剖析
为了彻底看清上述双向流转在底层的运转细节,我重点跟踪了内核源码中负责驱动状态流转的几个核心函数:
1. 页面访问判定:mark_page_accessed()
当用户态通过系统调用访问文件页,或者内核建立内存映射时,会触发mark_page_accessed()。其精简核心逻辑如下(源自内核mm/swap.c):
// 内核源码逻辑概念化提炼
void mark_page_accessed(struct page *page) {
page = compound_head(page);
// 状态一:页面处于非活跃状态且未被引用
if (!PageActive(page) && !PageReferenced(page)) {
// 第一次访问:仅置位referenced标志,依然留在inactive链表
SetPageReferenced(page);
}
// 状态二:页面在inactive链表,且referenced已置位(第二次被访问!)
else if (!PageActive(page) && PageReferenced(page)) {
// 两次访问原则满足:清除referenced,正式激活晋升至active链表
ClearPageReferenced(page);
activate_page(page); // 从inactive_list迁移到active_list头部
}
// 状态三:页面已经在active链表上
else if (PageActive(page)) {
// 热区页面再次命中:仅刷新referenced,保持其热度
SetPageReferenced(page);
}
}
这一精简状态机彻底封死了流式数据污染热区的可能。一次性读取的页面虽然进入inactive链表并打上referenced,但由于再也不会发生第二次访问,随着后续内存申请推进,它将顺理成章地滑落到inactive尾部并被内核光速抹除。
2. 热区向冷区的回降:shrink_active_list()
active链表不能无限膨胀。内核通过维持active与inactive页面的动态比例(通常为1:1至3:1),在内存吃紧触发kswapd或直接回收(Direct Reclaim)时,调用shrink_active_list()将过热的active尾部页面扫描降级至inactive链表:
static void shrink_active_list(unsigned long nr_to_scan,
struct lruvec *lruvec,
struct scan_control *sc,
enum lru_list lru) {
LIST_HEAD(l_hold);
LIST_HEAD(l_active);
LIST_HEAD(l_inactive);
// 1. 从active链表尾部批量隔离 nr_to_scan 个页面
isolate_lru_pages(nr_to_scan, lruvec, &l_hold, …);
while (!list_empty(&l_hold)) {
struct page *page = lru_to_page(&l_hold);
list_del(&page->lru);
// 检查硬件MMU页表项中的 _PAGE_ACCESSED 访问位
int referenced = page_referenced(page, 0, sc->target_mem_cgroup, …);
// 若页面在热区内依然有硬件级别的持续访问,且非匿名页,允许其继续留在active
if (referenced && !page_is_file_lru(page)) {
SetPageReferenced(page);
list_add(&page->lru, &l_active);
continue;
}
// 否则:清除PG_active标志,将其降级并推入inactive链表!
ClearPageActive(page);
ClearPageReferenced(page);
list_add(&page->lru, &l_inactive);
}
// 将处理后的页面分别重新挂回全局active与inactive链表
move_pages_to_lru(lruvec, &l_active);
move_pages_to_lru(lruvec, &l_inactive);
}
3. 冷区最终抹杀:shrink_inactive_list()
回收器执行shrink_inactive_list()时,仅针对inactive尾部的页面进行最终审判:
- 文件未脏页(Clean File Page):无需I/O回写,直接解除页表映射并归还物理伙伴系统(Buddy System)。
- 文件脏页(Dirty File Page):提交给后台刷盘线程,待写回磁盘完成后回收。
- 匿名页(Anonymous Page):若系统开启了Swap分区,将其压缩或写入Swap,清空物理页表项并填入换出条目(Swap Entry)。
双链表LRU的现代局限与MGLRU演进
双链表LRU历经数十年迭代,支撑了Linux从小型嵌入式设备到超算集群的平稳运行。但在现代TB级DRAM、数万并发容器的服务器上,传统双链表暴露出两项底层瓶颈:
这正是Linux 6.1+正式合入主线的**MGLRU(Multi-Gen LRU,多代LRU)**旨在颠覆的现状。MGLRU通过引入代际(Generation)序列号并借助硬件页表脏位(Accessed Bit)的增量位图轮询,彻底解除了传统双链表频繁移动节点的自旋锁负担。
理解active与inactive双链表的流转机制,是深入理解Linux内核虚存管理、精准定位生产环境内存抖动与调优Page Cache水位线(vm.vfs_cache_pressure、vm.min_free_kbytes)不可或缺的技术基石。
