[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 相关的内存操作,其核心职责包括:
页缓存的查找与插入 (Finding and Inserting): 当需要读取文件数据时,它负责在页缓存中查找是否已缓存了对应的页面。如果找到(Cache Hit),则直接返回内存页;如果未找到(Cache Miss),则负责分配一个新的物理页,并将其插入到页缓存中,准备从磁盘加载数据。
数据的读取与写入 (Reading and Writing):
- 读操作: 在 Cache Miss 的情况下,它负责调用底层文件系统的
readpage
或readpages
操作,将数据从磁盘读入到新分配的内存页中。 - 写操作: 当用户写入数据时,它负责将数据写入到页缓存中,并将该页标记为脏页 (Dirty)。它并不立即将数据写回磁盘,而是依赖于
backing-dev
机制进行后续的异步回写(Writeback)。
- 读操作: 在 Cache Miss 的情况下,它负责调用底层文件系统的
预读 (Read-Ahead): 为了提高顺序读性能,
mm/filemap.c
实现了复杂的预读算法。当它检测到进程正在顺序读取文件时,它会主动、提前地将文件后续的内容异步读入页缓存中。这样,当进程真正需要这些数据时,它们很可能已经在内存里了。页面截断与清理 (Truncation and Invalidation): 当文件被截断(变小)或删除时,
mm/filemap.c
负责从页缓存中找到并移除所有属于该文件无效部分的缓存页,并确保脏页被正确处理。内存回收接口: 当系统内存不足时,内存回收机制(
kswapd
内核线程)会扫描页缓存。mm/filemap.c
提供了判断一个缓存页是否可以被回收的接口。如果页面是干净的(Clean),就可以直接回收;如果是脏的(Dirty),则必须先将其写回磁盘才能回收。
二、 核心数据结构与机制
mm/filemap.c
的所有操作都围绕着两个核心数据结构展开:
struct address_space
:- 可以把它看作是页缓存中一个文件的“索引卡片”。每个被缓存的文件(由
struct inode
表示)都有一个与之关联的address_space
对象。 - 它包含了管理该文件所有缓存页所需的信息,其中最重要的是一个基数树 (Radix Tree) 或 XArray(一种更现代的数据结构)的根指针 (
page_tree
)。 page_tree
: 这个树形数据结构是页缓存的核心索引机制。它以文件内的页面偏移量(page offset)为键(key),以指向对应struct page
的指针为值(value)。这使得内核可以以 O(logN) 的时间复杂度快速地根据文件偏移量找到或插入一个缓存页。
- 可以把它看作是页缓存中一个文件的“索引卡片”。每个被缓存的文件(由
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()
:- 这是文件读操作的通用实现,被大多数文件系统直接使用。
- 它循环处理用户的读请求,对每一页:
- 调用
pagecache_get_page()
查找或创建缓存页。 - 如果页面不在内存中 (
!PageUptodate
),则调用a_ops->readpage()
从磁盘加载数据。 - 等待 I/O 完成。
- 将页缓存中的数据拷贝到用户的缓冲区。
- 调用
generic_perform_write()
:- 这是文件写操作的通用实现。
- 它将用户缓冲区的数据拷贝到页缓存中,并调用
set_page_dirty()
将页面标记为脏页。脏页的处理则交由backing-dev
机制负责。
do_readahead()
:- 实现预读逻辑的函数。它会根据当前的读取模式和上下文,决定是否以及预读多少个页面,然后异步地发起 I/O 请求。
truncate_inode_pages_range()
:- 实现文件截断的函数。它会扫描基数树,找到并移除指定范围内的所有缓存页。
四、 场景贯穿:第一次读取一个文件
- 系统调用: 应用程序调用
read()
系统调用。 - VFS 层: VFS 层找到对应的
struct file
和struct inode
,最终调用generic_file_read_iter()
。 mm/filemap.c
介入:generic_file_read_iter()
开始执行。假设要读取文件的前 4KB (offset 0)。- 查找页缓存: 它调用
pagecache_get_page(inode->i_mapping, 0)
。由于是第一次读取,基数树中没有索引为 0 的页面,发生 Cache Miss。 - 分配与插入:
pagecache_get_page
会分配一个新的struct page
,并将其插入到inode->i_mapping->page_tree
中,索引为 0。此时页面是锁定的,且没有Uptodate
标志。 - 调用文件系统:
generic_file_read_iter
发现页面不是Uptodate
,于是调用inode->i_mapping->a_ops->readpage(file, page)
。 - 磁盘 I/O: ext4 等文件系统收到请求,计算出文件 offset 0 对应的磁盘逻辑块地址,然后构建
bio
提交给块设备层,发起磁盘读操作。 - I/O 完成: 当 DMA 操作完成,数据从磁盘读入到
page
对应的物理内存后,块设备层通过回调函数通知上层。 - 更新状态: I/O 完成处理函数会设置
PageUptodate
标志,并解锁页面。 - 唤醒进程与拷贝: 等待 I/O 的进程被唤醒。
generic_file_read_iter
确认页面已Uptodate
,然后调用copy_to_user()
将page
中的数据拷贝到应用程序的缓冲区。 - 预读触发: 在此过程中,
filemap.c
的预读逻辑可能会被激活,它会发现这是一个顺序读,于是异步地为文件的 offset 1, 2, 3… 发起readpage
请求。
五、 总结
mm/filemap.c
是 Linux 高性能 I/O 的核心引擎。它通过页缓存机制,将频繁访问的文件数据保留在高速的 RAM 中,极大地减少了对慢速磁盘的依赖。
其关键设计思想在于:
- 通用性: 通过
address_space
和address_space_operations
的抽象,提供了一套适用于所有文件系统的、统一的缓存管理框架。 - 高效索引: 利用基数树或 XArray,实现了对海量缓存页的快速查找和管理。
- 异步操作: 读操作的预读和写操作的延迟回写都是异步的,最大化了系统吞吐量和响应性。
理解 mm/filemap.c
的工作原理,是理解 Linux 文件系统性能、内存使用情况以及如何进行 I/O 性能调优的根本。
include/linux/pagemap.h
mapping_shrinkable 检查页面缓存(page cache)的状态,以确定是否允许回收 inode
1 | /* |
filemap_fdatawait 等待写回完成
1 | static inline int filemap_fdatawait(struct address_space *mapping) |
Folio状态检查与锁定:内存页操作的基础原语
本代码片段定义了两个基础的、性能关键的内联函数,它们是Linux内核中对 folio
进行操作的基础构建块。
folio_trylock
: 提供了一个非阻塞的方式来尝试锁定一个folio。这是高性能批处理和避免死锁的关键工具。folio_contains
: 提供了一个快速的检查,用于判断一个给定的文件页索引是否落在一个(可能由多个页组成的)大型folio的覆盖范围内。
实现原理分析
folio_trylock
核心原子操作 (
test_and_set_bit_lock
):- 这个函数是
folio_trylock
的心脏。test_and_set_bit_lock
是一个原子的“测试并设置”操作。它会执行以下三个步骤,且整个过程不可被中断:
a. 测试 (Test): 检查PG_locked
位(folio标志位中的一个)的当前值。
b. 设置 (Set): 无论当前值是多少,都将该位设置为1。
c. 返回 (Return): 返回该位在设置之前的旧值。 _lock
后缀表示这个原子操作还附带了“获取锁”的内存屏障语义,可以防止后续的内存访问被乱序到它前面。
- 这个函数是
逻辑判断:
- 如果
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
核心逻辑:
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 = 2
,2 < 4
为真,函数返回true
。如果传入的index
是14,14 - 10 = 4
,4 < 4
为假,函数返回false
。
调试断言:
VM_WARN_ON_ONCE_FOLIO(folio_test_swapcache(folio), folio)
: 这是一个调试用的断言。它检查这个folio是否意外地处于swapcache
中。folio_contains
的调用者被要求确保folio不处于这种状态,因为在这种状态下,folio的index
字段的含义可能会改变。如果断言触发,它会在内核日志中打印一次警告,帮助开发者捕获逻辑错误。
代码分析
1 | /** |
include/linux/mm.h
folio_nr_pages 计算一个 folio(页面的抽象表示)中包含的页面数量
1 | /** |
mm/filemap.c
find_get_entry 在 xa_state(XArray 状态)中查找与指定条件匹配的 folio(页面的抽象表示)
1 | static inline struct folio *find_get_entry(struct xa_state *xas, pgoff_t max, |
filemap_get_folios_tag 从指定的地址空间(address_space)中获取一批与特定标签(tag)匹配的 folio(页面的抽象表示)
1 | /** |
__filemap_fdatawait_range 用于等待指定地址空间(address_space)中某个范围内的页面完成写回操作
1 | static void __filemap_fdatawait_range(struct address_space *mapping, |
filemap_check_errors 用于检查 address_space(地址空间)结构中是否存在未处理的写入错误,并返回相应的错误代码
1 | int filemap_check_errors(struct address_space *mapping) |
folio_waitqueue 用于获取与 folio(页面的抽象表示)相关联的等待队列
1 | /* |
wake_page_function 处理等待队列中的唤醒逻辑
1 | /* |
folio_wait_bit_common 等待 folio(页面的抽象表示)上的特定标志位(bit_nr)被清除
1 | static inline int folio_wait_bit_common(struct folio *folio, int bit_nr, |
folio_wait_bit 用于等待 folio(页面的抽象表示)上的指定标志位(bit_nr)被清除
1 | void folio_wait_bit(struct folio *folio, int bit_nr) |
filemap_fdatawait_range 等待写回完成
1 | /** |
pagecache_init 初始化页面缓存相关的结构和系统控制参数
1 | /* How many times do we accept lock stealing from under a waiter? */ |
页缓存批处理查找与锁定:高效检索可操作的缓存页
本代码片段定义了 find_lock_entries
函数,它是内核中用于从文件页缓存(address_space
)中高效地、批量地查找并锁定一批可操作条目的核心函数。这个函数是 invalidate_mapping_pages
等高级缓存管理功能能够高性能运作的引擎。它不仅仅是查找,更重要的是它会过滤掉不适合操作的条目(如已被锁定、正在回写),并对找到的 folio 原子地加锁,为调用者准备好一个可以直接进行安全操作的 folio 批次。
实现原理分析
find_lock_entries
的实现直接与底层的XArray数据结构和 folio 的状态管理紧密交互,其逻辑复杂但高效。
RCU保护下的遍历:
- 整个遍历过程被
rcu_read_lock()
和rcu_read_unlock()
包围。 - 为什么用RCU? 页缓存的XArray支持在不持有锁的情况下进行查找(RCU-walk)。这允许查找操作与删除操作可以高度并发地进行。RCU读锁确保了在遍历期间,即使有条目被并发地删除,其指针所指向的
folio
结构体内存也不会被立即释放,从而防止了 use-after-free 错误。
- 整个遍历过程被
核心查找循环 (
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
对象也不会消失。
对找到的条目进行严格的过滤和锁定:
- 处理真实Folio (
!xa_is_value
):
a. 范围检查: 它会仔细检查大型folio(由多个页面组成)的边界,确保整个folio都完全落在start
和end
的请求范围内。
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
): 对于影子条目,它只做范围检查。影子条目没有锁或回写状态,可以直接处理。
- 处理真实Folio (
添加到批处理:
- 只有通过了所有检查和锁定的folio(或影子条目),才会被
folio_batch_add
添加到fbatch
中。 - 同时,它的索引被记录在
indices
数组中。 *start = base + nr;
更新下一次查找的起始点,跳过当前找到的整个条目(可能是大型folio)。
- 只有通过了所有检查和锁定的folio(或影子条目),才会被
错误处理与清理 (
goto
):- 代码中大量使用了
goto
语句。在这种复杂的、多级检查的状态机中,goto
是一种清晰、高效的错误处理方式,可以避免深层嵌套的if-else
。 put
: 如果folio在锁定前就被排除了,跳转到这里,只调用folio_put
释放之前find_get_entry
增加的引用计数。unlock
: 如果folio在锁定后被排除,跳转到这里,先folio_unlock
解锁,再folio_put
释放引用。
- 代码中大量使用了
返回: 函数最终返回批处理中成功添加的条目数量。
代码分析
1 | /** |
Folio 解锁和唤醒:释放页面锁并通知服务员
本代码片段定义了 folio_unlock
函数,它是与 folio_lock
和 folio_trylock
配对的解锁原语。其核心功能是清除 folio 的 PG_locked
标志位,并唤醒所有可能正在睡眠等待这个锁的进程。这是Linux内存管理中所有基于页面锁(PG_locked
)的同步机制的基础。folio_unlock
的实现非常高效,它通过一个巧妙的原子操作来同时完成解锁和判断是否有等待者这两个步骤。
实现原理分析
folio_unlock
编译时与运行时检查:
BUILD_BUG_ON(...)
: 这些是编译时断言。它们检查内核中某些关键标志位的定义,确保PG_waiters
和PG_locked
的位号(bit number)符合某些硬件架构(如x86)的优化预期。如果定义不符,编译会直接失败。VM_BUG_ON_FOLIO(...)
: 这是一个运行时断言。它检查被解锁的folio是否真的处于锁定状态。如果尝试解锁一个未被锁定的folio,说明内核逻辑存在严重错误,会触发一个bug报告。
核心原子操作 (
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
。 - 优点: 通过一次原子操作,同时完成了“解锁”和“判断是否有等待者”两个任务,避免了“先解锁,再检查”可能导致的竞态条件(即在解锁后、检查前,一个新的等待者到来)。
- 这是
条件唤醒:
if (folio_xor_flags_has_waiters(...)) folio_wake_bit(...)
: 只有当原子操作告诉我们确实有等待者时,才需要调用相对“重”的唤醒函数folio_wake_bit
。如果没有任何等待者,folio_unlock
就在一次原子操作后快速返回,性能极高。
folio_wake_bit
这个函数负责执行实际的唤醒操作。
- 获取等待队列:
folio_waitqueue(folio)
会找到与这个folio关联的等待队列头(wait_queue_head_t
)。在内核中,为了节省内存,通常不是每个folio都有一个独立的等待队列,而是通过一个哈希表将多个folio映射到同一个等待队列上。 - 准备唤醒键 (
wait_page_key
):__wake_up_locked_key
是一个高级的唤醒函数,它允许进行选择性唤醒。wait_page_key
结构体就是“筛选条件”。我们只希望唤醒那些正在等待这个特定folio的**PG_locked
位**的进程,而不是唤醒哈希冲突到同一个等待队列上的其他folio的等待者。key
中设置了folio
指针和bit_nr
,就是为了这个目的。 - 执行唤醒:
__wake_up_locked_key(q, TASK_NORMAL, &key)
在持有等待队列的自旋锁的情况下,遍历队列,并唤醒所有与key
匹配的等待项。 - 清理
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 | // folio_wake_bit: 唤醒正在等待folio上特定比特位的任务。 |
Folio 等待队列:高效地管理对内存页的并发访问
本代码片段定义了 Linux 内核中用于管理对 folio
对象(代表一个或多个物理页的内存单元)的并发访问的等待队列机制。由于为每一个 folio
都创建一个独立的等待队列会消耗大量内存,内核采用了一种哈希表来共享等待队列。多个 folio
对象可能会被哈希到同一个等待队列上,但这通过辅助的键值匹配可以保证正确的唤醒,是内存效率和性能之间的折衷。
实现原理分析
本机制的核心思想是:并非每个 folio
都拥有一个专属的等待队列,而是多个 folio
共享同一个等待队列。
等待队列哈希表 (
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
属性确保数组中的每个元素都对齐到一个缓存行,以减少缓存行争用,提高多核性能。
哈希函数 (
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
数组中对应索引的等待队列头指针。
哈希冲突与精准唤醒:
- 由于多个
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
的同一个特定标志位。- 这种精确的唤醒机制避免了“误唤醒”带来的性能损失,保证了只有真正需要被唤醒的任务才会被唤醒。
- 由于多个
清空等待者标志:
- folio_clear_waiters
1 | /* |
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
): 执行借阅流程。
这个流程分为两种情况:
VIP加急服务 (Direct I/O): 读者(进程)如果出示了“加急卡”(
O_DIRECT
标志),图书馆员会绕过公共书架(Page Cache),直接去后台的书库(存储设备),让库管(mapping->a_ops->direct_IO
)把书的内容直接复印到读者的笔记本上。这个过程没有中间环节,速度可能很快,但对读者的笔记本(内存对齐、大小等)有严格要求。标准借阅流程 (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路径。
直接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),这是行不通的,所以会直接返回。
- 前提: 用户在
缓冲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 | /** |