Linux内核Memory Folios深度实战:从struct page到巨型页缓存的架构革命
Memory Folios 是 Linux 内核近年来最重要的内存管理重构之一,由 Matthew Wilcox(Oracle)自2019年起推动,以 v5.16(2022年1月)首次进入主线并持续演进。Folio 将"复合页/巨型页"与普通页统一为一种类型,消除了十多年来 page cache 与 compound page 之间的模糊边界,大幅简化了虚拟内存子系统的代码复杂度,并为未来的透明巨型页(THP)在 page cache 中的普及铺平了道路。本文将从设计哲学、数据结构、API迁移、文件系统适配、性能影响及生产实践等维度进行全景剖析。
一、问题的根源:struct page 的"身份危机"
1.1 struct page 的世界
在深入了解 folio 之前,必须先理解 Linux 内存管理最核心的数据结构——struct page。系统中的每一个物理页帧都对应一个 struct page,在 x86_64 架构上是 64 字节(含两个 union 加一个 unsigned long flags)。对于一台 128GB 内存的服务器,有 33,554,432 个页帧,page 结构体总内存占用约 2GB。
struct page 中最关键的字段是第一个 union 中的 mapping 和 index:
// include/linux/mm_types.h(简化)
struct page {
unsigned long flags; // PG_head, PG_compound, PG_swapbacked, ...
union {
struct address_space *mapping; // page cache映射
struct slab_cache *slab_cache; // slab分配器
// ...
};
pgoff_t index; // 在映射中的偏移
union {
void *virtual; // 虚拟地址(高端内存时有效)
unsigned long _pt_pad_1; // 页表缓存
};
atomic_t _refcount; // 引用计数
atomic_t _mapcount; // 映射计数(PTE entries数量)
// ... 更多字段
};
1.2 Compound Page 的双重身份问题
Linux 内核有一种称为 compound page(复合页)的机制,用于将多个连续的页帧组合成一个逻辑单元——例如 64KB 的巨型页由 16 个 4KB 的普通页组成。历史上,compound page 的关键语义如下:
- Head page(首部页):第一个页,其
PG_head被置位,所有对复合页属性(如引用计数、映射关系)的操作都通过 head page - Tail page(尾部页):其余页,其
compound_head字段指向 head page。私有数据如compound_mapcount、compound_pincount、compound_dtor、compound_order存储在 head page 的尾页区域中
这种设计在当时是合理的,但导致了两个严重问题:
问题一:page cache 中的 compound page 身份歧义
当 page cache 中缓存了一个 compound page(如 THP 在内存文件系统中的用法),内核无法仅通过 struct page* 指针判断它是一个"复合页的 head"还是一个"普通单页"。PageCompound() 宏虽然可以检测,但无数函数收到的 struct page* 可能是 tail page,它们必须先调用 compound_head() 获取 head page,再操作属性。这种模式散布在数千个函数中——不仅容易出错,还增加了代码复杂度和间接访问开销。
问题二:THP 在 page cache 中的有限支持
Linux 5.16 之前,匿名内存的 THP(Transparent Huge Pages)已实现,但 page cache 不支持 THP。换句话说,文件 IO(read/write/mmap 文件)永远只能以 base page(4KB)粒度操作,即使读取连续的大块文件数据,也会被拆成多个 4KB page cache 条目。这导致:
- TLB 压力更大(相同大小的内存需要更多 TLB 条目)
- page cache 的链表和基数树节点数更多
- 缺页异常和回写操作的次数更多
- 文件系统预读无法发挥巨型页的优势
2. Folio 的设计哲学
2.1 核心定义:一种"保证是 head page"的类型
Folio 本质上是 struct page 的一个类型包装——它确保你拿到的永远是 head page(或单页),永远不是 tail page。Folio 的定义极其简洁:
// include/linux/mm_types.h
struct folio {
unsigned long flags; // 与 struct page 相同布局
union {
struct address_space *mapping;
struct slab_cache *slab_cache;
};
pgoff_t index;
void *virtual;
atomic_t _refcount;
atomic_t _mapcount;
// ... 与 struct page 共享布局直到某个偏移量
};
关键洞察:struct folio 和 struct page 在内存布局上是前向兼容的。folio 只使用 page 结构体前半部分的"通用"字段,不涉及 page 后半部分的 union(那些是 tail page 专属的)。这意味着两者可以安全地在许多场景中互换。
2.2 核心不变量
Folio 引入了一条核心不变量:
struct folio* 永远指向一个 head page 或单页。绝不可能是 tail page。
基于此不变量,folio_test_head() 永远返回 true(单页本身就是自己的 head)。所有 folio API 都操作完整的逻辑页——无论是 4KB 的单页还是 2MB 的 compound folio。这种确定性使得:
- 函数签名自文档化:参数类型
struct folio*即表示"这是一个完整的、可直接操作的内存单元" - 编译器级类型安全:无法将 tail page 的
struct page*直接传递给期望 folio 的函数 - 消除运行时检查:不再需要在每个函数入口处调用
PageCompound()或compound_head()
三、数据结构深度解析
3.1 Folio 与 Page 的内存布局关系
// include/linux/mm.h
static inline struct folio *page_folio(struct page *page) {
// 将任何 page 转换为其所属的 folio(head)
// 对于 head page 或单 page,返回的就是 page 自身按 folio 类型解释
return (struct folio *)page;
}
static inline struct page *folio_page(struct folio *folio, unsigned int n) {
// 返回 folio 中的第 n 个物理 page(0 = head)
return &folio->page + n;
}
static inline unsigned int folio_order(struct folio *folio) {
// 返回 folio 的 order(2^order 个 page)
if (!folio_test_head(folio))
return 0;
return folio->_compound_order; // 通过私有数据获取
}
static inline size_t folio_size(struct folio *folio) {
// 返回 folio 的总字节数
return PAGE_SIZE << folio_order(folio);
}
3.2 引用计数语义
Folio 的引用计数语义与 page 一致——针对逻辑页(head)计数,而非每个 base page:
folio_get(folio) // 增加逻辑页引用计数(等价于 get_page(compound_head))
folio_put(folio) // 减少逻辑页引用计数(为0时释放整个 compound unit)
folio_ref_count(folio) // 查询引用计数
这里有一个微妙之处:_refcount 字段在 head page 上维护总的逻辑引用数,而不是每个 base page 都独立引用。compound_mapcount 和 compound_pincount 存储在 head page 的 tail page 区域内,这是 Linux 内核中经典的"first page 携带全局元数据,后续 page 存储扩展元数据"模式。
3.3 基数树(Xarray)中的 folio 存储
Linux 的 page cache 使用 Xarray(扩展基数树,Radix Tree 的继任者)存储页缓存条目。Xarray 支持 multi-order entry(多阶条目)——即一个 Xarray 槽位可以存储一个 2^order 大小的连续单元。Folio 完美利用了这一点:
// page cache 中存储 folio 的核心操作
void filemap_add_folio(struct address_space *mapping, struct folio *folio, pgoff_t index);
filemap_add_folio()
→ xa_store(&mapping->i_pages, index, folio, gfp)
// xa_store 检测 folio 的大小并设置 XA_MARK_ORDERED 标记
// 树节点知道这是一个大跨度条目,lookup 时自动返回对应的 folio 起始位置
struct folio *filemap_lock_folio(struct address_space *mapping, pgoff_t index);
filemap_lock_folio()
→ xa_find(&mapping->i_pages, &index, ∞, XA_PRESENT)
→ 锁定 folio 后返回(确保不是 sleep 持有状态下返回)
四、从 Page 到 Folio 的 API 迁移之路
4.1 迁移策略:渐进式而非大爆炸
Linux 社区对 folio 迁移采取了一种务实的分层策略——不要求一次性重写所有代码,而是通过"洋葱模型"逐步将核心逻辑迁移到 folio API:
- 核心层迁移:将 page cache 核心操作(
filemap.c、readahead.c、memory.c)迁到 folio API。影响最大,最先进行 - 文件系统适配:各文件系统对 page 操作内部逻辑。ext4、XFS、Btrfs、NFS、F2FS 等逐个适配
- 块层与驱动:bio 子系统和块设备驱动继续使用
struct page*,但高层传下来的 folio 可以将 base page 提取出来传给块层 - VFS 层:
struct address_space_operations 添加 folio 版本方法,旧方法通过 wrapper 兼容
4.2 文件系统钩子函数的重写示例
以 readpage 这一关键操作为例,展示迁移前后的变化:
// 迁移前(旧 API)— 每个 base page 单独操作
static int ext4_readpage(struct file *file, struct page *page)
{
// page 可能是 compound page 的某个 page(不一定是 head)
// 如果是 tail page,还需处理 compound_head
return ext4_readahead_block(file->f_mapping, page->index, page);
}
// 迁移后(folio API)— 按逻辑页操作
static int ext4_read_folio(struct file *file, struct folio *folio)
{
// folio 保证是 head,直接处理 16 pages(order-4,64KB)或 1 page
size_t len = folio_size(folio);
pgoff_t start = folio->index;
// 文件系统可以直接操作整个 folio 区域
return ext4_readahead_blocks(file->f_mapping, start, folio_order(folio));
}
4.3 仍在使用 page 的场景
并非所有场景都适合 folio。以下场景继续使用 struct page*:
- Slab 分配器:slab 返回的永远不会是 compound page,没有 head/tail 歧义
- 页表映射:PTE 永远是 base page 粒度
- DMA / IOMMU:IOMMU IOVA 映射以 base page 为单位
- 页帧号(PFN)操作:需要逐页处理物理页帧
内核通过 folio_page(folio, n) 提取 folio 中的第 n 个 page,供上述场景使用。
五、THP for Page Cache:Folio 解锁的新能力
5.1 问题场景
在 folio 出现之前,THP 只能用于匿名内存(MAP_ANONYMOUS mmap),三大限制导致其效果受限:
- 只能匿名映射:文件的 mmap 映射使用 base page
- 只能 madvise 触发:需显式标记 MADV_HUGEPAGE 才能尝试
- 碎片化导致分配失败:长期运行后,物理内存可能碎片化,无法找到连续 2MB
5.2 Folio 带来的文件支持 THP
Folio 完成了 THP in page cache 的基础设施,核心变化包括:
(1)filemap_alloc_folio():按 hint 分配适当大小的 folio
struct folio *filemap_alloc_folio(gfp_t gfp, unsigned int order)
{
struct folio *folio = folio_alloc(gfp, order);
if (folio && >0) {
// order >= HPAGE_PMD_ORDER (即 2MB) 的 folio 被视为"大页"
folio_set_active(folio); // 统计和回收使用
}
return folio;
}
(2)readahead 的自动升级
当 readahead 检测到顺序读模式时,会提示分配更大 order 的 folio:
// mm/readahead.c
void page_cache_ra_order(...)
{
// ...
if (_ra->ra_pages > 256) {
// 顺序读扩大:分配 order-4 (64KB) 甚至 order-9 (2MB) 的 folio
order = min(ra_order, 9);
folio = filemap_alloc_folio(GFP | __GFP_NORETRY, order);
filemap_add_folio(mapping, folio, index);
// ...
}
}
(3)writeback 按 folio 粒度写入
folio-aware 的 writeback 可以一次回写整个 2MB 区域(而非 512 个独立 4KB page),大幅减少文件系统块分配次数和 journal 事务提交次数。
5.3 实际性能影响
根据 kernel.org 邮件列表和社区 benchmark 数据:
| 工作负载 | 改进幅度 | 关键原因 |
|---|---|---|
| 大文件顺序 read | 5-15% 吞吐提升 | TLB miss 减少、预读粒度提升 |
| 大文件 writeback | 10-30% IOPS 提升 | 减少 fs metadata 操作、合并块分配 |
| mmap 文件随机访问 | 2-8% 提升 | 减少 page cache 级数遍历深度 |
| 内存回收(shrink) | 复杂 case 下 5-20% 提升 | 一次扫描操作可回收更大内存单元 |
| 文件系统 metadata 操作 | 15-25% 提升 | 分配、journal 和回写批量化 |
六、生产环境实践与调优
6.1 查看系统的 folio/THP 状态
$ grep -i huge /proc/meminfo
AnonHugePages: 524288 kB # 匿名 THP 使用量
ShmemHugePages: 0 kB # 共享内存 THP(tmpfs 等)
FileHugePages: 1048576 kB # 文件支持 THP 使用量(kernel 6.0+)
FilePMDMapped: 393216 kB # 文件页表直接映射 2MB
$ cat /sys/kernel/mm/transparent_hugepage/enabled
[always] madvise never
$ ls /sys/kernel/mm/hugepages/
hugepages-2048kB/ # 1024 x 2MB THP pool
hugepages-32768kB/ # ARM64 32MB (若支持)
hugepages-65536kB/ # 64KB (aarch64 级联页表)
hugepages-1048576kB/ # 1GB 巨型页(需静态预分配)
6.2 配置建议
OLTP 数据库(MySQL/PostgreSQL):建议关闭匿名 THP(避免延迟尖刺),但内核文件 IO 层自动产生的 folio 通常有益——THP in page cache 是按需求逐文件产生的,不会产生 promote/defrag 的开销。
# 仅关闭匿名 THP,文件 THP 的行为由 readahead 逻辑自动管理
echo never > /sys/kernel/mm/transparent_hugepage/enabled
# 仍可使用 MADV_HUGEPAGE madvise 让特定 mmap 区域启用文件 THP
存储服务器(分布式存储/RADOS/Ceph):开启大页预分配 + 文件 THP:
# 增加 THP pool
echo 4096 > /sys/kernel/mm/hugepages/hugepages-2048kB/nr_hugepages
# readahead 自动升级为 2MB folio
echo always > /sys/kernel/mm/transparent_hugepage/enabled
# 预读窗口调大
echo 8192 > /sys/block/nvme0n1/queue/read_ahead_kb
虚拟化和容器:对 guest 来说,host 的 THP 仅影响 host 自身的 page cache。若使用 virtiofs 或 9p 传递文件,guest 内部的文件 IO 是否使用 THP 由 guest 内核决定。
6.3 关键内核启动参数
# 彻底禁用 THP(包括文件支持)
transparent_hugepage=never
# 仅通过 madvise 启用(推荐生产默认值)
transparent_hugepage=madvise
# 全局启用(适用于大多数场景)
transparent_hugepage=always
# 对 tmpfs/shmem 的全局 THP 设置
/sys/kernel/mm/transparent_hugepage/shmem_enabled
# 选项:always|within_size|advise|never|deny|force
七、安全视角:新的攻击面与缓解
7.1 TLB 侧信道与 folio
更大的 page 大小(2MB vs 4KB)意味着 TLB 条目覆盖的内存范围更大。这使得某些 TLB 侧信道攻击更容易(一个 TLB miss 可泄漏 2MB 的信息而非 4KB)。然而:
- 正向效应:更少的 TLB 条目意味着 KPTI(内核页表隔离)的 TLB 刷新开销更低
- 缓解:现代 CPU 的 TLB 按 page size 隔离,不同大小的 TLB 条目独立淘汰
- 实践:Spectre/Meltdown 缓解的 TLB 刷新开销与 page size 成反比
7.2 巨型页碎片化拒绝服务
申请 2MB 不可移动的 folio 可能加剧内存碎片化。Linux 内核通过以下机制缓解:
- defrag 水位:
sysctl vm.extfrag_threshold控制内存压缩触发阈值 - GFP 标志约束:
GFP | __GFP_NORETRY让 folio 分配失败时优雅降级为 smaller order - 渐进式分配:先尝试 order-9(2MB),失败后回退到 order-6(256KB),再到 base page
八、代码实战:手动操作 folio
8.1 内核模块中的 folio 生命周期管理
#include <linux/fs.h>
#include <linux/pagemap.h>
#include <linux/falloc.h>
static int __init folio_demo_init(void)
{
struct folio *folio;
struct page *page0, *page1;
void *addr;
// 1. 分配一个 order-2 (16KB) 的 folio
folio = folio_alloc(GFP_KERNEL, 2);
if (!folio)
return -ENOMEM;
// 2. 锁定(防止被回收)
folio_lock(folio);
// 3. 映射到内核虚拟地址
addr = folio_address(folio);
memset(addr, 0, folio_size(folio)); // 使用 folio_size() 而非硬编码 PAGE_SIZE
// 4. 操作 folio 中的子页
page0 = folio_page(folio, 0); // head page
page1 = folio_page(folio, 1); // 第2个 page
SetPageDirty(page1); // 标记子页 dirty(PG_dirty 只作用于 page 级)
// 5. 引用计数操作
folio_get(folio); // +1
// ... 使用 folio ...
folio_put(folio); // -1
folio_put(folio); // -1 → 触发释放
pr_info("folio_demo: allocated 4 pages (order-2), addr %pK\n", addr);
return 0;
}
static void __exit folio_demo_exit(void)
{
pr_info("folio_demo: exited\n");
}
MODULE_LICENSE("GPL");
8.2 用户态观测 folio/THP 行为
# 通过 /proc/pid/smaps_rollup 查看进程级 THP 使用
$ grep -i anonhuge /proc/self/smaps_rollup
AnonHugePages: 131072 kB
# 通过 perf 追踪 THP 分配事件
$ perf stat -e dTLB-load-misses,iTLB-load-misses \
-p $(pidof my_app) sleep 5
# 通过 ftrace 追踪 folio 分配
$ echo 1 > /sys/kernel/debug/tracing/events/kmem/mm_page_alloc_extfrag/enable
$ cat /sys/kernel/debug/tracing/trace_pipe
# 查看哪些调用路径因碎片化导致分配降级
# 通过 /proc/vmstat 查看 THP 统计
$ grep thp /proc/vmstat
thp_fault_alloc 847 # THP 通过缺页分配的次数
thp_fault_fallback 23 # THP 降级为 base page 的次数
thp_file_alloc 1502 # 文件 THP 分配次数(folio)
thp_file_fallback 41 # 文件 THP 降级次数
thp_split_page 68 # THP 被拆分为 base page 的次数
九、未来演进路线
9.1 Folio 化的最终目标
Linux 内核社区的目标是将 page cache 的核心代码路径完全基于 folio,最终将 struct page 在虚拟内存层中逐步"降级"为仅由低级硬件接口(页表、DMA、slab)使用的类型。这不是短期内能完成的目标——数以万计的驱动和文件系统代码仍需逐步适配。
9.2 与 CXL.mem 的协同
CXL 2.0/3.0 引入的 CXL.mem(内存扩展)设备通常以 2MB 或 1GB 粒度映射。File-backed folio 天然适配大页面映射——主机可以将 CXL-attached 内存中的文件 THP(2MB folio)整体映射到用户态,避免多级页表遍历。这对 AI/ML 大模型训练中频繁加载参数文件的场景有直接性能价值。
9.3 Name-Based Mapping
更激进的方向是(Name-Based Mapping)——不将文件映射到虚拟地址空间的固定偏移,而是通过文件名 + offset 直接定位 page cache 条目。这可以消除 mmap 中 VM 管理的复杂度,从根本上避免 VMA 膨胀问题。Folio 已经为此铺平了道路——一个 order-N 的 folio 是一个自包含的逻辑单元,可以脱离虚拟地址空间单独管理。
十、总结
Memory Folios 绝非简单的重命名或类型包装,而是 Linux 内存管理架构的一次根本性简化。它解决了困扰内核开发者十余年的 compound page 歧义问题,为 THP 在 page cache 中的全面支持奠定基础,并通过类型安全提升代码可维护性。对于高性能存储、数据库、AI 训练和大规模虚拟化工作负载,理解 folio 的分配、锁定、引用和回收机制是优化 IO 吞吐和内存效率的关键。随着 Linux 6.x+ 的不断成熟,folio 已成为现代 Linux 存储栈的事实标准——掌握它,就是掌握了虚拟内存子系统的演进方向。

发表评论 取消回复