[TOC]

mm/filemap.c: Linux 页缓存 (Page Cache) 的心脏

mm/filemap.c 是 Linux 内核中实现和管理页缓存 (Page Cache) 的核心源文件。页缓存是 Linux I/O 性能的基石,它将磁盘上的文件内容缓存到物理内存(RAM)中,使得后续对同一文件的读写操作可以直接在内存中完成,从而避免了缓慢的磁盘 I/O。

可以把 mm/filemap.c 想象成一个高效的“图书管理员”,它负责管理一个巨大的图书馆(页缓存),图书馆里的每一页书(struct page)都对应着磁盘文件上的某一页内容。


一、 核心职责

mm/filemap.c 的代码几乎参与了所有与文件 I/O 相关的内存操作,其核心职责包括:

  1. 页缓存的查找与插入 (Finding and Inserting): 当需要读取文件数据时,它负责在页缓存中查找是否已缓存了对应的页面。如果找到(Cache Hit),则直接返回内存页;如果未找到(Cache Miss),则负责分配一个新的物理页,并将其插入到页缓存中,准备从磁盘加载数据。

  2. 数据的读取与写入 (Reading and Writing):

    • 读操作: 在 Cache Miss 的情况下,它负责调用底层文件系统的 readpagereadpages 操作,将数据从磁盘读入到新分配的内存页中。
    • 写操作: 当用户写入数据时,它负责将数据写入到页缓存中,并将该页标记为脏页 (Dirty)。它并不立即将数据写回磁盘,而是依赖于 backing-dev 机制进行后续的异步回写(Writeback)。
  3. 预读 (Read-Ahead): 为了提高顺序读性能,mm/filemap.c 实现了复杂的预读算法。当它检测到进程正在顺序读取文件时,它会主动、提前地将文件后续的内容异步读入页缓存中。这样,当进程真正需要这些数据时,它们很可能已经在内存里了。

  4. 页面截断与清理 (Truncation and Invalidation): 当文件被截断(变小)或删除时,mm/filemap.c 负责从页缓存中找到并移除所有属于该文件无效部分的缓存页,并确保脏页被正确处理。

  5. 内存回收接口: 当系统内存不足时,内存回收机制(kswapd 内核线程)会扫描页缓存。mm/filemap.c 提供了判断一个缓存页是否可以被回收的接口。如果页面是干净的(Clean),就可以直接回收;如果是脏的(Dirty),则必须先将其写回磁盘才能回收。


二、 核心数据结构与机制

mm/filemap.c 的所有操作都围绕着两个核心数据结构展开:

  1. struct address_space:

    • 可以把它看作是页缓存中一个文件的“索引卡片”。每个被缓存的文件(由 struct inode 表示)都有一个与之关联的 address_space 对象。
    • 它包含了管理该文件所有缓存页所需的信息,其中最重要的是一个基数树 (Radix Tree)XArray(一种更现代的数据结构)的根指针 (page_tree)。
    • page_tree: 这个树形数据结构是页缓存的核心索引机制。它以文件内的页面偏移量(page offset)为键(key),以指向对应 struct page 的指针为值(value)。这使得内核可以以 O(logN) 的时间复杂度快速地根据文件偏移量找到或插入一个缓存页。
  2. struct page:

    • 代表一个物理页帧。当一个 page 被用于页缓存时,它的几个字段会被赋予特殊的含义:
    • page->mapping: 指向它所属的 address_space
    • page->index: 存储了它在文件内的页面偏移量。
    • page->flags: 包含了丰富的状态位,如 PG_dirty (脏页), PG_uptodate (数据已从磁盘加载且有效), PG_writeback (正在被回写) 等。

address_space_operations:

  • struct address_space 中包含一个指向 address_space_operations 结构体的指针。这是一个函数指针表,定义了页缓存如何与底层文件系统进行交互。
  • 它扮演着抽象层的角色,使得 mm/filemap.c 的通用逻辑可以与任何具体的文件系统(ext4, XFS, NFS等)协同工作。
  • 关键操作包括:
    • .readpage() / .readpages(): 当 Cache Miss 发生时,filemap.c 调用此函数,要求文件系统从磁盘读取一页或多页数据。
    • .writepage() / .writepages(): 当回写线程需要将脏页写回磁盘时,调用此函数。
    • .set_page_dirty(): 通知文件系统一个页面变脏了,文件系统可以借此机会更新日志(Journaling)。

三、 关键源码函数解析

  • find_or_create_page() / pagecache_get_page():

    • 这是页缓存的核心查找/创建函数
    • 它首先在 address_space 的基数树中根据文件偏移 index 查找页面。
    • 如果找到,就增加页面的引用计数并返回(Cache Hit)。
    • 如果找不到,它会分配一个新的 page,将其插入到基数树中,然后返回这个新页面,并将其锁定,准备进行 I/O 操作(Cache Miss)。
  • generic_file_read_iter():

    • 这是文件读操作的通用实现,被大多数文件系统直接使用。
    • 它循环处理用户的读请求,对每一页:
      1. 调用 pagecache_get_page() 查找或创建缓存页。
      2. 如果页面不在内存中 (!PageUptodate),则调用 a_ops->readpage() 从磁盘加载数据。
      3. 等待 I/O 完成。
      4. 将页缓存中的数据拷贝到用户的缓冲区。
  • generic_perform_write():

    • 这是文件写操作的通用实现。
    • 它将用户缓冲区的数据拷贝到页缓存中,并调用 set_page_dirty() 将页面标记为脏页。脏页的处理则交由 backing-dev 机制负责。
  • do_readahead():

    • 实现预读逻辑的函数。它会根据当前的读取模式和上下文,决定是否以及预读多少个页面,然后异步地发起 I/O 请求。
  • truncate_inode_pages_range():

    • 实现文件截断的函数。它会扫描基数树,找到并移除指定范围内的所有缓存页。

四、 场景贯穿:第一次读取一个文件

  1. 系统调用: 应用程序调用 read() 系统调用。
  2. VFS 层: VFS 层找到对应的 struct filestruct inode,最终调用 generic_file_read_iter()
  3. mm/filemap.c 介入: generic_file_read_iter() 开始执行。假设要读取文件的前 4KB (offset 0)。
  4. 查找页缓存: 它调用 pagecache_get_page(inode->i_mapping, 0)。由于是第一次读取,基数树中没有索引为 0 的页面,发生 Cache Miss
  5. 分配与插入: pagecache_get_page 会分配一个新的 struct page,并将其插入到 inode->i_mapping->page_tree 中,索引为 0。此时页面是锁定的,且没有 Uptodate 标志。
  6. 调用文件系统: generic_file_read_iter 发现页面不是 Uptodate,于是调用 inode->i_mapping->a_ops->readpage(file, page)
  7. 磁盘 I/O: ext4 等文件系统收到请求,计算出文件 offset 0 对应的磁盘逻辑块地址,然后构建 bio 提交给块设备层,发起磁盘读操作。
  8. I/O 完成: 当 DMA 操作完成,数据从磁盘读入到 page 对应的物理内存后,块设备层通过回调函数通知上层。
  9. 更新状态: I/O 完成处理函数会设置 PageUptodate 标志,并解锁页面。
  10. 唤醒进程与拷贝: 等待 I/O 的进程被唤醒。generic_file_read_iter 确认页面已 Uptodate,然后调用 copy_to_user()page 中的数据拷贝到应用程序的缓冲区。
  11. 预读触发: 在此过程中,filemap.c 的预读逻辑可能会被激活,它会发现这是一个顺序读,于是异步地为文件的 offset 1, 2, 3… 发起 readpage 请求。

五、 总结

mm/filemap.c 是 Linux 高性能 I/O 的核心引擎。它通过页缓存机制,将频繁访问的文件数据保留在高速的 RAM 中,极大地减少了对慢速磁盘的依赖。

其关键设计思想在于:

  • 通用性: 通过 address_spaceaddress_space_operations 的抽象,提供了一套适用于所有文件系统的、统一的缓存管理框架。
  • 高效索引: 利用基数树或 XArray,实现了对海量缓存页的快速查找和管理。
  • 异步操作: 读操作的预读和写操作的延迟回写都是异步的,最大化了系统吞吐量和响应性。

理解 mm/filemap.c 的工作原理,是理解 Linux 文件系统性能、内存使用情况以及如何进行 I/O 性能调优的根本。

include/linux/pagemap.h

mapping_shrinkable 检查页面缓存(page cache)的状态,以确定是否允许回收 inode

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
/*
* mapping_shrinkable - 测试页面缓存状态是否允许 inode 回收
* @mapping:页面缓存映射
*
* 这将检查映射的缓存状态,以了解 inode 回收和 LRU 管理。
*
* 调用方应持有 i_lock,但不需要持有 i_pages 锁,这通常可以保护缓存状态。这是因为保护 inode 及其 LRU 状态的 i_lock 和 list_lru 锁不会嵌套在 irq 安全的 i_pages 锁内。
*
* 缓存删除在 i_lock 下执行,这可确保当 inode 为空时,它将可靠地在 LRU 上排队。
*
* 缓存添加不会获得 i_lock,并且可能会与此检查竞争,在这种情况下,当 inode 具有缓存页时,我们会将 inode 报告为可收缩。这没关系: shrinker 还会检查 refcount 和引用的位,在向 inode 添加新缓存页的过程中,它们将被提升或设置。
*/
static inline bool mapping_shrinkable(struct address_space *mapping)
{
void *head;

/*
*在 highmem 系统上,在页面高速缓存出现 highmem 压力之前,inode 可能会有 lowmem 压力。使 inode 可收缩,而不管缓存状态如何。
*/
if (IS_ENABLED(CONFIG_HIGHMEM))
return true;

/* 检查缓存是否为空
说明没有页面与 inode 关联,可以安全地回收 inode*/
head = rcu_access_pointer(mapping->i_pages.xa_head);
if (!head)
return true;

/*
* 如果 xa_head 存储的是非驻留页面缓存条目(例如单个偏移量为 0 的条目),函数返回 true
*/
if (!xa_is_node(head) && xa_is_value(head))
return true;

return false;
}

filemap_fdatawait 等待写回完成

1
2
3
4
static inline int filemap_fdatawait(struct address_space *mapping)
{
return filemap_fdatawait_range(mapping, 0, LLONG_MAX);
}

Folio状态检查与锁定:内存页操作的基础原语

本代码片段定义了两个基础的、性能关键的内联函数,它们是Linux内核中对 folio 进行操作的基础构建块。

  • folio_trylock: 提供了一个非阻塞的方式来尝试锁定一个folio。这是高性能批处理和避免死锁的关键工具。
  • folio_contains: 提供了一个快速的检查,用于判断一个给定的文件页索引是否落在一个(可能由多个页组成的)大型folio的覆盖范围内。

实现原理分析

folio_trylock

  1. 核心原子操作 (test_and_set_bit_lock):

    • 这个函数是 folio_trylock 的心脏。test_and_set_bit_lock 是一个原子的“测试并设置”操作。它会执行以下三个步骤,且整个过程不可被中断:
      a. 测试 (Test): 检查 PG_locked 位(folio标志位中的一个)的当前值。
      b. 设置 (Set): 无论当前值是多少,都将该位设置为1。
      c. 返回 (Return): 返回该位在设置之前的旧值。
    • _lock 后缀表示这个原子操作还附带了“获取锁”的内存屏障语义,可以防止后续的内存访问被乱序到它前面。
  2. 逻辑判断:

    • 如果 PG_locked 位原来是0(未锁定),test_and_set_bit_lock会返回0。!0 的结果是 true,表示成功获取了锁
    • 如果 PG_locked 位原来是1(已被锁定),test_and_set_bit_lock会返回1。!1 的结果是 false,表示获取锁失败,因为已经有别人持有了锁。
    • likely(): 这是一个编译器提示,告诉编译器 test_and_set_bit_lock 返回0(即成功获取锁)是最可能发生的情况。这允许编译器优化指令流水线,将更可能执行的分支放在更优的位置。

folio_contains

  1. 核心逻辑:

    • index - folio->index: 计算给定的文件页索引 index 相对于 folio 的起始索引 folio->index 的偏移量。
    • < folio_nr_pages(folio): 检查这个偏移量是否小于 folio 包含的总页面数。
    • 示例: 一个大型folio的index是10,nr_pages是4。它覆盖了索引10, 11, 12, 13。如果传入的index是12,那么 12 - 10 = 22 < 4 为真,函数返回true。如果传入的index是14,14 - 10 = 44 < 4 为假,函数返回false
  2. 调试断言:

    • VM_WARN_ON_ONCE_FOLIO(folio_test_swapcache(folio), folio): 这是一个调试用的断言。它检查这个folio是否意外地处于swapcache中。folio_contains的调用者被要求确保folio不处于这种状态,因为在这种状态下,folio的index字段的含义可能会改变。如果断言触发,它会在内核日志中打印一次警告,帮助开发者捕获逻辑错误。

代码分析

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
/**
* folio_trylock() - 尝试锁定一个folio。
* @folio: 要尝试锁定的folio。
*
* 通常情况下,folio_lock()是正确的选择。但有时我们不希望等待
* folio解锁(例如,当锁的获取顺序不正确,或者处理一批folio的
* 进度比按顺序处理它们更重要时)。
*
* 上下文: 任何上下文。
* 返回值: 是否成功获取了锁。
*/
static inline bool folio_trylock(struct folio *folio)
{
// test_and_set_bit_lock原子地测试并设置PG_locked位,返回其旧值。
// 如果旧值是0(未锁定),!0为true,表示成功。
// 如果旧值是1(已锁定),!1为false,表示失败。
// likely()提示编译器成功路径是大概率事件。
return likely(!test_and_set_bit_lock(PG_locked, folio_flags(folio, 0)));
}

/**
* folio_contains - 这个folio是否包含这个索引?
* @folio: The folio.
* @index: 文件内的页索引。
*
* 上下文: 调用者应已锁定folio,并确保(例如)shmem没有将此folio
* 移到交换缓存中。
* 返回值: true 或 false。
*/
static inline bool folio_contains(struct folio *folio, pgoff_t index)
{
// 调试断言:如果folio在swapcache中,则发出警告。
VM_WARN_ON_ONCE_FOLIO(folio_test_swapcache(folio), folio);
// 核心逻辑:计算索引相对于folio起始的偏移量,
// 并检查它是否小于folio包含的总页面数。
return index - folio->index < folio_nr_pages(folio);
}

include/linux/mm.h

folio_nr_pages 计算一个 folio(页面的抽象表示)中包含的页面数量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
* folio_nr_pages - 作品集中的页数。
* @folio:对开页。
*
* 返回值:2 的正数幂。
*/
static inline long folio_nr_pages(const struct folio *folio)
{
/* 检查 folio 是否为普通页面 */
if (!folio_test_large(folio))
/* 如果 folio 是普通页面,直接返回 1,表示该 folio 仅包含一个页面 */
return 1;
return folio_large_nr_pages(folio);
}

mm/filemap.c

find_get_entry 在 xa_state(XArray 状态)中查找与指定条件匹配的 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
static inline struct folio *find_get_entry(struct xa_state *xas, pgoff_t max,
xa_mark_t mark)
{
struct folio *folio;

retry:
/* XA_PRESENT 标记通常用于筛选或查找那些实际存在的条目,而不考虑其他状态或标记。 */
if (mark == XA_PRESENT)
folio = xas_find(xas, max);
else
/* 查找具有指定标记的页面。 */
folio = xas_find_marked(xas, max, mark);

/* 查找操作需要重试(例如由于并发修改导致的不一致性 */
if (xas_retry(xas, folio))
goto retry;
/*
* 找到的条目是 NULL 或表示特殊值(例如影子条目、交换条目或 DAX 条目),直接返回该条目而不尝试增加引用计数.
*/
if (!folio || xa_is_value(folio))
return folio;

/* 尝试增加 folio 的引用计数 */
if (!folio_try_get(folio))
goto reset;
/* 重新加载 xas 状态,确保返回的 folio 与当前状态一致 */
if (unlikely(folio != xas_reload(xas))) {
/* 释放 folio 的引用计数并 */
folio_put(folio);
goto reset;
}

return folio;
reset:
/* 调用 xas_reset 重置 xas 状态,清除可能的错误状态并准备重新查找。 */
xas_reset(xas);
goto retry;
}

filemap_get_folios_tag 从指定的地址空间(address_space)中获取一批与特定标签(tag)匹配的 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
/**
* filemap_get_folios_tag - 获取一批与 @tag 匹配的作品集
* @mapping:搜索address_space
* @start:起始页索引
* @end:最终页面索引(含)
* @tag:标签索引
* @fbatch:要填充的批次
*
* 第一个对开页可以在 @start 之前开始;如果是这样,它将包含 @start。 最终对开页可能会延伸到 @end 之外;如果是,它将包含 @end。 对开页具有升序索引。 如果页面缓存中存在没有作品集的索引,则作品集之间可能存在间隙。 如果在运行页面缓存时将作品集添加到页面缓存或从页面缓存中删除作品集,则此调用可能会找到也可能找不到它们。仅返回使用 @tag 标记的作品集。
*
* 返回:找到的作品集数。
* 同时更新@start以索引下一个作品集以进行遍历。
*/
unsigned filemap_get_folios_tag(struct address_space *mapping, pgoff_t *start,
pgoff_t end, xa_mark_t tag, struct folio_batch *fbatch)
{
/* 使用 XA_STATE 初始化遍历状态(xas) */
XA_STATE(xas, &mapping->i_pages, *start);
struct folio *folio;

rcu_read_lock();
/* 查找与标签(tag)匹配的 folio */
while ((folio = find_get_entry(&xas, end, tag)) != NULL) {
/*
* 影子条目永远不应该被标记,但这个迭代是无锁的,所以有一个页面回收窗口来驱逐我们看到的被标记的页面。跳过它。
*/
/* 找到的条目是影子条目 */
if (xa_is_value(folio))
continue;
/* 添加到批处理结构(fbatch)中。如果批处理已满,更新起始索引并退出循环 */
if (!folio_batch_add(fbatch, folio)) {
unsigned long nr = folio_nr_pages(folio);
*start = folio->index + nr;
goto out;
}
}
/*
* 如果遍历到范围的末尾,更新起始索引以指向下一个条目。
* 特别处理索引溢出的情况,避免影响调用者。
*/
if (end == (pgoff_t)-1)
*start = (pgoff_t)-1;
else
*start = end + 1;
out:
rcu_read_unlock();
/* 返回批处理结构中找到的 folio 数量 */
return folio_batch_count(fbatch);
}
EXPORT_SYMBOL(filemap_get_folios_tag);

__filemap_fdatawait_range 用于等待指定地址空间(address_space)中某个范围内的页面完成写回操作

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
static void __filemap_fdatawait_range(struct address_space *mapping,
loff_t start_byte, loff_t end_byte)
{
pgoff_t index = start_byte >> PAGE_SHIFT;
pgoff_t end = end_byte >> PAGE_SHIFT;
struct folio_batch fbatch;
unsigned nr_folios;

folio_batch_init(&fbatch);

while (index <= end) {
unsigned i;
/* 获取指定范围内标记为写回状态的页面 */
nr_folios = filemap_get_folios_tag(mapping, &index, end,
PAGECACHE_TAG_WRITEBACK, &fbatch);

if (!nr_folios)
break;

for (i = 0; i < nr_folios; i++) {
struct folio *folio = fbatch.folios[i];
/* 阻塞并等待写回操作完成 */
folio_wait_writeback(folio);
}
/* 释放批处理结构中的资源 */
folio_batch_release(&fbatch);
/* 检查是否需要调度其他任务,避免长时间占用 CPU */
cond_resched();
}
}

filemap_check_errors 用于检查 address_space(地址空间)结构中是否存在未处理的写入错误,并返回相应的错误代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
int filemap_check_errors(struct address_space *mapping)
{
int ret = 0;
/* 检查空间不足错误 */
/* 检查地址空间的标志位 AS_ENOSPC 是否被设置,表示写入操作因空间不足而失败 */
if (test_bit(AS_ENOSPC, &mapping->flags) &&
/* 清除该标志位,并将返回值设置为 -ENOSPC(空间不足错误) */
test_and_clear_bit(AS_ENOSPC, &mapping->flags))
ret = -ENOSPC;
/* 检查 I/O 错误 */
if (test_bit(AS_EIO, &mapping->flags) &&
test_and_clear_bit(AS_EIO, &mapping->flags))
ret = -EIO;
return ret;
}
EXPORT_SYMBOL(filemap_check_errors);

folio_waitqueue 用于获取与 folio(页面的抽象表示)相关联的等待队列

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
/*
* 为了等待页面可用,必须有与页面关联的等待队列。
* 通过使用 waitqueues 的哈希表,其中存储桶规则是维护同一队列中的所有 waiter,
* 并在任何页面可用时唤醒所有 waiter,并让唤醒的上下文检查以确保适当的页面可用,
* 这可以节省空间,但代价是在罕见的哈希冲突期间出现 “thundering herd” 现象。

Thundering Herd(雷鸣般的群体效应) 是一种性能问题,通常发生在多线程或多进程环境中。当多个线程或进程同时等待某个资源(如锁、信号或事件)时,一旦资源可用,所有等待的线程或进程会同时被唤醒,导致系统负载激增或竞争加剧。这种现象可能导致性能下降,甚至系统不稳定Thundering Herd(雷鸣般的群体效应) 是一种性能问题,通常发生在多线程或多进程环境中。当多个线程或进程同时等待某个资源(如锁、信号或事件)时,一旦资源可用,所有等待的线程或进程会同时被唤醒,导致系统负载激增或竞争加剧。这种现象可能导致性能下降,甚至系统不稳定
*/
#define PAGE_WAIT_TABLE_BITS 8
#define PAGE_WAIT_TABLE_SIZE (1 << PAGE_WAIT_TABLE_BITS)
static wait_queue_head_t folio_wait_table[PAGE_WAIT_TABLE_SIZE] __cacheline_aligned;

static wait_queue_head_t *folio_waitqueue(struct folio *folio)
{
return &folio_wait_table[hash_ptr(folio, PAGE_WAIT_TABLE_BITS)];
}

wake_page_function 处理等待队列中的唤醒逻辑

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
/*
* 页面等待代码对 “wait->flags” 的处理有些不同寻常,因为我们有多种不同类型的等待,而不仅仅是通常的 “exclusive” 等待。
*
*我们有:
*
* (a) 没有特殊位设置:
*
* 我们只是在等待 bit 被释放,当唤醒器调用 wakeup 函数时,我们设置 WQ_FLAG_WOKEN 并将其唤醒,并将其从 await 队列中删除。
*
* 简单明了。
*
* (b) WQ_FLAG_EXCLUSIVE:
*
* 服务员正在等待取锁,并且只应叫醒一名服务员,以避免任何雷霆万钧的羊群行为。我们将设置 WQ_FLAG_WOKEN 位,将其唤醒,并将其从 await 队列中删除。
*
* 这是传统的专属等待。
*
* (c) WQ_FLAG_EXCLUSIVE |WQ_FLAG_CUSTOM:
*
* waiter 正在等待获取位,并且还希望将锁转移到它以实现公平的锁定行为。如果无法取锁,我们将停止遍历等待队列,而不会唤醒 Waiter。
*
* 这是“公平锁交接”的情况,除了设置WQ_FLAG_WOKEN外,我们还设置WQ_FLAG_DONE让服务员轻松看到它现在有锁。
*/
static int wake_page_function(wait_queue_entry_t *wait, unsigned mode, int sync, void *arg)
{
unsigned int flags;
struct wait_page_key *key = arg;
struct wait_page_queue *wait_page
= container_of(wait, struct wait_page_queue, wait);

/* 检查当前等待条目是否匹配唤醒条件 */
if (!wake_page_match(wait_page, key))
return 0;

/*
* 等待类型为独占或公平锁移交,函数尝试获取锁
*/
flags = wait->flags;
if (flags & WQ_FLAG_EXCLUSIVE) {
/* 如果锁无法获取,函数停止队列遍历并返回 -1 */
if (test_bit(key->bit_nr, &key->folio->flags))
return -1;
if (flags & WQ_FLAG_CUSTOM) {
if (test_and_set_bit(key->bit_nr, &key->folio->flags))
return -1;
flags |= WQ_FLAG_DONE;
}
}

/*
* 我们持有等待队列锁,但等待此锁的 Waiter 将检查标志,而不会被锁定。
*
* 因此,原子地更新标志,并在之后唤醒 Waiter 以避免任何竞争。此 store-release 与 folio_wait_bit_common() 中的 load-acquire 配对。
*/
smp_store_release(&wait->flags, flags | WQ_FLAG_WOKEN);
/* 唤醒处于特定状态的线程 */
wake_up_state(wait->private, mode);

/*
* 好了,我们已经成功完成了我们正在等待的工作,我们可以无条件地删除等待条目。
*
* 请注意,这与 Waiter 中的 “finish_wait()” 配对,并且必须是我们绝对最后要做的事情。在此 list_del_init(&wait->entry) 之后,等待条目可能会被取消分配,进程甚至可能已退出。
*/
list_del_init_careful(&wait->entry);
/* 如果等待类型为独占,返回非零值;否则返回 0。 */
return (flags & WQ_FLAG_EXCLUSIVE) != 0;
}

folio_wait_bit_common 等待 folio(页面的抽象表示)上的特定标志位(bit_nr)被清除

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
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
static inline int folio_wait_bit_common(struct folio *folio, int bit_nr,
int state, enum behavior behavior)
{
/* 获取 folio 的等待队列,并初始化等待条目。 */
wait_queue_head_t *q = folio_waitqueue(folio);
int unfairness = sysctl_page_lock_unfairness;
struct wait_page_queue wait_page;
wait_queue_entry_t *wait = &wait_page.wait;
bool thrashing = false;
unsigned long pflags;
bool in_thrashing;

/* 如果页面处于工作集状态且未更新,函数进入页面抖动处理逻辑。
页面抖动通常表示频繁的页面交换或写回,可能影响性能 */
if (bit_nr == PG_locked &&
!folio_test_uptodate(folio) && folio_test_workingset(folio)) {
delayacct_thrashing_start(&in_thrashing);
psi_memstall_enter(&pflags);
thrashing = true;
}

init_wait(wait);
/* 设置等待条目的回调函数 wake_page_function,用于处理唤醒逻辑 */
wait->func = wake_page_function;
wait_page.folio = folio;
wait_page.bit_nr = bit_nr;

repeat:
wait->flags = 0;
if (behavior == EXCLUSIVE) {
wait->flags = WQ_FLAG_EXCLUSIVE;
if (--unfairness < 0)
wait->flags |= WQ_FLAG_CUSTOM;
}

/*
* 最后检查我们是否可以同步获取 page bit。
*
* 在此之前做 folio_set_waiters() 标记,让任何我们 _刚刚_ 错过的唤醒者知道他们需要唤醒我们(否则他们甚至永远不会进入查看页面队列的慢速情况),如果我们需要睡眠,请将我们自己添加到等待队列中。
*
* 这部分需要在队列锁下完成,以避免 races。
*/
spin_lock_irq(&q->lock);
/* 标记页面有等待者,确保唤醒逻辑能够正确处理 */
folio_set_waiters(folio);
/* 尝试获取标志位 */
if (!folio_trylock_flag(folio, bit_nr, wait))
/* 当前线程添加到等待队列 */
__add_wait_queue_entry_tail(q, wait);
spin_unlock_irq(&q->lock);

/*
* 从现在开始,所有的 logic 都将基于 WQ_FLAG_WOKEN 和 WQ_FLAG_DONE 标志,
* 以查看 wake 函数是否已经完成了 page bit 测试。
*
* 我们可以删除对作品集的引用。
*/
if (behavior == DROP)
folio_put(folio);

/*
* 请注意,在 “finish_wait()” 之前,或者直到我们看到 WQ_FLAG_WOKEN 标志之前,我们需要非常小心 'wait->flags',因为我们可能会与设置它们的唤醒器进行比赛。
*/
for (;;) {
unsigned int flags;

set_current_state(state);

/* 循环,直到我们被唤醒或中断 */
/* 进入睡眠状态,等待标志位清除或信号中断 */
flags = smp_load_acquire(&wait->flags);
if (!(flags & WQ_FLAG_WOKEN)) {
if (signal_pending_state(state, current))
break;
/* io_schedule 进行 I/O 调度,避免长时间占用 CPU */
io_schedule();
continue;
}

/* If we were non-exclusive, we're done */
if (behavior != EXCLUSIVE)
break;

/* If the waker got the lock for us, we're done */
if (flags & WQ_FLAG_DONE)
break;

/*
* 否则,如果我们要获得锁,则需要尝试自己获取它。
*
* 如果失败,我们将不得不重试所有这些作。
*/
if (unlikely(test_and_set_bit(bit_nr, folio_flags(folio, 0))))
goto repeat;

wait->flags |= WQ_FLAG_DONE;
break;
}

/*
* 如果发生信号,则此“finish_wait()”可能会从等待队列中删除最后一个服务员,但 folio 服务员位将保持设置状态。没关系。下一次醒来会照顾它,尝试在这里这样做会很困难并且容易发生比赛。
*/
/* 从等待队列中移除当前线程,清理等待状态 */
finish_wait(q, wait);

if (thrashing) {
delayacct_thrashing_end(&in_thrashing);
psi_memstall_leave(&pflags);
}

/*
*注意!在我们完成 'finish_wait()' 之前, wait->标志并不稳定,我们可能由于信号而退出了上面的循环,并在信号测试之后但在 'finish_wait()' 之前发生了唤醒事件。
*
* 因此,只有在 finish_wait() 之后,我们才能可靠地确定我们是否被唤醒,因此我们现在可以根据该状态计算出最终的返回值,而无需进行争用。
*
* 另请注意,WQ_FLAG_WOKEN对于非专属服务员来说就足够了,但专属服务员需要WQ_FLAG_DONE。
*/
if (behavior == EXCLUSIVE)
return wait->flags & WQ_FLAG_DONE ? 0 : -EINTR;

return wait->flags & WQ_FLAG_WOKEN ? 0 : -EINTR;
}

folio_wait_bit 用于等待 folio(页面的抽象表示)上的指定标志位(bit_nr)被清除

1
2
3
4
5
6
7
void folio_wait_bit(struct folio *folio, int bit_nr)
{
/* TASK_UNINTERRUPTIBLE 指定等待模式为不可中断,确保调用线程不会因信号而退出等待。
SHARED 指定等待的共享模式,允许多个线程同时等待同一标志位 */
folio_wait_bit_common(folio, bit_nr, TASK_UNINTERRUPTIBLE, SHARED);
}
EXPORT_SYMBOL(folio_wait_bit);

filemap_fdatawait_range 等待写回完成

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
/**
* filemap_fdatawait_range - 等待写回完成
* @mapping:要等待的地址空间结构
* @start_byte:范围开始的偏移量(以字节为单位)
* @end_byte:范围结束(含)的偏移量(以字节为单位)
*
* 遍历给定范围内给定地址空间的 under-writeback 页面列表,并等待所有页面。 检查地址空间的错误状态并返回。
*
* 由于该函数清除了地址空间的错误状态,因此调用者负责检查返回值并处理和/或报告错误。
*
* 返回值:地址空间的错误状态。
*/
int filemap_fdatawait_range(struct address_space *mapping, loff_t start_byte,
loff_t end_byte)
{
__filemap_fdatawait_range(mapping, start_byte, end_byte);
return filemap_check_errors(mapping);
}
EXPORT_SYMBOL(filemap_fdatawait_range);

pagecache_init 初始化页面缓存相关的结构和系统控制参数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/* How many times do we accept lock stealing from under a waiter? */
static int sysctl_page_lock_unfairness = 5;
static const struct ctl_table filemap_sysctl_table[] = {
{
.procname = "page_lock_unfairness",
.data = &sysctl_page_lock_unfairness,
.maxlen = sizeof(sysctl_page_lock_unfairness),
.mode = 0644,
.proc_handler = proc_dointvec_minmax,
.extra1 = SYSCTL_ZERO,
}
};

void __init pagecache_init(void)
{
int i;

for (i = 0; i < PAGE_WAIT_TABLE_SIZE; i++)
init_waitqueue_head(&folio_wait_table[i]);

page_writeback_init();
register_sysctl_init("vm", filemap_sysctl_table);
}

页缓存批处理查找与锁定:高效检索可操作的缓存页

本代码片段定义了 find_lock_entries 函数,它是内核中用于从文件页缓存(address_space)中高效地、批量地查找并锁定一批可操作条目的核心函数。这个函数是 invalidate_mapping_pages 等高级缓存管理功能能够高性能运作的引擎。它不仅仅是查找,更重要的是它会过滤掉不适合操作的条目(如已被锁定、正在回写),并对找到的 folio 原子地加锁,为调用者准备好一个可以直接进行安全操作的 folio 批次。

实现原理分析

find_lock_entries 的实现直接与底层的XArray数据结构和 folio 的状态管理紧密交互,其逻辑复杂但高效。

  1. RCU保护下的遍历:

    • 整个遍历过程被 rcu_read_lock()rcu_read_unlock() 包围。
    • 为什么用RCU? 页缓存的XArray支持在不持有锁的情况下进行查找(RCU-walk)。这允许查找操作与删除操作可以高度并发地进行。RCU读锁确保了在遍历期间,即使有条目被并发地删除,其指针所指向的folio结构体内存也不会被立即释放,从而防止了 use-after-free 错误。
  2. 核心查找循环 (find_get_entry):

    • while ((folio = find_get_entry(&xas, end, XA_PRESENT))): 这是核心的查找循环。find_get_entry 是一个XArray的辅助函数,它会从xas指定的位置开始查找,直到end,并返回找到的条目。
    • 关键: find_get_entry 在返回 folio 指针之前,会自动地增加该folio的引用计数 (folio_get)。这保证了即使在后续的锁定和检查过程中,folio对象也不会消失。
  3. 对找到的条目进行严格的过滤和锁定:

    • 处理真实Folio (!xa_is_value):
      a. 范围检查: 它会仔细检查大型folio(由多个页面组成)的边界,确保整个folio都完全落在startend的请求范围内。
      b. 尝试锁定 (folio_trylock): 这是最关键的过滤步骤。它尝试获取folio的PG_locked位。如果失败(返回false),说明该folio已被其他任务锁定,当前操作不能处理它,因此通过goto put跳过。
      c. 状态检查: 锁定成功后,它会再次检查folio的状态。folio->mapping != mapping检查folio是否在锁定期间被迁移到了其他文件(一个罕见的竞态);folio_test_writeback检查folio是否正在被写回磁盘。如果任一条件为真,说明folio当前状态不适合被驱逐,通过goto unlock跳过。
    • 处理影子条目 (xa_is_value): 对于影子条目,它只做范围检查。影子条目没有锁或回写状态,可以直接处理。
  4. 添加到批处理:

    • 只有通过了所有检查和锁定的folio(或影子条目),才会被folio_batch_add添加到fbatch中。
    • 同时,它的索引被记录在indices数组中。
    • *start = base + nr; 更新下一次查找的起始点,跳过当前找到的整个条目(可能是大型folio)。
  5. 错误处理与清理 (goto):

    • 代码中大量使用了goto语句。在这种复杂的、多级检查的状态机中,goto是一种清晰、高效的错误处理方式,可以避免深层嵌套的if-else
    • put: 如果folio在锁定前就被排除了,跳转到这里,只调用folio_put释放之前find_get_entry增加的引用计数。
    • unlock: 如果folio在锁定后被排除,跳转到这里,先folio_unlock解锁,再folio_put释放引用。
  6. 返回: 函数最终返回批处理中成功添加的条目数量。

代码分析

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
/**
* find_lock_entries - 查找一批页缓存条目。
* @mapping: 要搜索的 address_space。
* @start: 起始页缓存索引的指针(会被更新)。
* @end: 最终页索引(包含)。
* @fbatch: 用于存放结果条目的 folio_batch。
* @indices: fbatch中条目的缓存索引。
*
* 返回值: 找到的条目数量。
*/
unsigned find_lock_entries(struct address_space *mapping, pgoff_t *start,
pgoff_t end, struct folio_batch *fbatch, pgoff_t *indices)
{
// 初始化XArray状态机,定位到*start索引。
XA_STATE(xas, &mapping->i_pages, *start);
struct folio *folio;

// 进入RCU读端临界区,以安全地遍历XArray。
rcu_read_lock();
// 主循环:使用find_get_entry查找下一个存在的条目,并自动增加其引用计数。
while ((folio = find_get_entry(&xas, end, XA_PRESENT))) {
unsigned long base; // folio的基地址索引
unsigned long nr; // folio包含的页面数

// 检查找到的是不是一个真正的folio。
if (!xa_is_value(folio)) {
// --- 处理真实Folio ---
nr = folio_nr_pages(folio);
base = folio->index;
// 过滤:跳过起始位置在请求范围之前的folio。
if (base < *start)
goto put;
// 过滤:跳过结束位置超出请求范围的folio。
if (base + nr - 1 > end)
goto put;
// 过滤:尝试锁定folio,如果失败则跳过。
if (!folio_trylock(folio))
goto put;
// 过滤:锁定后再次检查状态,防止竞态。
if (folio->mapping != mapping ||
folio_test_writeback(folio))
goto unlock;
// 调试断言,确保folio的索引与XArray的索引一致。
VM_BUG_ON_FOLIO(!folio_contains(folio, xas.xa_index),
folio);
} else {
// --- 处理影子/值条目 ---
// ... 范围检查逻辑 ...
}

// 更新下一次循环的起始索引,跳过当前整个条目。
*start = base + nr;
// 记录当前条目的索引。
indices[fbatch->nr] = xas.xa_index;
// 将条目添加到批处理中。
if (!folio_batch_add(fbatch, folio))
break; // 如果批处理已满,则退出循环。
continue;
unlock:
// 如果锁定后检查失败,先解锁。
folio_unlock(folio);
put:
// 释放由find_get_entry增加的引用计数。
folio_put(folio);
}
// 退出RCU读端临界区。
rcu_read_unlock();

// 返回批处理中成功添加的条目数量。
return folio_batch_count(fbatch);
}

Folio 解锁和唤醒:释放页面锁并通知服务员

本代码片段定义了 folio_unlock 函数,它是与 folio_lockfolio_trylock 配对的解锁原语。其核心功能是清除 folio 的 PG_locked 标志位,并唤醒所有可能正在睡眠等待这个锁的进程。这是Linux内存管理中所有基于页面锁(PG_locked)的同步机制的基础。folio_unlock 的实现非常高效,它通过一个巧妙的原子操作来同时完成解锁和判断是否有等待者这两个步骤。

实现原理分析

folio_unlock

  1. 编译时与运行时检查:

    • BUILD_BUG_ON(...): 这些是编译时断言。它们检查内核中某些关键标志位的定义,确保 PG_waitersPG_locked 的位号(bit number)符合某些硬件架构(如x86)的优化预期。如果定义不符,编译会直接失败。
    • VM_BUG_ON_FOLIO(...): 这是一个运行时断言。它检查被解锁的folio是否真的处于锁定状态。如果尝试解锁一个未被锁定的folio,说明内核逻辑存在严重错误,会触发一个bug报告。
  2. 核心原子操作 (folio_xor_flags_has_waiters):

    • 这是 folio_unlock 中最关键、最高效的一步。这个函数(其内部实现未显示,但可以推断)执行了一个原子的、带返回值的异或(XOR)操作
    • 解锁: 它原子地将 folio 的标志字与 1 << PG_locked 进行XOR操作。因为PG_locked位当前是1,1 XOR 1 = 0,所以这一步的效果就是原子地将PG_locked位清零,即完成了“解锁”。
    • 检查等待者: PG_waiters 是另一个标志位。当有进程因等待PG_locked而睡眠时,内核会设置PG_waiters位。这个原子操作在执行XOR之后,会检查标志字中 PG_waiters 位的值。
    • 返回值: 如果 PG_waiters 位被设置了,folio_xor_flags_has_waiters 返回 true;否则返回 false
    • 优点: 通过一次原子操作,同时完成了“解锁”和“判断是否有等待者”两个任务,避免了“先解锁,再检查”可能导致的竞态条件(即在解锁后、检查前,一个新的等待者到来)。
  3. 条件唤醒:

    • if (folio_xor_flags_has_waiters(...)) folio_wake_bit(...): 只有当原子操作告诉我们确实有等待者时,才需要调用相对“重”的唤醒函数 folio_wake_bit。如果没有任何等待者,folio_unlock 就在一次原子操作后快速返回,性能极高。

folio_wake_bit

这个函数负责执行实际的唤醒操作。

  1. 获取等待队列: folio_waitqueue(folio) 会找到与这个folio关联的等待队列头(wait_queue_head_t)。在内核中,为了节省内存,通常不是每个folio都有一个独立的等待队列,而是通过一个哈希表将多个folio映射到同一个等待队列上。
  2. 准备唤醒键 (wait_page_key): __wake_up_locked_key 是一个高级的唤醒函数,它允许进行选择性唤醒wait_page_key 结构体就是“筛选条件”。我们只希望唤醒那些正在等待这个特定folio的**PG_locked位**的进程,而不是唤醒哈希冲突到同一个等待队列上的其他folio的等待者。key中设置了folio指针和bit_nr,就是为了这个目的。
  3. 执行唤醒: __wake_up_locked_key(q, TASK_NORMAL, &key) 在持有等待队列的自旋锁的情况下,遍历队列,并唤醒所有与key匹配的等待项。
  4. 清理PG_waiters标志:
    • if (!waitqueue_active(q) || !key.page_match): 在唤醒之后,函数会检查等待队列是否已经变空(!waitqueue_active(q)),或者本次唤醒是否已经处理了所有与该folio匹配的等待者(!key.page_match)。
    • 如果满足任一条件,就调用 folio_clear_waiters(folio) 来清除folio上的PG_waiters标志。
    • 为什么可能不清? 如注释所说,在哈希冲突的情况下,队列上可能还有等待其他folio的进程,此时 waitqueue_active(q) 仍为真。这不要紧,不清PG_waiters标志是安全的,下一次对这个哈希队列的唤醒操作最终会清理它。

代码分析

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
// folio_wake_bit: 唤醒正在等待folio上特定比特位的任务。
static void folio_wake_bit(struct folio *folio, int bit_nr)
{
// 获取folio对应的等待队列头(可能是一个哈希队列)。
wait_queue_head_t *q = folio_waitqueue(folio);
struct wait_page_key key;
unsigned long flags;

// 设置唤醒键,用于精确匹配等待者。
key.folio = folio; // 只唤醒等待这个folio的任务
key.bit_nr = bit_nr; // 只唤醒等待这个比特位的任务
key.page_match = 0;

// 获取等待队列的锁,并禁用中断。
spin_lock_irqsave(&q->lock, flags);
// 执行带键值的唤醒操作。
__wake_up_locked_key(q, TASK_NORMAL, &key);

// 在唤醒后,尝试清理folio上的PG_waiters标志。
if (!waitqueue_active(q) || !key.page_match)
folio_clear_waiters(folio);

// 释放锁并恢复中断。
spin_unlock_irqrestore(&q->lock, flags);
}

/**
* folio_unlock - 解锁一个已锁定的folio。
* @folio: The folio.
*/
void folio_unlock(struct folio *folio)
{
// 编译时断言,确保关键标志位的布局符合预期。
BUILD_BUG_ON(PG_waiters != 7);
BUILD_BUG_ON(PG_locked > 7);
// 运行时调试断言,确保我们正在解锁一个确实被锁定的folio。
VM_BUG_ON_FOLIO(!folio_test_locked(folio), folio);

// 核心操作:原子地清除PG_locked位,并检查PG_waiters位是否被设置。
// 这是一个集解锁与检查于一体的高效操作。
if (folio_xor_flags_has_waiters(folio, 1 << PG_locked))
// 仅当确实有等待者时,才调用较重的唤醒函数。
folio_wake_bit(folio, PG_locked);
}
// 导出符号,供内核其他部分使用。
EXPORT_SYMBOL(folio_unlock);

Folio 等待队列:高效地管理对内存页的并发访问

本代码片段定义了 Linux 内核中用于管理对 folio 对象(代表一个或多个物理页的内存单元)的并发访问的等待队列机制。由于为每一个 folio 都创建一个独立的等待队列会消耗大量内存,内核采用了一种哈希表来共享等待队列。多个 folio 对象可能会被哈希到同一个等待队列上,但这通过辅助的键值匹配可以保证正确的唤醒,是内存效率和性能之间的折衷。

实现原理分析

本机制的核心思想是:并非每个 folio 都拥有一个专属的等待队列,而是多个 folio 共享同一个等待队列。

  1. 等待队列哈希表 (folio_wait_table):

    • #define PAGE_WAIT_TABLE_BITS 8: 定义了哈希表的桶(bucket)的数量的对数,即哈希表的大小为 2^PAGE_WAIT_TABLE_BITS。更大的值可以减少哈希冲突,但会增加内存占用。
    • #define PAGE_WAIT_TABLE_SIZE (1 << PAGE_WAIT_TABLE_BITS): 计算出哈希表的大小。
    • static wait_queue_head_t folio_wait_table[PAGE_WAIT_TABLE_SIZE] __cacheline_aligned;: 声明了等待队列头的数组。__cacheline_aligned属性确保数组中的每个元素都对齐到一个缓存行,以减少缓存行争用,提高多核性能。
  2. 哈希函数 (folio_waitqueue):

    • static wait_queue_head_t *folio_waitqueue(struct folio *folio): 这是一个内联函数,实现了将 folio 映射到等待队列的哈希函数。
    • hash_ptr(folio, PAGE_WAIT_TABLE_BITS): 这是一个通用的内核哈希函数,它将folio指针(实际上是folio结构体的地址)作为输入,并使用PAGE_WAIT_TABLE_BITS作为掩码来生成一个介于0和PAGE_WAIT_TABLE_SIZE - 1之间的哈希值。实际上就是取folio地址的低8位(如果PAGE_WAIT_TABLE_BITS是8)。
    • 函数返回folio_wait_table数组中对应索引的等待队列头指针。
  3. 哈希冲突与精准唤醒:

    • 由于多个 folio 可能会被哈希到同一个等待队列,当调用 wake_up 唤醒等待在这个队列上的任务时,可能会出现“误唤醒”(thundering herd)现象:本不应该被唤醒的、等待其他 folio 的任务也被错误地唤醒了。
    • 为了解决这个问题,folio_wake_bit 使用了带键值的唤醒机制 (__wake_up_locked_key),并传递了一个 wait_page_key 结构体。该结构体中包含了目标 folio 的指针以及被等待的标志位(例如PG_locked)。
    • __wake_up_locked_key 只会唤醒那些同时满足以下两个条件的任务:
      a. 等待在同一个等待队列上(哈希冲突)。
      b. 等待的是同一个folio同一个特定标志位
    • 这种精确的唤醒机制避免了“误唤醒”带来的性能损失,保证了只有真正需要被唤醒的任务才会被唤醒。
  4. 清空等待者标志:

    • folio_clear_waiters
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
/*
* 为了等待页面变为可用,必须有与页面关联的等待队列。
* 通过使用等待队列的哈希表,其中bucket的原则是维护所有在同一个
* 队列上的等待者,并在任何页面变为可用时唤醒所有等待者,并且对于
* 被唤醒的上下文进行检查以确保适当的页面变为可用,这节省了空间
* 但代价是罕见的哈希冲突期间的“惊群效应”现象。
*/
// 定义哈希表的位数。
#define PAGE_WAIT_TABLE_BITS 8
// 计算哈希表的大小。
#define PAGE_WAIT_TABLE_SIZE (1 << PAGE_WAIT_TABLE_BITS)
// 声明并初始化等待队列头的哈希表。 __cacheline_aligned用于缓存行对齐。
static wait_queue_head_t folio_wait_table[PAGE_WAIT_TABLE_SIZE] __cacheline_aligned;

// folio_waitqueue: 计算指定folio对应的等待队列头的地址。
static wait_queue_head_t *folio_waitqueue(struct folio *folio)
{
// 使用hash_ptr函数将folio指针映射到哈希表的索引,并返回对应的等待队列头指针。
return &folio_wait_table[hash_ptr(folio, PAGE_WAIT_TABLE_BITS)];
}

// folio_wake_bit: 唤醒正在等待folio上特定比特位的任务。
static void folio_wake_bit(struct folio *folio, int bit_nr)
{
// 获取folio对应的等待队列头。
wait_queue_head_t *q = folio_waitqueue(folio);
struct wait_page_key key;
unsigned long flags;

// 设置唤醒键,用于精确匹配等待者。
key.folio = folio; // 只唤醒等待这个folio的任务。
key.bit_nr = bit_nr; // 只唤醒等待特定比特位的任务。
key.page_match = 0; // 初始为0,__wake_up_locked_key会设置。

// 获取等待队列的锁,并禁用中断。
spin_lock_irqsave(&q->lock, flags);
// 执行带键值的唤醒操作。
__wake_up_locked_key(q, TASK_NORMAL, &key);

// 检查是否需要清理等待者标志。
if (!waitqueue_active(q) || !key.page_match)
folio_clear_waiters(folio);

// 释放锁并恢复中断。
spin_unlock_irqrestore(&q->lock, flags);
}

VFS通用文件读取例程:基于页缓存的标准读取实现

本代码片段展示了generic_file_read_iter函数,它是Linux VFS层为绝大多数普通文件系统提供的标准read_iter()实现read_iter是现代内核中用于文件读取的核心接口,它取代了旧的read接口,因为它使用iov_iter结构体,可以高效地处理分散/聚集I/O(scatter-gather I/O),即将数据直接读取到多个不连续的内存缓冲区中,避免了不必要的数据拷贝。

您可以将generic_file_read_iter想象成一个图书管的标准借阅流程。这个流程适用于图书馆里所有可以放入公共书架(Page Cache,页缓存)的普通书籍(普通文件)。

  • 读者: 用户进程。
  • 借书单 (iov_iter): 记录了读者想要把书的内容抄到哪些笔记本(内存缓冲区)的哪些位置。
  • 图书馆员 (generic_file_read_iter): 执行借阅流程。

这个流程分为两种情况:

  1. VIP加急服务 (Direct I/O): 读者(进程)如果出示了“加急卡”(O_DIRECT标志),图书馆员会绕过公共书架(Page Cache),直接去后台的书库(存储设备),让库管(mapping->a_ops->direct_IO)把书的内容直接复印到读者的笔记本上。这个过程没有中间环节,速度可能很快,但对读者的笔记本(内存对齐、大小等)有严格要求。

  2. 标准借阅流程 (Buffered I/O): 对于绝大多数普通读者,图书馆员会:

    • 在公共书架上查找 (filemap_read): 图书馆员首先去公共书架(Page Cache)找这本书。
    • 书在架子上: 如果书正好在书架上(数据在Page Cache中),就直接拿给读者抄写。
    • 书不在架子上: 如果书不在书架上(数据不在Page Cache中),图书馆员会发起一个“调取请求”(启动块I/O操作),让库管去后台书库(存储设备)把书拿过来,放到公共书架上。然后,再从书架上拿给读者抄写。
    • 预读: 如果图书馆员看到读者借了第一章,他会猜测读者可能接下来会借第二章,于是会顺手把第二章也从书库调取到公共书架上,以备不时之需。这就是“预读”(readahead)。

实现原理分析

generic_file_read_iter是一个分发器,它根据打开文件的标志(iocb->ki_flags)来决定是走直接I/O路径还是缓冲I/O路径。

  1. 直接I/O (Direct I/O) 路径 (iocb->ki_flags & IOCB_DIRECT):

    • 前提: 用户在open()时指定了O_DIRECT标志。
    • 写回等待: kiocb_write_and_wait(iocb, count)是一个看似奇怪但非常重要的步骤。它确保在开始直接读取之前,所有与此文件相关的、由本进程发起的“脏”页(dirty pages,即在Page Cache中被修改过但尚未写回磁盘的数据)都被写回到存储设备上。这是为了保证数据的一致性。因为直接I/O会绕过Page Cache,如果不先写回脏页,那么直接读操作可能会读到比Page Cache中还旧的数据。
    • 委托给底层: 核心操作被委托给地址空间操作表(address_space_operations)中的->direct_IO方法。具体文件系统(如ext4)或块设备层会实现这个方法,它负责构建I/O请求,并直接与块设备驱动交互,将数据从磁盘直接传输到用户提供的iov_iter缓冲区中。
    • 部分读取与回退: direct_IO可能会因为各种原因(如遇到压缩块、磁盘错误等)只读取了部分数据。iov_iter_revert函数在这里的作用是,如果发生了部分读取,它会回退iov_iter的内部指针,使其准确地反映出还有多少缓冲区空间未被填充。
    • 回退到缓冲I/O: 在某些文件系统(如Btrfs)上,如果直接I/O因为特定原因(如压缩)而提前终止,代码会继续执行,落入到下面的缓冲I/O路径 (filemap_read),尝试用标准方式读取剩余的数据。但对于DAX文件(直接在存储设备上执行,没有传统Page Cache),这是行不通的,所以会直接返回。
  2. 缓冲I/O (Buffered I/O) 路径 (filemap_read):

    • 这是最常见的情况。
    • filemap_read是一个复杂的函数(未在此代码片段中提供),它封装了所有与Page Cache交互的逻辑。其大致流程是:
      a. 循环遍历请求的读取范围。
      b. 对于每一页,调用find_get_page在Page Cache中查找。
      c. Cache Hit: 如果页在缓存中,并且是最新的(Uptodate),就将页的内容拷贝到iov_iter指定的缓冲区。
      d. Cache Miss: 如果页不在缓存中,或者已失效,就会触发“缺页处理”:
      i. 分配一个新的页。
      ii. 将其加入到文件的Page Cache中。
      iii. 调用地址空间操作表中的->read_folio方法,创建一个块I/O请求,让块设备层从磁盘读取数据填充这个页。
      iv. 如果是同步读取,当前进程会等待I/O完成。
      v. I/O完成后,页被标记为Uptodate,然后将内容拷贝到用户缓冲区。
      e. 同时,它会根据访问模式触发**预读(readahead)**逻辑,异步地读取文件后续的页面,以期下次读取时能命中缓存。

代码分析

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
/**
* generic_file_read_iter - 通用文件系统读取例程
* @iocb: 内核 I/O 控制块 (kiocb),包含了文件指针、位置、标志等信息。
* @iter: iov_iter 结构体,描述了数据要被读到哪里去。
*
* 这是所有能直接使用页缓存(page cache)的文件系统的 "read_iter()" 标准实现。
* ... [注释中对IOCB_NOWAIT和IOCB_NOIO标志的解释] ...
*/
ssize_t
generic_file_read_iter(struct kiocb *iocb, struct iov_iter *iter)
{
// 获取总共要读取的字节数。
size_t count = iov_iter_count(iter);
ssize_t retval = 0;

// 如果请求读取0字节,直接返回0,避免不必要地更新文件访问时间(atime)。
if (!count)
return 0;

// 检查是否是直接I/O (Direct I/O) 请求。
if (iocb->ki_flags & IOCB_DIRECT) {
struct file *file = iocb->ki_filp;
struct address_space *mapping = file->f_mapping;
struct inode *inode = mapping->host;

// 确保所有由本进程产生的脏页都已写回磁盘,保证数据一致性。
retval = kiocb_write_and_wait(iocb, count);
if (retval < 0)
return retval;

file_accessed(file); // 更新文件访问时间。

// 调用底层文件/设备驱动提供的 a_ops->direct_IO 方法。
retval = mapping->a_ops->direct_IO(iocb, iter);
if (retval >= 0) {
// 如果成功读取,更新文件指针位置和剩余字节数。
iocb->ki_pos += retval;
count -= retval;
}
// 如果direct_IO不是异步返回,就需要回退iter,使其反映真实情况。
if (retval != -EIOCBQUEUED)
iov_iter_revert(iter, count - iov_iter_count(iter));

/*
* 特殊处理:某些文件系统(如Btrfs的压缩文件)可能只完成部分DIO读取。
* 如果:1. 发生错误;2. 已读完所有请求的数据;3. 是DAX文件;
* 就直接返回。
* 4. 如果到达文件末尾,也返回。
* 否则,将落入(fallthrough)到下面的缓冲I/O,继续读取剩余部分。
*/
if (retval < 0 || !count || IS_DAX(inode))
return retval;
if (iocb->ki_pos >= i_size_read(inode))
return retval;
}

// 对于普通缓冲I/O,或者DIO回退的情况,调用filemap_read。
// filemap_read是处理页缓存读的核心函数。
// retval作为输入参数,记录了DIO部分可能已经读取的字节数。
return filemap_read(iocb, iter, retval);
}
// 导出符号,使得其他内核模块(主要是文件系统驱动)可以调用这个函数。
EXPORT_SYMBOL(generic_file_read_iter);