[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) 系统调用(等同于openO_CREAT | O_WRONLY | O_TRUNC)。

核心原理与设计

它的核心工作原理是什么?

mm/truncate.c的工作核心是一个两阶段的、内存优先的清理过程,它与具体的文件系统驱动紧密协作。

当一个文件需要被截断到一个新的、更小的尺寸(new_size)时:

  1. VFS层发起:操作始于VFS层,通常是notify_change()函数检测到ATTR_SIZE变更。
  2. 文件系统预处理:VFS会首先调用文件系统特有的函数,通知它文件大小即将改变。文件系统此时会更新其内存中的inode大小 (i_size),并可能做一些准备工作。
  3. 内存截断 (mm/truncate.c的核心):VFS接着调用truncate_inode_pages_range()或类似的函数进入mm/truncate.c的逻辑。这个函数会:
    a. 遍历页面缓存:它会从新的文件末尾(new_size)开始,一直遍历到旧的文件末尾,查找所有属于这个范围的缓存页。
    b. 处理完全位于截断范围的页面:对于整个页面都在new_size之后的,逻辑很简单:
    * 如果页面是脏的,丢弃其数据(因为这部分数据已被删除),并清除其脏状态。
    * 从文件的页面缓存基数树(Radix Tree)中移除该页面。
    * 减少页面的引用计数,如果降到零,则释放该物理页。
    c. 处理跨越边界的页面:对于恰好包含new_size的那个页面,操作比较精细:
    * 页面中从new_size到页面末尾的这部分区域必须被清零。
    * 如果这个页面是脏的,它仍然保持脏状态,但后续写回时只会写回有效部分。
  4. 文件系统后处理:在页面缓存被清理干净后,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
2
3
4
5
6
7
8
// include/linux/pagevec.h
#define PAGEVEC_SIZE 14

struct pagevec {
unsigned short nr; // 当前向量中的页面数量
unsigned short cold; // (用于LRU) 标记页面是冷/热
struct page *pages[PAGEVEC_SIZE]; // 存放页面指针的数组
};

它的工作模式遵循一个清晰的**“收集-刷出”(Collect-and-Flush)**模式:

  1. 初始化:在使用前,通过pagevec_init()nr计数器清零。
  2. 收集页面:在一个循环中,通过pagevec_add(pvec, page)将需要处理的页面指针添加到pvec->pages数组中,并增加nr计数。
  3. 自动刷出pagevec_add()函数有一个关键的内置逻辑:如果添加页面后,nr达到了PAGEVEC_SIZE(即向量已满),它会自动调用一个批量处理函数(例如,__pagevec_lru_add_fn()),将当前向量中的所有页面一次性处理掉,然后自动将nr清零。
  4. 手动刷出:当收集页面的循环结束后,向量中可能还残留着一些未处理的页面(因为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 的实现非常简洁高效,本质上是一个轻量级的、基于数组的队列或集合。

  1. 数据结构 (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批次是否已被清空。
  2. 核心操作 (内联函数):

    • folio_batch_init(): 初始化一个批处理。它简单地将nri清零,表示这是一个空的批处理。这是使用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
  3. 生命周期与资源管理:

    • folio_batch_release(): 这是一个至关重要的函数。当一个folio被添加到folio_batch中时,它的引用计数通常是增加的。处理完一个批次后,必须调用folio_batch_release。这个函数(通过其工作者__folio_batch_release)会遍历批处理中的所有folio,并调用folio_put递减它们的引用计数。这确保了所有被临时引用的folio最终都能被正确释放,防止了内存泄漏。
  4. 特殊条目处理:

    • folio_batch_remove_exceptionals(): 页缓存等机制有时会在批处理中放入一些“异常”条目(例如,已经被其他任务解锁的folio)。这个函数负责遍历批处理,并将这些异常条目从中移除(通常是通过将后面的有效条目向前移动来覆盖它们),以保证后续操作只处理有效的folio。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
// ... (头文件保护和包含) ...

// 定义批处理的大小。31个指针加上头部成员,可以使结构体大小对齐到2的幂。
#define PAGEVEC_SIZE 31

struct folio;

// folio_batch: 一个folio的集合。
struct folio_batch {
unsigned char nr; // 当前批处理中的folio数量
unsigned char i; // 用于队列式处理的迭代游标
bool percpu_pvec_drained; // 用于per-cpu批处理的特殊标志
struct folio *folios[PAGEVEC_SIZE]; // 存储folio指针的核心数组
};

// folio_batch_init: 初始化一个folio批处理。
static inline void folio_batch_init(struct folio_batch *fbatch)
{
fbatch->nr = 0; // 数量清零
fbatch->i = 0; // 游标清零
fbatch->percpu_pvec_drained = false;
}

// folio_batch_reinit: 重新初始化一个批处理,不清空percpu标志。
static inline void folio_batch_reinit(struct folio_batch *fbatch)
{
fbatch->nr = 0;
fbatch->i = 0;
}

// folio_batch_count: 返回批处理中的folio数量。
static inline unsigned int folio_batch_count(struct folio_batch *fbatch)
{
return fbatch->nr;
}

// folio_batch_space: 返回批处理中剩余的可用空间。
static inline unsigned int folio_batch_space(struct folio_batch *fbatch)
{
return PAGEVEC_SIZE - fbatch->nr;
}

// folio_batch_add: 向批处理中添加一个folio。
static inline unsigned folio_batch_add(struct folio_batch *fbatch,
struct folio *folio)
{
// 将folio指针存入数组的下一个可用位置,并递增数量。
fbatch->folios[fbatch->nr++] = folio;
// 返回剩余空间。
return folio_batch_space(fbatch);
}

// folio_batch_next: 将批处理作为队列使用,返回下一个待处理的folio。
static inline struct folio *folio_batch_next(struct folio_batch *fbatch)
{
// 如果迭代游标已到达数量上限,说明队列已空。
if (fbatch->i == fbatch->nr)
return NULL;
// 返回当前游标指向的folio,并递增游标。
return fbatch->folios[fbatch->i++];
}

// 声明外部函数,用于释放批处理中所有folio的引用。
void __folio_batch_release(struct folio_batch *pvec);

// folio_batch_release: 释放一个批处理。
static inline void folio_batch_release(struct folio_batch *fbatch)
{
// 仅当批处理不为空时,才调用重量级的工作函数。
if (folio_batch_count(fbatch))
__folio_batch_release(fbatch);
}

// 声明外部函数,用于从批处理中移除异常条目。
void folio_batch_remove_exceptionals(struct folio_batch *fbatch);

mm/truncate.c

页缓存失效:从地址空间中移除干净的缓存页

本代码片段定义了Linux内存管理中一个关键的函数 invalidate_mapping_pages,它是在 drop_caches 功能中负责清理页缓存的核心执行者。其主要功能是遍历一个文件(由其address_space表示)的页缓存,并尝试移除所有干净的(clean)、未被锁定且未被映射到用户空间的缓存页(folios)。这是一个性能攸关的操作,它采用**批处理(batching)**的方式来高效地查找、锁定和处理页面,同时通过主动调度来避免长时间占用CPU。

实现原理分析

invalidate_mapping_pages 本身是一个简单的包装,其所有复杂逻辑都在 mapping_try_invalidate 函数中。该函数的设计体现了内核中常见的高性能、高并发处理模式。

  1. 批处理 (Batched Processing):

    • 函数的核心是一个while循环,但它不是一次处理一个页面。它使用一个folio_batch结构体,通过find_lock_entries函数一次性地从文件的页缓存(内部由XArray数据结构实现)中查找并锁定多达PAGEVEC_SIZE(通常是15个)个folio。
    • 为什么用批处理? 因为查找和锁定XArray本身是有开销的。一次性处理一批,可以**分摊(amortize)**这些开销,显著提高整体吞吐量。
  2. 核心循环 (The Main Loop):

    • while (find_lock_entries(...)): 这个循环会一直持续,直到find_lock_entries在指定的范围内再也找不到任何可处理的页缓存条目为止。
    • 查找并锁定: find_lock_entries负责在mapping中从index位置开始查找,它会跳过已经被锁定的条目,并对找到的条目尝试加锁。
    • 处理批次: 在while循环内部,一个for循环会遍历folio_batch中所有被成功找到并锁定的folio。
  3. 逐个驱逐 (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)**端,使其成为下一次内存回收时优先被考虑的对象。这是一种“我虽然不能马上扔掉你,但我可以把你放在垃圾桶旁边”的策略。
  4. 清理与协作 (cond_resched):

    • folio_batch_release: 在处理完一个批次后,必须调用此函数来释放folio_batch中所有folio的引用计数。
    • cond_resched: 这是保证系统响应性的生命线。清理一个大文件的页缓存可能是一个非常耗时的操作。如果在内核态长时间不间断地执行,会导致其他任务(包括高优先级的)得不到调度,造成系统“卡死”或触发软锁死(soft lockup)警告。cond_resched()会检查当前任务是否需要重新调度,如果是,它会主动让出CPU,让其他任务运行,然后再继续执行。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
/**
* mapping_try_invalidate - 尝试使一个inode的所有可驱逐folio无效
* @mapping: 持有待失效folio的address_space
* @start: 失效范围的起始偏移
* @end: 失效范围的结束偏移(包含)
* @nr_failed: 用于返回失效失败的folio数量的指针
*
* 此函数与invalidate_mapping_pages()类似,但它会返回无法驱逐的folio数量。
*/
unsigned long mapping_try_invalidate(struct address_space *mapping,
pgoff_t start, pgoff_t end, unsigned long *nr_failed)
{
pgoff_t indices[PAGEVEC_SIZE]; // 用于存储folio索引的临时数组
struct folio_batch fbatch; // folio批处理结构体
pgoff_t index = start; // 当前扫描的起始索引
unsigned long ret;
unsigned long count = 0; // 成功失效的条目计数
int i;

// 初始化folio批处理结构体
folio_batch_init(&fbatch);
// 主循环:只要还能在指定范围内找到并锁定一批页缓存条目...
while (find_lock_entries(mapping, &index, end, &fbatch, indices)) {
bool xa_has_values = false;
int nr = folio_batch_count(&fbatch); // 获取批处理中的条目数

// 遍历批处理中的每一个条目
for (i = 0; i < nr; i++) {
struct folio *folio = fbatch.folios[i];

// 检查是否为值条目(影子条目),而不是真正的folio。
if (xa_is_value(folio)) {
xa_has_values = true;
count++; // 影子条目也被视为成功“失效”。
continue;
}

// 尝试驱逐这个folio。
ret = mapping_evict_folio(mapping, folio);
// 无论成功与否,都必须解锁folio,与find_lock_entries配对。
folio_unlock(folio);

// 如果驱逐失败 (ret == 0)...
if (!ret) {
// ...将folio降级到非活跃LRU列表,使其更容易被回收。
deactivate_file_folio(folio);
// 如果调用者关心失败次数,则递增计数器。
if (nr_failed)
(*nr_failed)++;
}
// ret为1表示成功驱逐,累加到总数中。
count += ret;
}

// 如果批处理中包含了值条目,则需要专门清理它们。
if (xa_has_values)
clear_shadow_entries(mapping, indices[0], indices[nr-1]);

// 从批处理中移除那些特殊的、未被完全处理的folio。
folio_batch_remove_exceptionals(&fbatch);
// 释放批处理中所有folio的引用计数。
folio_batch_release(&fbatch);
// 主动让出CPU,防止长时间运行导致系统无响应。
cond_resched();
}
return count;
}

/**
* invalidate_mapping_pages - 使一个inode的所有干净、未锁定的缓存无效
* @mapping: 持有待失效缓存的address_space
* @start: 失效范围的起始偏移
* @end: 失效范围的结束偏移(包含)
*
* 返回值: 内容被成功失效的条目数量
*/
unsigned long invalidate_mapping_pages(struct address_space *mapping,
pgoff_t start, pgoff_t end)
{
// 这是一个简单的包装函数,直接调用核心实现,且不关心失败的次数。
return mapping_try_invalidate(mapping, start, end, NULL);
}
// 导出符号,使得内核其他部分(如文件系统驱动)可以调用此函数。
EXPORT_SYMBOL(invalidate_mapping_pages);

清理影子条目:维护页缓存(XArray)的一致性

本代码片段定义了 clear_shadow_entries 函数,它是页缓存管理的一个内部辅助函数。其核心功能是清理一个文件(由其address_space表示)的页缓存(内部由一个XArray数据结构实现)中特定范围内的影子条目(shadow entries)。影子条目是一种特殊的元数据标记,不代表真实的内存页,通常用来表示文件空洞(holes)或者标记某些页已被交换出去(swap)。当 invalidate_mapping_pages 等操作需要清理页缓存时,必须同时清理这些影子条目,以保证页缓存数据结构的一致性。

实现原理分析

clear_shadow_entries 的实现直接操作底层的XArray数据结构,这是一个高度优化的、用于存储稀疏索引指针的树状结构。

  1. 初始化XArray状态 (XA_STATE):

    • XA_STATE(xas, &mapping->i_pages, start): 这是一个宏,它在栈上初始化一个xas(XArray State)对象。这个对象是与XArray交互的“游标”或“句柄”,它包含了遍历和修改XArray所需的所有上下文信息。它被初始化为指向mapping->i_pages(即页缓存的XArray树)的start索引位置。
  2. 特殊文件系统处理:

    • if (shmem_mapping(mapping) || dax_mapping(mapping)): 函数首先检查是否为shmem(共享内存/tmpfs)或DAX(直接访问存储)。
      • shmem有自己独特的、更复杂的影子条目管理逻辑,因此不使用这个通用函数。
      • DAX直接将存储映射到地址空间,不使用常规的页缓存或影子条目,因此也无需操作。
    • 对于普通的文件系统,这两个条件都为假。
  3. 获取锁:

    • 函数需要获取两个锁来保证操作的原子性和安全性:
      • spin_lock(&mapping->host->i_lock): 获取inodei_lock。这个锁保护了inode的大部分元数据,包括LRU链表的状态。
      • xas_lock_irq(&xas): 获取保护XArray树本身的内部锁,并禁用本地中断。这是必需的,因为页缓存可能会在中断上下文中被访问(例如,在缺页异常中)。
  4. 核心遍历与清理 (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中删除了这个影子条目。
  5. 释放锁与后续操作:

    • 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
// clear_shadow_entries: 清理一个address_space中特定范围内的影子条目。
static void clear_shadow_entries(struct address_space *mapping,
unsigned long start, unsigned long max)
{
// 初始化一个XArray状态对象(xas),作为遍历和修改的句柄。
XA_STATE(xas, &mapping->i_pages, start);
struct folio *folio;

// 特殊文件系统处理:shmem有自己的逻辑,DAX不需要,直接返回。
if (shmem_mapping(mapping) || dax_mapping(mapping))
return;

// 配置xas,以便在更新XArray节点时,同时更新工作集信息。
xas_set_update(&xas, workingset_update_node);

// 获取inode锁,保护inode元数据。
spin_lock(&mapping->host->i_lock);
// 获取XArray内部锁,并禁用中断,以安全地修改XArray树。
xas_lock_irq(&xas);

// 核心循环:从start到max,遍历XArray中的每一个条目。
xas_for_each(&xas, folio, max) {
// 检查当前条目是否为值条目/影子条目。
if (xa_is_value(folio))
// 如果是,就将其替换为NULL,即删除该条目。
xas_store(&xas, NULL);
}

// 释放XArray锁和中断。
xas_unlock_irq(&xas);
// 如果这个inode是可收缩的(通常是的)...
if (mapping_shrinkable(mapping))
// ...将其添加到inode LRU链表中,以便在内存压力下可以被回收。
inode_add_lru(mapping->host);
// 释放inode锁。
spin_unlock(&mapping->host->i_lock);
}

Folio驱逐核心:从页缓存中安全地移除一个干净页

本代码片段定义了 mapping_evict_folio 函数,它是页缓存清理功能(如invalidate_mapping_pages)中负责决策和执行单个folio驱逐的核心工作者。其主要功能是进行一系列严格的安全检查,以确定一个folio当前是否处于“可安全驱逐”的状态。只有当所有条件都满足时,它才会真正地将该folio从文件的页缓存(address_space)中移除,并最终释放其内存。

实现原理分析

mapping_evict_folio 的实现是一个多阶段的过滤器(filter),它会因为多种原因拒绝驱逐一个folio,以保证系统的正确性和数据一致性。

  1. 前提条件:

    • 调用者负责锁定: 函数的文档明确指出,传入的folio必须已经被调用者锁定。这是防止在检查folio状态的过程中,其状态被并发修改的关键前提。
    • Mapping存在性检查: if (!mapping)。在folio被锁定之前,它可能已经被截断(truncate)操作从mapping中移除了。这是一个必要的竞态条件检查。
  2. 核心安全检查 (过滤条件):

    • 检查脏数据 (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_entriesfind_get_entry的临时引用)。
      • 如果folio_ref_count大于这个基准值,就意味着有其他内核子系统正在使用这个folio。最常见的情况是:该folio的某个页面被一个或多个用户进程的页表映射着(即mmap)。在这种情况下驱逐folio,会导致用户进程访问非法内存,引发段错误。
    • filemap_release_folio: 这个函数尝试释放与folio关联的一些额外资源,主要是buffer_head。如果它返回false,意味着这些资源由于某种原因不能被释放,驱逐也必须中止。
  3. 执行驱逐 (remove_mapping):

    • 只有当一个folio通过了以上所有检查,即它是一个干净的、未被回写的、未被映射的、空闲的folio时,mapping_evict_folio才会调用remove_mapping
    • remove_mapping(未在此处显示)是最终的执行者。它会原子地将该folio从address_space的XArray中移除,并调用folio_put来递减其引用计数。由于这个folio是空闲的,这次folio_put通常会导致其引用计数降为零,从而触发__folio_put函数,最终将物理内存返还给伙伴系统。

代码分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
/**
* mapping_evict_folio() - 从页缓存中移除一个未使用的folio。
* @mapping: 这个folio所属的address_space。
* @folio: 要移除的folio。
*
* 安全地从页缓存中移除一个folio。
* 它只丢弃干净的、未使用的folio。
*
* 上下文: Folio必须已被锁定。
* 返回值: 成功移除的页面数量。
*/
long mapping_evict_folio(struct address_space *mapping, struct folio *folio)
{
// 检查1: 文件是否在锁定folio之前被截断?如果是,mapping会是NULL。
if (!mapping)
return 0;
// 检查2: folio是否是脏的或正在被回写?如果是,则不能驱逐,否则会丢失数据。
if (folio_test_dirty(folio) || folio_test_writeback(folio))
return 0;
// 检查3: 引用计数是否过高?
// 如果大于基准值,说明有其他用户(如用户进程的mmap),不能驱逐。
if (folio_ref_count(folio) >
folio_nr_pages(folio) + folio_has_private(folio) + 1)
return 0;
// 检查4: 尝试释放与folio关联的底层资源(如buffer_heads)。
// 如果失败,则不能驱逐。
if (!filemap_release_folio(folio, 0))
return 0;

// 所有检查通过,调用核心函数将folio从mapping的XArray中移除。
return remove_mapping(mapping, folio);
}