[toc]
mm/truncate.c 页面缓存截断(Page Cache Truncation) 文件大小变更的核心内存管理
历史与背景
这项技术是为了解决什么特定問題而誕生的?
这项技术是为了实现一个最基本、最核心的文件系统功能:更改文件的大小,特别是缩减文件(Truncation)。这是一个由POSIX标准规定的基础操作。mm/truncate.c
专门负责处理这个操作在内存管理(Memory Management, MM)层面最复杂的部分:确保文件的内存表示(即页面缓存 Page Cache)与文件在磁盘上的新大小保持一致。
它解决了以下核心问题:
- 内存与磁盘同步:当用户调用
truncate()
或以O_TRUNC
模式打开文件时,不仅需要通知文件系统释放磁盘上的数据块,还必须精确地从内核的页面缓存中移除或修改对应的缓存页。否则,应用程序可能会读到已经被“删除”的陈旧数据,或者脏页(Dirty Page)可能会错误地将已删除的数据写回磁盘。 - 处理边界条件:文件截断不总是以页面大小(通常是4KB)对齐的。
mm/truncate.c
必须能精确处理部分被截断的页面,即一个页面中只有一部分数据属于截断后的文件,剩余部分必须被作废(通常是清零)。 - 统一接口:不同的文件系统(ext4, XFS, Btrfs等)有不同的磁盘数据块管理方式。
mm/truncate.c
提供了一套通用的、与文件系统无关的页面缓存处理逻辑,使得文件系统开发者不必重复实现这套复杂的内存管理代码,只需专注于实现自己特有的磁盘块释放逻辑即可。
它的发展经历了哪些重要的里程碑或版本迭代?
mm/truncate.c
中的逻辑是内核VFS和MM子系统非常古老和核心的一部分。其发展主要体现在对新内核特性的适应和性能优化上:
- 基本实现:早期内核就包含了文件截断的核心逻辑,即遍历和释放页面缓存。
- 性能优化:对于非常大的文件,逐页遍历和锁定开销很大。内核对此进行了优化,例如引入了范围截断(
truncate_inode_pages_range
),可以更高效地处理大范围的页面失效。 - 支持透明大页(THP):当文件数据被缓存在透明大页中时,截断操作需要能正确地分割(split)大页,只保留所需的部分,这比处理标准小页要复杂得多。
- 支持DAX(Direct Access):对于支持DAX的持久内存,文件数据直接映射,绕过了页面缓存。
mm/truncate.c
的逻辑需要与DAX协同工作,在截断时不是释放缓存页,而是直接清零持久内存的对应区域,并使CPU缓存失效。
目前该技术的社区活跃度和主流应用情况如何?
mm/truncate.c
是内核MM和VFS中极其稳定和基础的部分,其核心逻辑很少发生大的变动。社区的活跃度主要体现在为新的内存管理或文件系统特性(如Folios)提供适配和进行微观性能调优。它是所有支持可写操作的Linux文件系统的基石,被以下系统调用和操作所依赖:
truncate(2)
和ftruncate(2)
系统调用。open(2)
系统调用中使用O_TRUNC
标志。creat(2)
系统调用(等同于open
与O_CREAT | O_WRONLY | O_TRUNC
)。
核心原理与设计
它的核心工作原理是什么?
mm/truncate.c
的工作核心是一个两阶段的、内存优先的清理过程,它与具体的文件系统驱动紧密协作。
当一个文件需要被截断到一个新的、更小的尺寸(new_size
)时:
- VFS层发起:操作始于VFS层,通常是
notify_change()
函数检测到ATTR_SIZE
变更。 - 文件系统预处理:VFS会首先调用文件系统特有的函数,通知它文件大小即将改变。文件系统此时会更新其内存中的inode大小 (
i_size
),并可能做一些准备工作。 - 内存截断 (mm/truncate.c的核心):VFS接着调用
truncate_inode_pages_range()
或类似的函数进入mm/truncate.c
的逻辑。这个函数会:
a. 遍历页面缓存:它会从新的文件末尾(new_size
)开始,一直遍历到旧的文件末尾,查找所有属于这个范围的缓存页。
b. 处理完全位于截断范围的页面:对于整个页面都在new_size
之后的,逻辑很简单:
* 如果页面是脏的,丢弃其数据(因为这部分数据已被删除),并清除其脏状态。
* 从文件的页面缓存基数树(Radix Tree)中移除该页面。
* 减少页面的引用计数,如果降到零,则释放该物理页。
c. 处理跨越边界的页面:对于恰好包含new_size
的那个页面,操作比较精细:
* 页面中从new_size
到页面末尾的这部分区域必须被清零。
* 如果这个页面是脏的,它仍然保持脏状态,但后续写回时只会写回有效部分。 - 文件系统后处理:在页面缓存被清理干净后,VFS会调用文件系统的
.truncate
操作。此时,文件系统驱动可以安全地遍历并释放所有从new_size
开始的磁盘数据块,因为它知道这些块在内存中已无任何缓存。
这个**“先清理内存,再释放磁盘”**的顺序至关重要,它避免了在释放磁盘块后,仍然有脏页试图将数据写回已释放块的竞态条件。
它的主要优势体現在哪些方面?
- 抽象与代码复用:将通用的内存管理逻辑集中化,极大地简化了文件系统的开发。
- 一致性保证:通过严格的执行顺序,确保了文件在内存和磁盘上视图的一致性。
- 健壮性:能正确处理各种复杂情况,如脏页、稀疏文件(holes)、透明大页和DAX。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 性能开销:截断一个非常大的文件可能是一个性能开销很大的操作。它需要修改大量的文件系统元数据(块位图、扩展树等),这可能导致大量的随机I/O。
- 破坏性操作:截断是一个不可逆的数据销毁操作。
mm/truncate.c
的职责就是正确地执行这个销毁,但从用户角度看,误操作的后果是灾难性的。 - 非安全擦除:该机制只负责将磁盘块归还给文件系统的可用空间池,并不会对这些块进行擦写。被截断的数据在被新数据覆盖之前,仍可能通过磁盘工具恢复。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?
它是Linux/POSIX系统中唯一标准的缩减文件尺寸的解决方案。
- 日志文件管理:定期清空或轮转(rotate)日志文件。例如,执行
truncate -s 0 /var/log/app.log
或在脚本中用> /var/log/app.log
来清空日志,其后台就是mm/truncate.c
在工作。 - 文件重写:当应用程序需要完全重写一个配置文件或数据文件时,最常见的模式就是以
open(path, O_WRONLY | O_TRUNC)
的方式打开文件。 - 数据库和键值存储:在管理WAL(预写日志)或SSTable文件时,可能会截断文件以移除已合并或作废的数据段。
- 临时文件使用:当一个程序使用临时文件,并且需要在处理过程中缩减其大小时(虽然不常见),会用到
ftruncate()
。
是否有不推荐使用该技术的场景?为什么?
- 需要高性能地“清空”文件:如果一个应用的模式是反复地清空并重用同一个文件,那么
truncate()
后立即重新写入可能不是最高效的。unlink()
一个旧文件并创建一个同名的新文件有时可能更快,因为它避免了保留和修改旧inode的元数据开销。 - 需要安全删除数据:如上所述,
truncate
不是一个安全的数据擦除工具。需要保证数据无法恢复的场景,必须使用如shred
或专门的安全擦除工具。 - 在文件中间创建空洞:
truncate
只能从文件末尾移除数据。如果
include/linux/pagevec.h 页面向量(Page Vector) 高效的批量页面操作工具
历史与背景
这项技术是为了解决什么特定问题而诞生的?
这项技术是为了解决在内核内存管理(MM)子系统中,对大量页面(struct page
)进行单一、重复操作时的性能瓶ăpadă问题。
在pagevec
(页面向量)出现之前,许多内核路径需要对一组页面执行相同的操作(例如,将它们添加到LRU链表、释放它们、或将它们从页面缓存中移除)。这些操作通常是在一个循环中逐个页面完成的。这种方法的弊端在于:
- 高昂的锁开销:每次操作单个页面,都可能需要获取和释放一个或多个锁(例如,LRU链表锁)。在多核系统上,这种频繁的锁操作会导致严重的锁争用和缓存行弹跳,极大地限制了系统的伸缩性。
- 函数调用开销:在紧凑的循环中反复调用同一个函数会累积不可忽视的CPU开销。
- 效率低下:无法利用批量处理带来的优势。
pagevec
的诞生,就是为了提供一个轻量级的、基于栈的批量处理容器。它允许内核代码先将一组页面收集到一个小数组(向量)中,然后一次性地对整个数组执行操作。这极大地**摊销(Amortize)**了锁获取和函数调用的开销,显著提升了页面密集型操作的性能。
它的发展经历了哪些重要的里程碑或版本迭代?
pagevec
是一个相对稳定和底层的工具,其发展主要体现在其在内核中的逐步普及和应用,而不是自身功能的剧烈变化。
- 引入:作为一个内存管理性能优化的工具被引入。
- 广泛采用:由于其带来的显著性能提升,
pagevec
迅速成为内核MM子系统中处理页面集合的标准范式。它被深度集成到以下核心路径中:- 内存回收(
mm/vmscan.c
) - 页面缓存回写(
fs/fs-writeback.c
) - 文件截断(
mm/truncate.c
) - 页面迁移
- 内存回收(
- API完善:围绕
pagevec
,内核提供了一系列方便的封装函数,如pagevec_lru_add()
、pagevec_release()
等,使得批量操作的调用更加简洁。
目前该技术的社区活跃度和主流应用情况如何?
pagevec
是Linux内核内存管理中一个基础、成熟且不可或缺的组件。它的代码本身非常稳定,很少改动。其“活跃度”体现在内核中几乎所有涉及多页面处理的性能关键路径都在使用它。它是保证Linux在多核、大内存环境下依然能高效进行内存管理的关键微观优化之一。
核心原理与设计
它的核心工作原理是什么?
pagevec
的核心是一个简单的、固定大小的结构体,通常在函数栈上分配:
1 | // include/linux/pagevec.h |
它的工作模式遵循一个清晰的**“收集-刷出”(Collect-and-Flush)**模式:
- 初始化:在使用前,通过
pagevec_init()
将nr
计数器清零。 - 收集页面:在一个循环中,通过
pagevec_add(pvec, page)
将需要处理的页面指针添加到pvec->pages
数组中,并增加nr
计数。 - 自动刷出:
pagevec_add()
函数有一个关键的内置逻辑:如果添加页面后,nr
达到了PAGEVEC_SIZE
(即向量已满),它会自动调用一个批量处理函数(例如,__pagevec_lru_add_fn()
),将当前向量中的所有页面一次性处理掉,然后自动将nr
清零。 - 手动刷出:当收集页面的循环结束后,向量中可能还残留着一些未处理的页面(因为
nr
未满)。此时,需要手动调用一次批量处理函数(例如,pagevec_lru_add(pvec)
)来处理这些“尾单”。
这个设计巧妙地将批量处理的逻辑封装起来,使得调用者的代码可以保持简洁。
它的主要优势体現在哪些方面?
- 极高的性能:这是其核心优势。通过将14次锁操作合并为1次,极大地降低了锁争用,提升了吞吐量。
- 代码简洁:为批量页面操作提供了一个标准、统一的抽象,避免了在内核各处重复实现类似的手动批处理逻辑。
- 基于栈:
pagevec
通常在栈上分配,无需动态内存分配(kmalloc
),使得其在任何上下文中都能被高效、安全地使用。
它存在哪些已知的劣势、局限性或在特定场景下的不适用性?
- 固定大小:
PAGEVEC_SIZE
是一个编译时常量。这个大小是一个权衡:太小则摊销效果不佳,太大则会占用过多栈空间并可能增加单次操作的延迟。 - 非通用数据结构:它是一个高度特化的工具,不是一个通用的链表或动态数组。它只适用于“收集-刷出”模式,不支持随机访问或从中删除单个元素。
- 栈空间占用:虽然是优势,但在极深的函数调用链或栈空间极其受限的特殊场景下,也需要注意其栈空间占用。
使用场景
在哪些具体的业务或技术场景下,它是首选解决方案?
在任何需要对多个页面执行相同操作的内核路径中,pagevec
都是首选解决方案。
- 内存回收(
mm/vmscan.c
):内核的kswapd
线程在扫描LRU链表以回收内存时,会将识别出的可回收页面收集到一个pagevec
中,然后调用pagevec_release()
来批量释放它们。 - 页面缓存回写:当内核需要将脏页写回磁盘时,它会扫描文件的inode,将脏页收集到
pagevec
中,然后批量提交给块设备层。 - 文件截断(
mm/truncate.c
):当一个文件被截断时,所有需要从页面缓存中移除的页面会被收集到pagevec
中,然后被批量释放。 - 添加到LRU链表:当新页面被换入(fault in)时,通过
pagevec_lru_add()
将它们批量添加到LRU链表的尾部,可以显著减少对LRU锁的争用。
是否有不推荐使用该技术的场景?为什么?
- 单页面操作:如果明确知道每次只需要处理一个页面,那么使用
pagevec
会带来不必要的开销。直接调用单页面的处理函数(如lru_cache_add()
)会更简单、更高效。 - 需要构建持久的页面列表:如果需要构建一个页面列表,并在其生命周期中进行复杂的管理(如排序、随机删除),那么
pagevec
不适用。此时应该使用标准的内核链表(struct list_head
)。
对比分析
请将其 与 其他相似技术 进行详细对比。
pagevec
vs. 逐个页面循环操作
特性 | pagevec (批量) |
逐个页面循环操作 (单次) |
---|---|---|
实现方式 | 先将页面收集到数组中,然后对整个数组执行一次操作。 | 在循环中,对每个页面都执行一次独立的操作。 |
性能 | 高。锁开销被摊销,函数调用次数少。 | 低。频繁的锁操作和函数调用在高并发、多核环境下会导致严重的性能瓶颈。 |
锁争用 | 极低。一次循环(最多14个页面)通常只获取一次锁。 | 高。每次循环都获取和释放锁。 |
代码复杂度 | 略高,需要init 和一次最终的flush 调用。 |
非常直观,循环体即操作本身。 |
适用场景 | 处理多个页面的性能关键路径。 | 处理单个页面,或性能要求不高的场景。 |
pagevec
vs. struct list_head
(内核标准链表)
特性 | pagevec |
struct list_head |
---|---|---|
数据结构 | 定长数组,基于栈。 | 双向链表,需要动态分配节点或嵌入到其他结构中。 |
核心用途 | 瞬时批处理容器。”收集-刷出”。 | 通用、持久的列表管理。 |
内存分配 | 通常在栈上,无额外开销。 | 需要为链表节点或包含链表节点的结构体分配堆内存。 |
API | 简单的add (自动刷出)和flush 。 |
丰富的API,支持在任意位置添加、删除、遍历、拼接等。 |
性能特点 | 针对摊销锁开销进行了优化。 | 插入和删除操作是O(1),但本身不解决批量操作的锁争用问题。 |
适用场景 | 高效地将一组页面传递给一个批量处理函数。 | 构建和管理一个生命周期较长的、动态大小的页面集合。 |
Folio Batch:分摊内存操作开销的基础容器
本头文件 linux/pagevec.h
定义了Linux内存管理子系统中一个基础且至关重要的性能优化工具:struct folio_batch
。它是一个简单的、固定大小的容器,专门用于批量处理(batching) folio(代表一个或多个物理页的内存单元)。其核心设计思想是通过将多次对单个folio的操作聚合起来,然后一次性地对整批folio执行某个动作,从而**分摊(amortize)**获取锁、函数调用以及其他同步操作的开销,显著提升内存管理相关路径的性能。
实现原理分析
folio_batch
的实现非常简洁高效,本质上是一个轻量级的、基于数组的队列或集合。
数据结构 (
struct folio_batch
):folios[PAGEVEC_SIZE]
: 这是一个固定大小的指针数组,是批处理的核心存储区。PAGEVEC_SIZE
通常被设置为一个略小于2的幂的奇数(如31),这是一种常见的内存对齐技巧,可以使得整个folio_batch
结构体的大小恰好是一个2的幂(例如256字节),从而提高缓存利用率。nr
: 一个unsigned char
,记录了当前批处理中有效的folio数量。这是数组的“水位线”。i
: 一个unsigned char
,用作一个迭代游标,主要由folio_batch_next
使用,以支持将folio_batch
作为一个队列来处理。percpu_pvec_drained
: 一个布尔标志,用于更复杂的per-CPU批处理场景,标记相关的per-CPU批次是否已被清空。
核心操作 (内联函数):
folio_batch_init()
: 初始化一个批处理。它简单地将nr
和i
清零,表示这是一个空的批处理。这是使用folio_batch
的第一步。folio_batch_count()
: 返回当前批处理中的folio数量(即nr
的值)。folio_batch_space()
: 返回批处理中还剩多少可用的空位。folio_batch_add()
: 将一个folio指针添加到批处理的末尾,并递增nr
。这是向批处理中“装填”folio的标准方法。它会返回剩余空间,允许调用者判断批处理是否已满。folio_batch_next()
: 将folio_batch
作为一个队列来使用。它返回游标i
指向的folio,并递增i
。当i
追上nr
时,表示队列已处理完毕,返回NULL
。
生命周期与资源管理:
folio_batch_release()
: 这是一个至关重要的函数。当一个folio被添加到folio_batch
中时,它的引用计数通常是增加的。处理完一个批次后,必须调用folio_batch_release
。这个函数(通过其工作者__folio_batch_release
)会遍历批处理中的所有folio,并调用folio_put
来递减它们的引用计数。这确保了所有被临时引用的folio最终都能被正确释放,防止了内存泄漏。
特殊条目处理:
folio_batch_remove_exceptionals()
: 页缓存等机制有时会在批处理中放入一些“异常”条目(例如,已经被其他任务解锁的folio)。这个函数负责遍历批处理,并将这些异常条目从中移除(通常是通过将后面的有效条目向前移动来覆盖它们),以保证后续操作只处理有效的folio。
代码分析
1 | // ... (头文件保护和包含) ... |
mm/truncate.c
页缓存失效:从地址空间中移除干净的缓存页
本代码片段定义了Linux内存管理中一个关键的函数 invalidate_mapping_pages
,它是在 drop_caches
功能中负责清理页缓存的核心执行者。其主要功能是遍历一个文件(由其address_space
表示)的页缓存,并尝试移除所有干净的(clean)、未被锁定且未被映射到用户空间的缓存页(folios)。这是一个性能攸关的操作,它采用**批处理(batching)**的方式来高效地查找、锁定和处理页面,同时通过主动调度来避免长时间占用CPU。
实现原理分析
invalidate_mapping_pages
本身是一个简单的包装,其所有复杂逻辑都在 mapping_try_invalidate
函数中。该函数的设计体现了内核中常见的高性能、高并发处理模式。
批处理 (Batched Processing):
- 函数的核心是一个
while
循环,但它不是一次处理一个页面。它使用一个folio_batch
结构体,通过find_lock_entries
函数一次性地从文件的页缓存(内部由XArray数据结构实现)中查找并锁定多达PAGEVEC_SIZE
(通常是15个)个folio。 - 为什么用批处理? 因为查找和锁定XArray本身是有开销的。一次性处理一批,可以**分摊(amortize)**这些开销,显著提高整体吞吐量。
- 函数的核心是一个
核心循环 (The Main Loop):
while (find_lock_entries(...))
: 这个循环会一直持续,直到find_lock_entries
在指定的范围内再也找不到任何可处理的页缓存条目为止。- 查找并锁定:
find_lock_entries
负责在mapping
中从index
位置开始查找,它会跳过已经被锁定的条目,并对找到的条目尝试加锁。 - 处理批次: 在
while
循环内部,一个for
循环会遍历folio_batch
中所有被成功找到并锁定的folio。
逐个驱逐 (Eviction Logic):
- 处理影子条目: 页缓存(XArray)不仅可以存储指向folio的指针,还可以存储特殊的“影子条目”(shadow entries)或“值条目”(value entries),这些条目不代表真实的内存页,而是元数据(例如,表示文件空洞)。代码通过
xa_is_value
来识别它们,并跳过驱逐逻辑。 mapping_evict_folio
: 这是尝试驱逐一个folio的核心函数。它会检查folio的引用计数和状态。只有当folio是干净的、未被映射且引用计数允许释放时,才会成功将其从address_space
中移除并释放。如果成功,mapping_evict_folio
返回1。- 处理驱逐失败: 如果
mapping_evict_folio
返回0,意味着folio不能被立即驱逐(例如,它可能仍然被某个进程映射着,或者它是脏的)。在这种情况下,代码会采取一个“降级”措施:调用deactivate_file_folio
。这个函数不会释放页面,但会将其移动到LRU链表的**非活跃(inactive)**端,使其成为下一次内存回收时优先被考虑的对象。这是一种“我虽然不能马上扔掉你,但我可以把你放在垃圾桶旁边”的策略。
- 处理影子条目: 页缓存(XArray)不仅可以存储指向folio的指针,还可以存储特殊的“影子条目”(shadow entries)或“值条目”(value entries),这些条目不代表真实的内存页,而是元数据(例如,表示文件空洞)。代码通过
清理与协作 (
cond_resched
):folio_batch_release
: 在处理完一个批次后,必须调用此函数来释放folio_batch
中所有folio的引用计数。cond_resched
: 这是保证系统响应性的生命线。清理一个大文件的页缓存可能是一个非常耗时的操作。如果在内核态长时间不间断地执行,会导致其他任务(包括高优先级的)得不到调度,造成系统“卡死”或触发软锁死(soft lockup)警告。cond_resched()
会检查当前任务是否需要重新调度,如果是,它会主动让出CPU,让其他任务运行,然后再继续执行。
代码分析
1 | /** |
清理影子条目:维护页缓存(XArray)的一致性
本代码片段定义了 clear_shadow_entries
函数,它是页缓存管理的一个内部辅助函数。其核心功能是清理一个文件(由其address_space
表示)的页缓存(内部由一个XArray数据结构实现)中特定范围内的影子条目(shadow entries)。影子条目是一种特殊的元数据标记,不代表真实的内存页,通常用来表示文件空洞(holes)或者标记某些页已被交换出去(swap)。当 invalidate_mapping_pages
等操作需要清理页缓存时,必须同时清理这些影子条目,以保证页缓存数据结构的一致性。
实现原理分析
clear_shadow_entries
的实现直接操作底层的XArray数据结构,这是一个高度优化的、用于存储稀疏索引指针的树状结构。
初始化XArray状态 (
XA_STATE
):XA_STATE(xas, &mapping->i_pages, start)
: 这是一个宏,它在栈上初始化一个xas
(XArray State)对象。这个对象是与XArray交互的“游标”或“句柄”,它包含了遍历和修改XArray所需的所有上下文信息。它被初始化为指向mapping->i_pages
(即页缓存的XArray树)的start
索引位置。
特殊文件系统处理:
if (shmem_mapping(mapping) || dax_mapping(mapping))
: 函数首先检查是否为shmem
(共享内存/tmpfs)或DAX
(直接访问存储)。shmem
有自己独特的、更复杂的影子条目管理逻辑,因此不使用这个通用函数。DAX
直接将存储映射到地址空间,不使用常规的页缓存或影子条目,因此也无需操作。
- 对于普通的文件系统,这两个条件都为假。
获取锁:
- 函数需要获取两个锁来保证操作的原子性和安全性:
spin_lock(&mapping->host->i_lock)
: 获取inode
的i_lock
。这个锁保护了inode
的大部分元数据,包括LRU链表的状态。xas_lock_irq(&xas)
: 获取保护XArray树本身的内部锁,并禁用本地中断。这是必需的,因为页缓存可能会在中断上下文中被访问(例如,在缺页异常中)。
- 函数需要获取两个锁来保证操作的原子性和安全性:
核心遍历与清理 (
xas_for_each
):xas_for_each(&xas, folio, max)
: 这是一个XArray的遍历宏。它会从xas
当前的位置(start
)开始,一直迭代到max
索引。在每次循环中,folio
变量会被设置为当前索引处的条目。if (xa_is_value(folio))
: 在循环内部,检查当前条目是否是一个“值条目”(value entry),这是影子条目的通用术语。xas_store(&xas, NULL)
: 如果是影子条目,就调用xas_store
,将该索引位置的值更新为NULL
。这等同于从XArray中删除了这个影子条目。
释放锁与后续操作:
xas_unlock_irq(&xas)
和spin_unlock(&mapping->host->i_lock)
: 释放之前获取的锁。if (mapping_shrinkable(mapping)) inode_add_lru(mapping->host);
: 这是一个重要的后续步骤。当一个inode
的页缓存被修改后(即使只是清除了影子条目),它可能已经从“完全没有缓存”的状态变成了“有缓存但缓存为空”的状态。inode_add_lru
确保了这个inode
被正确地添加回内核的inode LRU链表中,以便在内存压力下可以被回收。
代码分析
1 | // clear_shadow_entries: 清理一个address_space中特定范围内的影子条目。 |
Folio驱逐核心:从页缓存中安全地移除一个干净页
本代码片段定义了 mapping_evict_folio
函数,它是页缓存清理功能(如invalidate_mapping_pages
)中负责决策和执行单个folio驱逐的核心工作者。其主要功能是进行一系列严格的安全检查,以确定一个folio当前是否处于“可安全驱逐”的状态。只有当所有条件都满足时,它才会真正地将该folio从文件的页缓存(address_space
)中移除,并最终释放其内存。
实现原理分析
mapping_evict_folio
的实现是一个多阶段的过滤器(filter),它会因为多种原因拒绝驱逐一个folio,以保证系统的正确性和数据一致性。
前提条件:
- 调用者负责锁定: 函数的文档明确指出,传入的
folio
必须已经被调用者锁定。这是防止在检查folio
状态的过程中,其状态被并发修改的关键前提。 - Mapping存在性检查:
if (!mapping)
。在folio
被锁定之前,它可能已经被截断(truncate
)操作从mapping
中移除了。这是一个必要的竞态条件检查。
- 调用者负责锁定: 函数的文档明确指出,传入的
核心安全检查 (过滤条件):
- 检查脏数据 (
folio_test_dirty
): 如果folio是“脏”的(dirty),意味着它包含了尚未写回磁盘的数据。此时驱逐它会导致数据丢失,因此绝对禁止。 - 检查回写状态 (
folio_test_writeback
): 如果folio正在被回写到磁盘的过程中,驱逐它会干扰I/O操作,可能导致数据损坏。 - 检查引用计数 (
folio_ref_count
): 这是最精妙的检查。- 一个“空闲”的folio(仅存在于页缓存中)的基准引用计数是
folio_nr_pages(folio)
(大型folio的每个子页都有一个引用) +folio_has_private(folio)
(如果有关联的buffer_head
,则加1) +1
(来自find_lock_entries
中find_get_entry
的临时引用)。 - 如果
folio_ref_count
大于这个基准值,就意味着有其他内核子系统正在使用这个folio。最常见的情况是:该folio的某个页面被一个或多个用户进程的页表映射着(即mmap
)。在这种情况下驱逐folio,会导致用户进程访问非法内存,引发段错误。
- 一个“空闲”的folio(仅存在于页缓存中)的基准引用计数是
filemap_release_folio
: 这个函数尝试释放与folio关联的一些额外资源,主要是buffer_head
。如果它返回false
,意味着这些资源由于某种原因不能被释放,驱逐也必须中止。
- 检查脏数据 (
执行驱逐 (
remove_mapping
):- 只有当一个folio通过了以上所有检查,即它是一个干净的、未被回写的、未被映射的、空闲的folio时,
mapping_evict_folio
才会调用remove_mapping
。 remove_mapping
(未在此处显示)是最终的执行者。它会原子地将该folio从address_space
的XArray中移除,并调用folio_put
来递减其引用计数。由于这个folio是空闲的,这次folio_put
通常会导致其引用计数降为零,从而触发__folio_put
函数,最终将物理内存返还给伙伴系统。
- 只有当一个folio通过了以上所有检查,即它是一个干净的、未被回写的、未被映射的、空闲的folio时,
代码分析
1 | /** |