Folio:为什么内核重新组织 page 抽象
上一篇看完 page cache 如何把文件内容组织成一批页面。中间已经出现过 folio 这个词。这一篇正面回答它:folio 是什么、为什么出现、读源码时该怎样处理这个新名词。
一句话定位 folio 的角色:
folio 把”缓存和回收实际处理的一组页面”显式化,去掉
struct page里 head/tail 二义性带来的语义负担。
问题从哪里来
struct page 在 Linux 内核里承担过太多角色:单页物理元数据、page cache 的索引单元、回收链表节点、复合页(compound page)的 head/tail、THP 的子页、SLUB 的 slab 元数据、设备直通的 page、ZONE_DEVICE 页等等。这个结构体在不同子系统里的字段复用方式不同,对同一个指针的解释也不同。
一个具体的痛点是复合页的 head/tail 二义性。复合页由若干物理页组成,其中第一页叫 head,其余叫 tail。许多接口要求传 head 不传 tail,或者反之;不少接口接受任意一个,但行为有微妙差异。LWN 在 folio 提案的报道里把这种混乱压成一句:
A function which has a struct page argument might be expecting a head or base page and will BUG if given a tail page.
更核心的问题是语义不清。同一个 struct page * 参数,调用方需要回答:
if a function is passed a pointer to a page structure for a tail page, is it expected to act on that tail page or on the compound page as a whole?
调用方有时要先调 compound_head() 把 tail 转成 head 再传,有时不需要。这种约定散在大量调用点上,难以验证。
folio 的提案就是把这层约定从“调用方记住”变成“类型本身保证”。
folio 是什么
folio 在概念上很简洁:
A folio is a page structure that is guaranteed not to be a tail page.
struct folio 在源码里与 struct page 用相似的物理布局起头,但通过类型把 tail 的可能性排除掉。一个 folio 要么是单页(order 0),要么是一段连续物理页中的 head,因此接受 folio 参数的函数可以确定:
Any function accepting a folio will operate on the full compound page (if, indeed, it is a compound page) with no ambiguity.
这条性质带来的连锁效应不只是“代码不易出错”:
- 接口签名表达力变强。看到函数收
struct folio *,就知道它是按一组页面整体处理。 - 性能上少一次
compound_head()调用。常规struct page *路径里很多操作需要先把 tail 标准化成 head 再处理,folio 路径直接省掉这一步。LWN 报道里给过一个数字:早期 THP 相关工作在 kernel 编译这类负载上能拿到约 7% 的改善,folio 抽象是延续这个方向的基础设施。 - 为不同尺寸的大页腾出空间。page cache 在演进中需要管理 order 不同的 folio(不只是单页和 THP-512),folio 把“管理单元”和“物理页”解耦后,多尺寸大页才有干净的承载结构。
注意几个克制:
- folio 没有“完全替代”
struct page。struct page仍然存在,仍然是单页物理元数据。folio 是叠加上去的更高层管理单元。 - folio 也不等于 huge page。一个 folio 可能只覆盖单页(order 0),也可能覆盖一段连续物理页。是否对应硬件大页是另一件事。
- folio 不是只用于文件页。page cache 文件页是 folio 第一批受益者,匿名页路径上 folio 化也在持续推进,具体进度按当前源码确认。
一段最小源码阅读
理解 folio 的最快路径是看它怎样出现在 page cache 路径上。打开 mm/filemap.c,搜 folio:
1 | |
会看到大量返回 struct folio *、接收 struct folio * 的接口,例如 filemap_get_folio、__filemap_get_folio、filemap_get_folios、read_cache_folio、folio_lock、folio_end_writeback、folio_test_uptodate 等等。
读其中一组接口,可以归纳出 folio 的常见操作类型:
| 操作族 | 例子 | 等价于旧 page 接口 |
|---|---|---|
| 查找 | filemap_get_folio |
find_get_page 等 |
| 装填 | read_cache_folio |
read_mapping_page 等 |
| 锁 | folio_lock / folio_unlock |
lock_page / unlock_page |
| 状态 | folio_test_uptodate / folio_mark_dirty |
PageUptodate / SetPageDirty |
| I/O 完成 | folio_end_writeback / folio_end_read |
end_page_writeback 等 |
| 引用 | folio_get / folio_put |
get_page / put_page |
不需要记完。重要的是看到一个规律:原来散在 struct page 上的标志位与状态机,正在按相同语义搬到 folio_* 命名空间。读到 folio 接口的命名规律后,反过来读旧 page 接口也更顺。
官方文档对 folio 的几个细节也写得很谨慎,比如 read_cache_folio:
the folio returned will contain index, but it may not be the first page of the folio.
这句话提醒读者:调用方传入的 pgoff_t 不一定是 folio 的起始偏移;如果需要起始偏移要单独取。它从侧面验证了 folio 是按组管理,组里包含调用方关心的那一页就行,不强求对齐到组的边界。
模式提炼:物理单位不等于管理单位
1 | |
复合页里的若干物理页是“物理单位”,folio 是“管理单位”。把它们等同会带来一连串误解。换个角度:
| 维度 | struct page |
struct folio |
|---|---|---|
| 表达 | 单页物理元数据 | 一组连续物理页的管理对象 |
| 大小 | PAGE_SIZE |
PAGE_SIZE * 2^order |
| 接口契约 | 调用方负责 head/tail 校正 | 函数收 folio 时保证不是 tail |
| 主战场 | 物理页生命周期、PFN/PTE 关联 | 缓存、回收、I/O、大页 |
这种“物理单位 vs 管理单位”的拆分在其他系统里也常见:文件系统的 block 与 extent、内存分配器的 page 与 chunk、对象存储的 object 与 segment、数据库的 page 与 buffer。
核心结构:struct folio
struct folio 的定义开头(include/linux/mm_types.h,极度精简):
1 | |
注意 _folio_order 字段:它让调用方无需再用 compound_order(page) 间接获取大小信息。
阅读 folio 源码的几个起手式
不要在第一次接触 folio 时尝试穷举所有接口。下面这条路径足以建立直觉:
- 在
include/linux/mm_types.h里定位struct folio与struct page的关系。看清楚 folio 复用了 page 布局的哪些字段。 - 在
include/linux/page-flags.h里看FOLIO_FLAG系列宏,理解原来的PG_*标志位是怎样按 head 视角整理到 folio 上的。 - 在
mm/filemap.c里看filemap_get_folio、__filemap_get_folio、filemap_fault三个函数。它们覆盖了“查找 + 装填 + 安装进进程地址空间”三步。 - 在
mm/folio-compat.c(若存在)和各模块里搜folio_*与对应旧 page 接口,对照看哪些路径已经完全切换,哪些还有兼容层。 - 用
git log --oneline -- mm/filemap.c | head -50看最近的提交,能感受到 folio 化是一个持续重构而非一次性切换。
读到陌生的 folio_* 函数时,先按命名规律推测它对应的旧 page 函数。多数情况这条捷径成立,少数函数有专门的 folio 路径,读到时再具体看。
研究生迁移表
| Linux 概念 | 一般系统设计 |
|---|---|
struct page |
最小物理单位的元数据 |
struct folio |
显式管理单位 |
compound_head() |
旧接口里的归一化函数 |
folio_test_* / folio_set_* |
状态机收敛 |
| 多尺寸大页 | 同一容器承载多种粒度 |
这种重构思路在系统软件里反复出现。统一资源池下面允许多尺寸对象、上层接口先于内部实现稳定下来、明显错误的调用在编译期通过类型阻止——folio 是 Linux MM 演进上的一个典型样本。
常见误解
第一个误解是把 folio 等同于 huge page。folio 可能是单页(order 0),也可能是一组连续物理页。它表达管理单位,不直接表达物理大小。
第二个误解是认为 folio 已经全面替代 struct page。事实是 folio 抽象在持续推广中,page cache 路径已经较为彻底,匿名页路径与设备页路径上还有大量原生 struct page 接口。当前情况按目标内核版本源码确认。
第三个误解是认为读 folio 化的源码必须重新学习。folio_* 的命名与原来的 PG_*、Page* 接口基本一一对应。先把这层对照建立起来,读起来就不陌生。
第四个误解是把 folio 的引入说成纯性能优化。它确实带来一些性能好处(LWN 报道过 kernel 编译负载约 7% 的提升),但更核心的价值是接口契约清晰,类型让 head/tail 二义性在编译期就消失。那个性能数字不能直接外推到所有场景。
练习
第一,本地克隆当前 mainline Linux 仓库(不必完整 checkout 历史,浅克隆即可),执行 grep -nE 'struct folio[^a-zA-Z_]' mm/filemap.c | wc -l 和 grep -nE 'struct page[^a-zA-Z_]' mm/filemap.c | wc -l,比较两者数量级。再换 mm/memory.c、mm/rmap.c 做同样比较,看不同子系统的 folio 化进度差异。
第二,打开 include/linux/mm_types.h,找到 struct folio 的定义,列出它通过 union 与 struct page 共享的前若干字段。再在 include/linux/page-flags.h 找一组 FOLIO_FLAG 宏定义,看它们如何把 PG_* 位映射到 folio 视角。
第三,写一段不超过 200 字的“给同事的内部文档”,解释“回收路径里为什么看到的是 folio 而不是 page”,要求至少引用 mm-api 文档与 mm/filemap.c 中一个具体函数。
第四,阅读 read_cache_folio 的实现与注释,回答两个问题:调用方传入的 pgoff_t 与返回的 folio 的起始偏移是什么关系;如果 backing store 读取失败,返回值如何反映这一情况。
第五,假设你接手一段还用 find_get_page、PageDirty、SetPageUptodate 的老代码,写出迁移到 folio 接口的最小步骤清单,并解释哪些步骤可能引入语义变化(例如 head/tail 假设不同)。
系列导航
- 上一篇:文件映射和 page cache:文件内容怎样变成页面
- 本文:Folio:为什么内核重新组织 page 抽象
- 下一篇:反向映射:从物理页找回虚拟地址
