1. 背景
在 Linux Block Driver - 2 中,我们在 Sampleblk 驱动创建了 Ext4 文件系统,并做了一个简单的 fio 测试。
本文将继续之前的实验,围绕这个简单的 fio 测试,探究 Linux 块设备驱动和文件 IO 的运作机制。除非特别指明,本文中所有 Linux 内核源码引用都基于 4.6.0。其它内核版本可能会有较大差异。若需对 Sampleblk 块驱动实现有所了解,请参考 Linux Block Driver - 1。
2. 准备
阅读本文前,可能需要如下准备工作,
- 了解 Linux Block Driver - 2 中所提的 fio 测试的环境准备,命令运行,还有性能分析方法。
- 了解 Flamegraph 如何解读。
在上篇文章中,通过使用 Flamegraph,我们把 fio 测试中做的 perf profiling 的结果可视化,生成了如下火焰图,
在新窗口中打开图片,我们可以看到,火焰图不但可以帮助我们理解 CPU 全局资源的占用情况,而且还能进一步分析到微观和细节。例如局部的热锁,父子函数的调用关系,和所占 CPU 时间比例。关于进一步的 Flamegraph 的介绍和资料,请参考 Brenden Gregg 的 Flamegraph 相关资源。
本文中,该火焰图将成为我们粗略了解该 fio 测试在 Linux 4.6.0 内核涉及到的文件 IO 内部实现的主要工具。
3. 深入理解文件 IO
在上一篇文章中,我们发现,尽管测试的主要时间都发生在 write 系统调用上。但如果查看单次系统调用的时间,fadvise64 远远超过了 write。为什么 write 和 fadvise64 调用的执行时间差异如此之大?如果对 Linux buffer IO 机制,文件系统 page cache 的工作原理有基本概念的话,这个问题并不难理解,
Page cache 加速了文件读写操作
一般而言,
write系统调用虽然是同步 IO,但 IO 数据是写入 page cache 就立即返回的,因此实际开销是写内存操作,而且只写入 page cache 里,不会对块设备发起 IO 操作。应用如果需要保证数据写到磁盘上,必需在write调用之后调用fsync来保证文件数据在fsync返回前把数据从 page cache 甚至硬盘的 cache 写入到磁盘介质。Flush page cache 会带来额外的开销
虽然 page cache 加速了文件系统的读写操作,但一旦需要 flush page cache,将集中产生大量的磁盘 IO 操作。磁盘 IO 操作比写 page cache 要慢很多。因此,flush page cache 非常费时而且影响性能。
由于 Linux 内核提供了强大的动态追踪 (Dynamic Trace) 能力,现在我们可以通过内核的 trace 工具来了解 write 和 fadvise64 调用的执行时间差异。
3.1 使用 Ftrace
Linux strace 只能追踪系统调用界面这层的信息。要追踪系统调用内部的机制,就需要借助 Linux 内核的 trace 工具了。Ftrace 就是非常简单易用的追踪系统调用内部实现的工具。
不过,Ftrace 的 UI 是基于 linux debugfs 的。操作起来有些繁琐。因此,我们用 Brendan Gregg 写的 funcgraph 来简化我们对 Ftrace 的使用。这个工具是基于 Ftrace 的用 bash 和 awk 写的脚本,非常容易理解和使用。关于 Brendan Gregg 的 perf-tools 的使用,请阅读 Ftrace: The hidden light switch 这篇文章。此外,Linux 源码树里的 Documentation/trace/ftrace.txt 就是极好的 Ftrace 入门材料。
3.2 open
运行 Linux Block Driver - 2 中的 fio 测试时,用 funcgraph 可以获取 open 系统调用的内核函数的函数图 (function graph),
$ sudo ./funcgraph -d 1 -p 95069 SyS_open
详细的 open 系统调用函数图日志可以查看 这里。
仔细察看函数图日志就会发现,open 系统调用打开普通文件时,并没有调用块设备驱动的代码,而只涉及到下面两个层次的处理,
VFS 层。
VFS 层的
open系统调用代码为进程分配 fd,根据文件名查找元数据,为文件分配和初始化struct file。在这一层的元数据查找、读取,以及文件的打开都会调用到底层具体文件系统的回调函数协助完成。最后在系统调用返回前,把 fd,和struct file装配到进程的struct task_struct上。要强调的是,
struct file是文件 IO 中最核心的数据结构之一,其内部包含了如下关键信息,文件路径结构。即
f_path成员,其类型为struct path。内核可以直接得到
vfsmount和dentry,即可唯一确定一个文件的位置。这个
vfsmount和dentry的初始化由do_last函数来搞定。如果文件以O_CREAT方式打开,则最终调用lookup_open来搞定。若无O_CREAT标志,则由lookup_fast来搞定。最终,dentry要么已经有对应的inode,这时dentry要么在 dentry cache 里,要么需要调用lookup方法 (Ext4 对应的ext4_lookup方法) 从磁盘上读取出来。还有一种情况,即文件是
O_CREAT方式打开,文件虽然不存在,也会由lookup_open调用vfs_create来为这个dentry创建对应的inode。而文件创建则需要借助具体文件系统的create方法,对 Ext4 来说,就是ext4_create。文件操作表结构。即
f_op成员,其类型为struct file_operations。文件 IO 相关的方法都定义在该结构里。文件系统通过实现该方法来完成对文件 IO 的具体支持。
当
vfsmount和dentry都被正确得到后,就会通过vfs_open调用do_dentry_open来初始化文件操作表。最终,实际上struct file的f_op成员来自于其对应dentry的对应inode的i_fop成员。而我们知道,inode都是调用具体文件系统的方法来分配的。例如,对 Ext4 来说,f_op成员最终被ext4_lookup或ext4_create初始化成了ext4_file_operations。文件地址空间结构。即
f_mapping成员,其类型为struct address_space。地址空间结构内部包括了文件内存映射的内存页面,和其对应的地址空间操作表
a_ops(类型为struct address_space_operations),都在其中。与
f_op成员类似,f_mapping成员也通过vfs_open调用do_dentry_open来初始化。而地址空间结构内部的地址空间操作表,也是通过具体文件系统初始化的。例如,Ext4 上,该地址空间操作表被ext4_lookup或ext4_create初始化为ext4_da_aops。当
open调用返回时,用户程序通过fd就可以让内核在文件操作时访问到对应的struct file,从而可以定位到具体文件,做相关的 IO 操作了。在 open 结束时,在
vfs_open实现里检查是否实现了open方法,若实现就调用到具体文件系统的open方法。例如,Ext4 文件系统的ext4_file_open方法。很多文件系统根本不会实现自己的
open方法,这时就不悔调用open方法。或者,一些文件系统将该方法初始化为generic_file_open。现在的ext4_file_open函数最终会在返回前调用 VFS 层的generic_file_open。 而generic_file_open只检查是否在 32 位系统上打开大文件而导致 overflow 的情况,在 64 位系统上,generic_file_open相当于空函数。
具体文件系统层。
以 Ext4 为例,Ext4 注册在 VFS 层的入口函数被上层调用,为上层查找元数据 (
ext4_lookup),创建文件 (ext4_create),打开文件 (ext4_file_open) 提供服务。当 dentry cache 里查找不到对应文件的
dentry结构时,需要从磁盘上查找。这时调用ext4_lookup方法来查找。当文件不再磁盘上,因为
O_CREAT标志被设置,需要创建文件时,调用ext4_create创建文件。当文件被定位后,最终调用
f_op成员的open方法,即ext4_file_open。
本例中的
fio测试里,由于文件已经被创建,而且元数据已经缓存在内存中,因此,只涉及到ext4_file_open的代码。早期 Ext4 代码并未实现自己独特的open方法。后来 Ext4 为了管理员提供了记录上次 mount 点的功能,引入了自己的ext4_file_open函数。而ext4_file_open逐渐也加入了其它边缘功能。该函数最终会在返回前调用 VFS 层的generic_file_open。
3.3 fadvise64
用 funcgraph 也可以获取 fadvise64 系统调用的内核函数的函数图 (function graph),
$ sudo ./funcgraph -d 1 -p 95069 SyS_fadvise64
详细的 fadvise64 系统调用的跟踪日志请查看这里。
根据 fadvise64(2),这个系统调用的作用是预先声明文件数据的访问模式。
从前面 strace 的日志里我们得知,每次 open 文件之后,fio 都会调用两次 fadvise64 系统调用,只不过两次的 advise 参数使用有所差别,因而起到的作用也不同。下面就对本实验中涉及到的两个 advise 参数做简单介绍。
3.3.1 POSIX_FADV_SEQUENTIAL
POSIX_FADV_SEQUENTIAL 主要是应用用来通知内核,它打算以顺序的方式去访问文件数据。
如果参考我们获得的 fadvise64 系统调用的函数图,再结合源码,我们知道,POSIX_FADV_SEQUENTIAL 的实现非常简单,主要有以下两方面,
- 把 VFS 层文件的预读窗口增大到默认的两倍。
- 把文件对应的内核结构
struct file的文件模式file->f_mode的随机访问FMODE_RANDOM标志位清除掉。从而让 VFS 预读算法对顺序读更高效。
由于以上操作仅仅涉及简单内存访问操作,因此在 fadvise64 系统调用的函数图里,我们可以看到它仅仅用了 0.891 us 就返回了,远远快于另外一个命令。
综上所述,POSIX_FADV_SEQUENTIAL 操作的代码路径下,全都是对文件系统预读的优化。而本文中的 fio 测试只有顺序写操作,因此,POS IX_FADV_SEQUENTIAL 的操作对本测试没有任何影响。
3.3.2 POSIX_FADV_DONTNEED
POSIX_FADV_DONTNEED 则是应用通知内核,与文件描述符 fd 关联的文件的指定范围 (offset 和 len 描述)的 page cache 都不需要了,脏页可以刷到盘上,然后直接丢弃了。
Linux 提供了全局刷 page cache 到磁盘,然后丢弃 page cache 的接口: /proc/sys/vm/drop_pagecache。然而 fadvise64 的 POSIX_FADV_DONTNEED 作用域是文件内的某段范围,具有更细的粒度。
在我们 fadvise64 系统调用的跟踪日志里,调用图关系最复杂,返回时间最长的就是这个命令了。但如果参考其源码实现,其实该命令主要分为两大步骤,
- 回写 (Write Back) 页缓存。
- 清除 (Invalidate) 页缓存。
3.3.2.1 回写页缓存
回写 (Write Back) 文件内部指定范围的 dirty page cache 到磁盘。
首先,检查是否符合回写的触发条件,然后调用 __filemap_fdatawrite_range 对文件指定范围回写。如果文件 inode 回写拥塞位被置位的话,则跳过回写操作。这时,fadvise64 系统调用还会在第 2 步时,尽量清除回收文件所属的 Page Cache。这个回写拥塞控制是 Cgroup Write Back 特性的一部分。
如果可以回写,调用 __filemap_fdatawrite_range。 该函数支持时以下两种同步写模式,
WB_SYNC_ALL指示代码在回写页面时,遇到某个页已经正在被别人回写时,睡眠等待。这样可以保证数据的完整性。因此fsync或者msync这类调用必须使用这个同步模式。WB_SYNC_NONE指示代码遇到某个页被别人回写时,跳过该页而避免等待。这种方式通常只用于内存回收的时候。
不论设置为上述哪种方式,页面回写都是同步的。也就是说,当磁盘 IO 结束返回之前,回写会等待。两者的差别仅仅是当有页面正在其它 IO 上下文时,是否要跳过。
POSIX_FADV_DONTNEED 的主要目的是清除 page cache,因此它使用了 WB_SYNC_NONE。
完整的脏页回写过程经历了以下 5 个层次,
VFS 层
如前所述,
fadvise64系统调用使用的__filemap_fdatawrite_range, 是 VFS 层的函数。VFS 层最终会使用 MM 子系统提供的页缓存回写函数do_writepages来完成回写。MM 子系统
函数
do_writepages最终会根据文件系统是否对该地址空间struct address_space的a_ops操作表是否初始化了writepages成员来决定页回写的处理。主要有以下两种情况,未初始化
writepages成员。
这时由 MM 子系统的generic_writepages调用write_cache_pages来遍历文件地址空间内的脏页,并最终调用具体文件系统的writepage回调来对每一个页做写 IO。初始化了
writepages成员。
由于具体文件系统模块已经初始化了writepages成员,则页缓存回写由具体文件系统的writepages的回调来直接处理。
本文实验环境中,属于第二种情况,即
writepages成员已经被 Ext4 初始化。具体文件系统层
本实验里使用的 Ext4 文件系统在
struct address_space_operations里已经把writepages初始化为ext4_writepages。因此,回写缓存的处理会由该函数来完成。函数
ext4_writepages处理页缓存回写的要点如下,当 Ext4 文件系统以
data=journal方式 mount 时在函数一开始就检查,如果是
data=journal方式,使用 MM 子系统的write_cache_pages来做页缓存的 page cache 处理。这条代码路径的实际效果和文件系统不实现writepages成员的处理是一样的。最终write_cache_pages还会使用 Ext4 的另一个writepage回调,即ext4_writepage来对单个脏页做 IO 操作。实际上,在早期的内核版本,Ext4 会根据 mount 是否支持或者使用了 delalloc ((Delay Allocation) 特性,来决定使用不同的struct address_space_operations的操作表声明。根据 mount 模式是否支持 delalloc 特性,Ext4 的缓存回写使用了不同的入口函数。关闭 Delay Allocation 特性时,不初始化writepages,就都使用write_cache_pages。但 Linux 3.11 版本开始 Ext4 使用统一的
ext4_writepages入口函数处理缓存回写。这个改动使得data=ordered模式下,即使 Delay Allocation 特性是关闭的,也会使用ext4_writepages方式,而不使用write_cache_pages方式。当 Ext4 使用非
data=journal方式 mount 时例如
data=ordered或data=writeback。本文中就是 Ext4 缺省模式,data=ordered。调用
mpage_prepare_extent_to_map。找到连续的还未在磁盘上建立块映射的脏页,把它们加入extent并调用mpage_map_and_submit_extent来映射和提交这些脏页。
如果脏页已经在磁盘上有块影射了,则直接提交这些页面。两种情况最终都会调用ext4_bio_write_page将要提交 IO 的页面加入到struct ext4_io_submit成员io_bio的bio结构里。通过
ext4_io_submit调用submit_bio,从而把之前提交到struct ext4_io_submit成员io_bio里的bio结构提交给通用块层。
篇幅有限,这里不再对 delalloc 特性做更详细的解读。
通用块层
Ext4 的
ext4_writepages使用了以下通用块层的机制或接口,Plug (蓄水) 机制。
对应函数为blk_start_plug,会在提交 IO 请求之前,在当前进程的task_struct里初始化一个列表,用于通用块层的 IO 请求排队。随后通过submit_bio提交给通用块层的bio请求,都在通用块层排队,而不立刻下发给更低层的块驱动。这个过程被叫做 Plug (蓄水)。submit_bio接口。
在调用submit_bio提交bio给通用块层之后,通用块层会调用generic_make_request。在此函数内,通过调用blk_queue_bio根据bio来构造 IOrequest,把或者将bio合并到一个已经存在的request里。这时的request可以在当前任务的plug列表里,或在块设备的request_queue队列里。当新提交的bio被构造或者合并入一个 IOrequest以后,这些request并不是立刻被发送给下层的块驱动程序,而是在plug列表或者request_queue里缓存,submit_bio会直接返回。Unplug (排水) 机制。
对应函数为blk_finish_plug或blk_flush_plug_list,该函数调用__blk_run_queue将所有 IO 请求都交给块驱动程序发送。Unplug 机制在以下两个时机会被触发,
- 在通用块层,如果
blk_queue_bio发现当前任务的plug列表里蓄积了足够多的 IOrequest,这时通用块层会主动触发 Unplug 机制,调用块驱动程序做真正的 IO 操作。 - Ext4 文件系统在完成所有回写操作后,主动触发 Unplug 操作。
- 在通用块层,如果
具体块驱动。
最终,通用块驱动的策略函数被调用来发送 IO 请求。在 Linux Block Driver - 1 中,我们知道,本实验中的 Sampleblk 块驱动的策略函数为
sampleblk_request。这个函数的实现在那篇文章里有详细的讲解。
上述 5 个层次中提到的函数名称都可以在前面提到的 fadvise64函数图的日志里找到。由于内部调用关系很复杂,另一个直观和简单的方式就是查看前面章节中保存的火焰图。
3.3.2.2 清除页缓存
将文件对应的指定范围的 page cache 尽可能清除 (Invalidate)。尽可能,就意味着这个清除操作可能会跳过一些页面,例如,跳过已经被加锁的页面,从而避免因等待 IO 完成而阻塞。其主要过程如下,
- 利用
pagevec_lookup_entries对范围内的每个页面调用invalidate_inode_page操作清除缓存页面。在此之前,使用trylock_page来尝试锁页。如果该页已经被锁住,则跳过该页,从而避免阻塞。由于 Ext4 为每一个属于 page cache 的页面创建了与之关联的 meta data, 因此这个过程中还需要调用releasepage回调,即ext4_releasepage来进行释放。 - 调用
pagevec_release减少这些页面的引用计数。如果之前的清除操作成功,此时页面引用计数为 0,会将页面从 LRU 链表拿下,并释放页面。释放后的页面被归还到 per zone 的 Buddy 分配器的 free 链表里。
3.4 write
用 funcgraph 也可以获取 write 系统调用的内核函数的函数图 (function graph),
$ sudo ./funcgraph -d 1 -p 95069 SyS_write
详细的 write 系统调用的跟踪日志请查看这里。
由于我们的测试是 buffer IO,因此,write 系统调用只会将数据写在文件系统的 page cache 里,而不会写到 Sampleblk 的块设备上。
系统调用 write 过程会经过以下层次,
VFS 层。
内核的
sys_write系统调用会直接调用vfs_write进入到 VFS 层代码。VFS 层为每个文件系统都抽象了文件操作表struct file_operations。如果write回调被底层文件系统模块初始化了,就优先调用write。否则,就调用write_iter。这里不得不说,基于 iov_iter 的接口正在成为 Linux 内核处理用户态和内核态 buffer 传递的标准。而
write_iter就是基于iov_iter的新的文件写 IO 的标准入口。Linux 内核 IO 和网络栈的各种与用户态 buffer 打交道的接口都在被iov_iter的新接口所取代。进一步讨论请阅读 The iov_iter interface。此外,Linux 4.1 中,原有的
aio_read和aio_write也都被删除,取而代之的正是read_iter和write_iter。其中write_iter相关的要点如下,write_iter既支持同步 (sync) IO,又支持异步 (async) IO。- Linux 内核的同步 IO,即
sys_write系统调用,最终通过new_sync_write来调用write_iter。 new_sync_write是通过调用init_sync_kiocb来调用write_iter的。这使得底层文件系统可以通过is_sync_kiocb来判断当前发起的 IO 是同步还是异步。而is_sync_kiocb主要判断依据是struct kiocb的ki_complete的取值为 NULL,即没有设置完成回调。- Linux 内核的异步 IO,则通过
sys_io_submit系统调用来调用write_iter。而此时异步 IO 恰恰设置了ki_complete的回调为aio_complete。
具体文件系统和 MM 子系统
Linux 4.1 之后,文件系统在声明文件操作表
struct file_operations时,若要支持读写,可以不实现标准的read和write,但一定要实现read_iter和write_iter。本文中的 Ext4 文件系统,只实现了write_iter入口`,即ext4_file_write_iter。对一些特殊情况做处理之后,MM 子系统的 入口函数__generic_file_write_iter被调用。在 MM 子系统的处理逻辑里,主要有以下两大分支,Direct IO。
如果文件打开方式为 O_DIRECT,这时kiocb的ki_flags被设置为 IOCB_DIRECT。此标志作为 Direct IO 模式检查的依据。而 MM 子系统的generic_file_direct_write会再次调用文件系统的地址空间操作表的direct_IO方法,在 Ext4 里就是ext4_direct_IO。在 Direct IO 上下文,文件系统可以通过
is_sync_kiocb来判断是否是同步 IO 或异步 IO,然后进入到具体的处理逻辑。本文中的实验,fio 使用的是 Buffer IO,因此这部分不进行详细讨论。Buffer IO。
使用 Buffer IO 的时候,文件系统的数据都会写入到文件系统的 page cache 后立即返回。这也是本文中测试实验的情况。MM 子系统的generic_perform_write方法会做如下处理,- 调用文件系统的
write_begin方法。Ext4 就是ext4_da_write_begin。
此函数通过调用grab_cache_page_write_begiin来分配新的 page cache。然后调用ext4_map_blocks在磁盘上分配新的,或者映射已存在的 block。 - 调用
iov_iter_copy_from_user_atomic把sys_write系统调用用户态 buffer 里的数据拷贝到write_begin方法返回的页面。 - 调用文件系统的
write_end方法,即ext4_da_write_end。该函数最终会将写完的buffer_head标记为 dirty。
- 调用文件系统的
3.5 close
用 funcgraph 也可以获取 close 系统调用的内核函数的函数图 (function graph),
$ sudo ./funcgraph -d 1 -p 95069 SyS_close
详细的 close 系统调用的跟踪日志请查看这里。
close 系统调用主要分两个阶段,
系统调用部分。首先,由
close系统调用的参数fd,可以得到其对应的struct file结构。而该结构里定义了该文件的文件操作表。如果文件系统实现了该文件操作表的flush方法,则调用该方法,保证所有与该文件相关的 pending operations 都可以在close调用返回前结束。最后通过schedule_delayed_work来让内核延迟执行delayed_fput处理函数。需要指出的是,这种关闭时自动执行的
flush方法不是必须的,很多文件系统,例如 Ext4 并不支持该方法。事实上,应用程序通常是主动调用fsync系统调用,从而调用文件系统的fsync方法来保证数据落盘。延迟执行部分。为防止死锁等不可预期情况发生,保证
fput操作可以被安全的执行,内核实现了 delay fput 的机制。该机制利用了task_work_add机制,保证延迟执行的fput操作可以在close系统调用返回到用户进程代码执行前,先被执行和立即返回。真正的fput实现会调用文件操作表的fasync和release方法,来实现文件系统层面上所需要的处理。最后,struct file结构会被彻底释放掉。Linux 3.6 开始,fput 的实现开始迁移到
task_work_add之上。而这个机制和一般异步执行机制如 work queue 最大的区别在于,该机制保证延迟执行的函数会在用户进程从系统调用代码返回到用户空间时被同步的执行。这样可以保证close语义不会被改变,其所有内核操作都会在用户进程取得控制权前被完成。以 Ext4 为例,它并没有实现
fasync方法,但却实现了release方法,即ext4_release_file。该方法与open方法不同的是,open方法每次打开文件都必须被调用,但release方法只有在最后一个关闭文件的操作执行时被调用。此时,当文件系统以auto_da_alloc为 mount option 时,ext4_release_file会调用filemap_flush来保证在close前,延迟分配的块可以被写入到磁盘。关于auto_da_alloc的目的和意义,请阅读相关内核文档。
4. 实验
运行 Linux Block Driver - 2 中的 fio 那个测试时,我们也可以利用 Linux Crash 工具来检查文件 IO 涉及的关键数据结构。Redhat 自带的 Crash 版本无法支持 4.6.0 内核,为此,您可能需要参考 Linux Crash - coding notes 最后一小节,重新编译新版 crash。
按照如下步骤,即可在运行 fio 时,以只读方式查看内核数据结构,
进入 crash,以只读方式查看内核。
$ sudo ./crash
用
foreach files查看哪些进程打开了 /mnt/test 文件。可以看到,两个fio任务分别各自打开了该文件,crash> foreach files -R /mnt/test
PID: 19670 TASK: ffff8800000715c0 CPU: 1 COMMAND: “fio”
ROOT: / CWD: /ws/mytools/test/fio
FD FILE DENTRY INODE TYPE PATH
3 ffff880027172400 ffff880027678c00 ffff880077bf99a8 REG /mnt/testPID: 19671 TASK: ffff880035284140 CPU: 0 COMMAND: “fio”
ROOT: / CWD: /ws/mytools/test/fio
FD FILE DENTRY INODE TYPE PATH
3 ffff880027170500 ffff880027678c00 ffff880077bf99a8 REG /mnt/test可以看到,输出中分别给出了两个 /mnt/test 文件的各自的
struct file,struct dentry和struct inode的数据结构地址。利用上一步得到的地址,查看
struct file的文件操作表地址和地址空间地址,crash> struct file.f_op,f_mapping ffff880027172400
f_op = 0xffffffffa0777940
f_mapping = 0xffff880077bf9b10利用之前得到的地址,查看
struct dentry的d_name和d_inode地址。可以看出,输出与之前的结构吻合。crash> dentry.d_name,d_inode ffff880027678c00
d_name = {
{
{
hash = 3378392626,
len = 4
},
hash_len = 20558261810
},
name = 0xffff880027678c38 “test”
}
d_inode = 0xffff880077bf99a8利用之前得到的地址,查看
struct inode的文件操作表地址和地址空间地址,发现与之前struct file的文件操作表地址和地址空间地址是一致的。如前所述,这是因为,struct file的这两个操作表就是从struct inode对应的成员复制过来的。crash> inode.i_fop,i_mapping ffff880077bf99a8
i_fop = 0xffffffffa0777940
i_mapping = 0xffff880077bf9b10根据地址空间地址,可以查看其对应的地址空间操作表地址,
crash> address_space.a_ops 0xffff880077bf9b10
a_ops = 0xffffffffa0777ec0由于 /mnt/test 在 Ext4 文件系统中创建,因此文件系统操作表被初始化为
ext4_file_operations。其内容可以打印如下,crash> p ext4_file_operations
ext4_file_operations = $10 = {
owner = 0x0,
llseek = 0xffffffffa07230c0 ,
read = 0x0,
write = 0x0,
read_iter = 0xffffffff81191340 ,
write_iter = 0xffffffffa0722a40 ,
iterate = 0x0,
poll = 0x0,
unlocked_ioctl = 0xffffffffa0730f70 ,
compat_ioctl = 0xffffffffa0732380 ,
mmap = 0xffffffffa0722940 ,
open = 0xffffffffa0722780 ,
flush = 0x0,
release = 0xffffffffa0723470 ,
fsync = 0xffffffffa0723530 ,
aio_fsync = 0x0,
fasync = 0x0,
lock = 0x0,
sendpage = 0x0,
get_unmapped_area = 0x0,
check_flags = 0x0,
flock = 0x0,
splice_write = 0xffffffff81249100 ,
splice_read = 0xffffffff81249d30 ,
setlease = 0x0,
fallocate = 0xffffffffa075c920 ,
show_fdinfo = 0x0,
copy_file_range = 0x0,
clone_file_range = 0x0,
dedupe_file_range = 0x0
}而 /mnt/test 对应的地址空间操作表内容也可以打印如下,
crash> p ext4_da_aops
ext4_da_aops = $11 = {
writepage = 0xffffffffa072b030 ,
readpage = 0xffffffffa0726f90 ,
writepages = 0xffffffffa072c0b0 ,
set_page_dirty = 0x0,
readpages = 0xffffffffa0726b40 ,
write_begin = 0xffffffffa072dbf0 ,
write_end = 0xffffffffa072e7d0 ,
bmap = 0xffffffffa0727ed0 ,
invalidatepage = 0xffffffffa0727b90 ,
releasepage = 0xffffffffa07266f0 ,
freepage = 0x0,
direct_IO = 0xffffffffa07281b0 ,
migratepage = 0xffffffff811f9370 ,
launder_page = 0x0,
is_partially_uptodate = 0xffffffff8124d950 ,
is_dirty_writeback = 0x0,
error_remove_page = 0xffffffff811a08e0 ,
swap_activate = 0x0,
swap_deactivate = 0x0
}
仔细对比前面几小节内容,我们即可清除直观的了解到,Ext4 文件系统是如何初始化 VFS 层提供的文件操作表和地址空间操作表的。
关于 Linux Crash 命令的使用,请参考延伸阅读的相关文章。
5. 小结
本文基于 fio 的测试用例,通过 Flamegraph 和 functongraph 等工具,结合源代码来进一步理解其中涉及到的文件 IO 操作。一般说来,从学习源码角度看,Flamegraph 更适合对被研究的代码和功能做一个全局的疏理,而 functongraph 则更适合研究代码实现的细节。
通过这些简单的了解,我们可以对 write 和 fadvise64 做较为深入地比较,以充分了解为什么两个函数在执行时间上有如此大差异。
此外,我们也可以借助 Linux Crash 工具,对 Linux 内核的关键数据结构以只读方式查看,帮助我们直观了解内核实现。进一步信息,请参考延伸阅读章节列出的文章。
6. 延伸阅读
- Linux Block Driver - 1
- Linux Block Driver - 2
- Linux Perf Tools Tips
- Using Linux Trace Tools - for diagnosis, analysis, learning and fun
- Flamegraph 相关资源
- Ftrace: The hidden light switch
- Device Drivers, Third Edition
- Ftrace: Function Tracer
- The iov_iter interface
- Toward a safer fput
- Linux Crash - background
- Linux Crash - coding notes
- Linux Crash White Paper (了解 crash 命令)