前言
IO是操作系统内核最重要的组成部分之一,它的概念很广,本文主要针对的是普通文件与设备文件的IO原理,采用自顶向下的方式,去探究从用户态的fread,fwrite函数到底层的数据是如何被读出和写入磁盘的的全过程。希望通过本文,能梳理出一条清晰的linux io过程的脉络。
文件与存储
普通文件与设备文件的IO集中解决了数据的存储与持久化的问题。正如进程是内核对CPU的抽象,虚拟内存是对物理内存的抽象,文件是对所有IO对象的抽象(在本文,则主要是指对磁盘的抽象)。为了解决数据存储与持久化问题,有很多不同类型的文件系统(ext2,ext3,ext4,xfs,…),它们大多数是工作在内核态的,也有少数的用户态文件系统(fuse)。linux为了便于管理这些不同的文件系统,提出了一个虚拟文件系统(VFS)的概念,对这些不同的文件系统提供了一套统一的对外接口。本文将会从虚拟文件系统说起,自顶向下地去阐述io的每一层的概念。
linux io体系结构
本文将按照上图的架构自顶向下依次分析每一层的要点。
从Hello world说起
|
|
看一个简单的hello world程序,它的功能非常简单,就是在栈空间里分配4096个字节作为buffer,从helloworld.in文件里读4KB到该buffer里,然后将该buffer的数据写到helloworld.out文件中。这个hello world进程是工作于用户态的,但由于操作系统的隔离性,用户态程序是无法直接操作硬件的,所以要通过read,write系统调用进入内核态,执行内核的相应代码。
现在我们看看read,write系统调用做了什么。
|
|
我们可以很清楚地看到,read,write系统调用实际上就是对vfs_read和vfs_write的一个封装,非常直接地进入了虚拟文件系统(VFS)这一层。
虚拟文件系统(VFS)
在深入vfs_read和vfs_write函数之前,必须先介绍一下vfs的基本知识。
先思考一个问题,如果我们自己要实现一个文件系统,需要做什么?先不管实现细节,对外我们总得定义自己的文件系统接口吧,只有有了相应的函数接口,用户程序才能进行读写操作。所以,最基本最基本我们也得有自己的读和写接口。
但是,如果每一家文件系统都定义自己的一套接口,对于上层应用来说就很难去管理。在这样的时代背景下,vfs应运而生。vfs为不同的文件系统提供了统一的对上层的接口,使得上层应用在调用的时候无需知道底层的具体文件系统,只有在真正执行读写操作的时候才调用相应文件系统的读写函数。这个思想与面向对象编程的多态思想是非常相似的。实际上,vfs就是定义了4种类型的基本对象,不同的文件系统要做的工作就是去实现具体的这4种对象。下面介绍一下这4种对象。这4种对象分别是superblock,inode,dentry和file。file对象就是我们用户进程打开了一个文件所产生的,每个file对象对应一个唯一的dentry对象。dentry代表目录项,是跟文件路径相关的,一个文件路径对应唯一的一个dentry。dentry与inode则是多对一的关系,inode存放单个文件的元信息,由于文件可能存在链接,所以多个路径的文件可能指向同一个inode。superblock对象则是整个文件系统的元信息,它还负责存储空间的分配与管理。它们的关系可以用下图表示:
superblock对象
superblock对象定义了整个文件系统的元信息,它实际上是一个结构体。
|
|
这里字段非常多,我们没必要一一解释,有个大概的感觉就行。有几个字段比较重要的这里提一下:
s_list
该字段是双向循环链表相邻元素的指针,所有的superblock对象都以链表的形式链在一起。s_fs_info
字段指向属于具体文件系统的超级块信息。很多具体的文件系统,例如ext2,在磁盘上有对应的superblock的数据块,为了访问效率,s_fs_info
就是该数据块在内存中的缓存。这个结构里最重要的是用bitmap形式存放了所有磁盘块的分配情况,任何分配和释放磁盘块的操作都要修改这个字段。s_dirt
字段表示超级块是否是脏的,上面提到修改了s_fs_info
字段后超级块就变成脏了,脏的超级块需要定期写回磁盘,所以当计算机掉电时候是有可能造成文件系统损坏的。s_dirty
字段引用了脏inode链表的首元素和尾元素。s_inodes
字段引用了该超级块的所有inode构成的链表的首元素和尾元素。s_op
字段封装了一些函数指针,这些函数指针就指向真实文件系统实现的函数,superblock对象定义的函数主要是读、写、分配inode的操作。多态主要是通过函数指针指向不同的函数实现的。
inode对象
inode对象定义了单个文件的元信息,例如最后修改时间、最后访问时间等等,同时还定义了一连串函数指针,这些函数指针指向具体文件系统的对文件操作的函数,可以说文件系统最核心的功能全部由inode的函数指针提供接口,包括常见的open,read,write,sync,close等等文件操作,都在inode的i_op
字段里定义了统一的接口函数。
|
|
除了i_op
外,介绍几个重要的字段:
i_state
表示inode的状态,主要是表示inode是否是脏的。一般的文件系统在磁盘上都有相应的inode数据块,内核的inode结构便是这个磁盘数据块的内存缓存,所以与superblock一样,也是需要定期写回磁盘的,否则会导致数据丢失。i_list
把操作系统里的某些inode用双向循环链表连接起来,该字段指向相应链表的前一个元素和后一个元素。内核中有好几个关于inode的链表,所有inode必定出现在其中的某个链表内。第一个链表是有效未使用链表,链表里的inode都是非脏的,并且没有被引用,仅仅是作为高速缓存存在。第二个链表是正在使用链表,inode不为脏,但i_count
字段为整数,表示被某些进程引用了。第三个链表是脏链表,由superblock的s_dirty
字段引用。i_sb_list
存放了超级块对象的s_inodes
字段引用的链表的前一个元素和后一个元素。- 所有的inode都存放在一个inode_hashtable的哈希表中,key是inode编号和超级块对象的地址计算出来的,作为高速缓存。因为哈希表可能会存在冲突,
i_hash
字段也是维护了链表指针,就指向同一个哈希地址的前一个inode和后一个inode。
dentry对象
dentry代表目录项,因为每一个文件必定存在于某个目录内,我们通过路径去查找一个文件,最终必定最终找到某个目录项。在linux里,目录与普通文件一样,往往都是存放在磁盘的数据块中,在查找目录的时候就读出该目录所在的数据块,然后去寻找其中的某个目录项。如果不存在硬链接,其实dentry是没有必要的,仅仅通过inode就能确定文件。但多个路径有可能指向同一个文件,所以vfs还抽象出了一个dentry的对象,一个或多个dentry对应一个inode。
|
|
介绍几个重要的字段:
d_inode
指向该dentry对应的inode,找到了dentry就可以通过它找到inode。- 同一个inode的所有dentry都在一个链表内,
d_alias
指向该链表的前一个和后一个元素。 d_op
定义了一些关于目录项的函数指针,指向具体文件系统的函数。
目录项高速缓存
按照我们平时使用linux的经验,我们做的最常用的操作是什么?是执行各种shell命令,由于执行命令要通过该命令对应的可执行文件的路径找到该可执行文件,所以根据路径查找文件无处不在。前面提到,文件系统的目录数据与普通文件的数据一样,都是存放在磁盘数据块内,如果每一级路径都要读取磁盘,性能肯定非常低。因此,dentry必须放在高速缓存中加速。
vfs把所有的dentry都放到dentry_hashtable这个哈希表中,key就是由目录项对象和文件名哈希产生。
同时,由于内存有限,不可能把所有查找过的dentry都放到缓存中,还必须有缓存释放的机制。目录项高速缓存采用LRU双向链表来进行缓存添加与释放。所有未使用的dentry(没有引用,仅作为缓存)都会加到一个LRU双向链表中,从链表首部插入,一旦高速缓存的空间变小,就从尾部删除元素。与之对应,所有正在使用的dentry都会插入到相应inode的i_dentry
字段所引用的链表中,d_alias
字段则指向链表相邻的元素。
dentry_hashtable的大小是与机器的内存成正比的,缺省是每MB内存有256个元素。
file对象
作为应用程序的开发者或使用者,我们平时能接触到的vfs对象就只有file对象。我们平常说的打开文件,实际上就是让内核去创建一个file对象,并返回给我们一个文件描述符。出于隔离性的考虑,内核不可能把file对象的地址传给我们,我们只能通过文件描述符去间接地访问file对象。
|
|
f_inode
指向对应的inode对象。f_dentry
指向对应的dentry对象。f_pos
表示当前文件的偏移,可见文件偏移是每个file对象都有自己的独立的文件偏移量。f_op
表示当前文件相关的所有函数指针,实际上在文件open的时候f_op
会全部赋值为i_op
相应的函数指针。
由于file对象是由内核进程直接管理的,我们有必要了解一下进程如何管理打开的文件。
首先,每个进程有一个fs_struc的字段:
|
|
我们看到,每个进程都维护一个根目录和当前工作目录的信息,每一个目录由vfsmount
和dentry
组合唯一确定,dentry
代表目录项前面已经说到,vfsmount
则代表相应目录项所在文件系统的挂载信息,会在后面展开介绍一下。
然后,每个进程都有当前打开的文件表,存放在进程的files_struct结构中。
|
|
我们只要关注一下fd_array这个数组就行,这个数组就存储了所有打开的file对象,我们应用程序拿到的文件描述符实际上就只是这个数组的索引。
vfs管理文件系统的注册与挂载
前面介绍了vfs的4种对象,所有能在linux上使用的文件系统都必须实现这4种对象接口,实际上就是实现s_op,i_op,d_op
这3组函数(f_op
简单地复制i_op
),这样vfs才能正常地使用文件系统进行工作。假设我们已经按照这个接口开发了一套文件系统,这时候又面临了一个问题,vfs怎么去识别我们的文件系统呢?我们的文件系统是如何注册和挂载到vfs上的呢?
文件系统注册
文件系统要么是固化在内核代码中的,要么是通过内核模块动态加载的,在内核代码中的随着操作系统启动会自动注册,而通过内核模块动态加载的也可以用操作系统的启动参数配置成自动注册,或者我们可以人为地执行类似这样的命令insmod fuse.ko
去动态注册,这里的fuse.ko就是fuse文件系统编译链接出来的二进制文件。
|
|
在注册文件系统的时候,我们需要提交一个file_system_type
,这个对象主要有一个get_sb
(linux kernel 2.6)或者是mount
(linux kernel 3.1)对象,这个是一个函数指针,主要是分配superblock的,每当该类型的文件系统挂载时,就会调用该函数分配superblock对象。fs_supers
引用了所有属于该文件系统类型的superblock对象。内核把所有注册的文件系统类型维护成一个链表,file_system_type
的next
字段指向链表的下一个元素。
文件系统挂载
正常情况下,操作系统启动后,常用的文件系统类型都是自动注册的,不需要用户干预。但一个块设备要以某文件系统的形式被操作系统识别的话,需要挂载到某个目录下,例如执行如下的挂载命令:
|
|
当执行这条命令以后,内核会首先分配一个vfsmount
的对象,该对象唯一标识一个挂载的文件系统。
|
|
vfsmount
主要存放了该文件系统的superblock对象以及该文件系统根目录(上例的/var/cold-storage/)的dentry对象,一开始superblock对象是空的。
有可能这个文件系统会被挂载了多次,之前已经被挂载到其他目录上了,就意味着其superblock对象已经被分配,因此内核会先搜索file_system_type
的fs_supers
链表,如果找到,就直接用该superblock对象赋值给新的vfsmount
对象的mnt_sb
字段。
如果这个文件系统是第一次被挂载,则调用注册的file_system_type
的get_sb
或者mount
函数,分配新的superblock对象。
当superblock对象确定了以后,该文件系统就被挂载成功,可以正常使用了。
所有的vfsmount都会存放在mount_hashtable的哈希表中,key是vfsmount地址以及dentry地址计算出来的哈希值。
以open系统调用为例小结vfs的基本知识
在继续探究vfs_read
和vfs_write
之前,先通过open系统调用去串连一下vfs层的4个对象,小结一下前面的内容。因为open调用仅仅涉及到vfs层,跟磁盘高速缓存以及具体文件系统的实现基本无关。
回忆一下前面的hello world例子,在进行文件拷贝前,先要open文件:
|
|
这里核心的任务就是要通过传入的路径参数,最终创建出vfs的file对象。file对象确定了以后,意味着对应的inode,dentry和superblock也确定了,4大vfs对象全都准备后,可以接受读写请求了。最后返回其在内核进程的文件打开数组里的索引号给上层用户进程。
具体步骤如下:
路径查找
首先进行路径查找,调用path_lookup()
函数。该函数主要接受两个参数,一个是路径名,一个是nameidata类型的结构体,这个结构体有一个比较重要的字段是path,主要分析这个字段,在路径查找的过程中会不断修改这个字段,最后这个字段就代表路径查找的最终结果。该字段利用vfsmount
和dentry
唯一确定了某个路径。
|
|
- 首先判断路径是绝对路径还是相对路径,决定用进程的root还是pwd字段去填充这个path结构体,作为起始参数。
- 用/去划分路径,依次解析每一层路径,对于每一层路径,首先找出其目录项的dentry对象,大概率会在目录项高速缓存中命中,如果缓存中没有,则读取磁盘,然后放到缓存中,并更新path字段。
- 检查该层目录的dentry是否是某文件系统的挂载点,如果是,则用当前path的
vfsmount
和dentry
计算哈希值,找出mount_hashtable中的子文件系统的vfsmount
和dentry
,并更新path的vfsmount
和dentry
。 - 直到把所有分路径都解析完成,获得了最后的path。
创建file对象
找到了目的路径的vfsmount
和dentry
,inode和superblock对象也相应确定了,剩下的工作就是分配一个新的文件对象,并把相应的字段用inode,dentry和vfsmount的地址去填充。
把file对象插入到当前进程的fd_array中,返回文件描述符。
vfs_read, vfs_write
现在,我们已经有了足够的vfs知识,可以探索一下前面hello_world程序里的vfs_read
和vfs_write
函数了。
|
|
可以看到,read,write系统调用会根据文件描述符提取出file对象,这个file对象是在Open调用里已经创建好的。然后会在file对象中读取出当前的文件偏移量,读写都会从这个偏移量开始。然后把file对象,用户层buffer的地址,要读写的大小,以及文件偏移量作为参数传入vfs_read
和vfs_write
中。这两个函数主要是对file对象的相应文件系统的读写函数进行封装,因此,主要的逻辑就过渡到了具体文件系统上了。具体文件系统的实现逻辑各不相同,但都要以vfs的4大对象为对外的接口,然后再定义自己的数据结构与方法。
对于通用的磁盘文件系统,linux提供了很多基本的函数,很多文件系统的核心功能都是以这些基本函数为基础,再封装一层而已。我们就以常用的xfs文件系统为例,去简单看看它的read和write函数干了什么。
|
|
这是xfs的f_op的函数指针表,可以看到,它的read,write函数竟然直接用了内核提供的函数,非常偷懒!
|
|
这两个函数实际上调用了具体文件系统的aio_read和aio_write函数,而xfs文件系统的这两个函数是自定义的,xfs_file_aio_read
和xfs_file_aio_write
。
这两个函数的代码就有点复杂了,不过我们不需要细究xfs的实现细节,我们的目的是要通过xfs文件系统去找出通用的磁盘文件系统的共性,xfs_file_aio_read
和xfs_file_aio_write
虽然有很多xfs自己的实现细节,但其核心功能都是建立在内核提供的通用函数上的,例如xfs_file_aio_read
最终会调用generic_file_aio_read
函数,而xfs_file_aio_write
则最终会调用generic_perform_write
函数。这些通用函数是基本上所有文件系统的核心逻辑。
进入到这里,就开始涉及到高速缓存这一层了。我们先立足于vfs这一层,然后预热一下高速缓存层,先直接概括一下generic_file_aio_read
函数做了什么事情,非常简单:
- 根据文件偏移量计算出在要读的内容在高速缓存中的位置。
- 搜索高速缓存,看看要读的内容是否在高速缓存中存在,若存在则直接返回,若不存在则触发读磁盘任务。若触发读磁盘任务,则判断当前是否顺序读,尽量预读取磁盘数据到高速缓存中,减少磁盘io的次数。
- 数据读取后,拷贝到用户态的buffer中。
generic_perform_write
的逻辑则是:
- 根据文件偏移量计算出在要写的内容在高速缓存中的位置。
- 搜索看看要写的内容是否已在高速缓存中分配了相应的数据结构,若没有,则分配相应内存空间。
- 从用户态的buffer拷贝数据到高速缓存中。
- 检查高速缓存中的空间是否用得太多,如果占用过多内存则唤醒后台的写回磁盘的线程,把高速缓存的部分内容写回到磁盘上,可能会造成不定时间的写阻塞。
- 向上层返回写的结果。
可以看到,vfs的读写很大程度上依赖于高速缓存的,实际上直接读写硬盘的机会可能没有我们想象的多。对于读任务,如果是顺序读的场景,我们的进程同步读取的数据正常情况下已经在高速缓存中存在了,我们直接从高速缓存中取出数据便返回上层,然后内核会异步地进行预读取;如果是写任务,则绝大部分时候是直接写到高速缓存中便返回,然后再异步地进行writeback。除非高速缓存占用过多,才会同步地写回一部分数据。
高速缓存
在linux中高速缓存主要是用页的形式来组织的,也称为页高速缓存(page cache)。页高速缓存相当于把磁盘映像抽象成一个个固定大小的连续的page,对于vfs层,只需要与page交互,而不需要管理底层硬盘是如何分配空间以及如何读写的。linux的page大小是4KB。
由于文件可能非常大,所以无论是读或者写page,都要求能够快速地在高速缓存中寻找到相应的page。这就需要一个高效的数据结构来管理缓存中的page。linux内核中采用radix tree来管理page cache。
radix tree
前面我们提到vfs的inode对象,在每一个inode对象中都间接持有了一个radix tree的数据结构,所以page cache的查找是以文件为单位进行的。
可以看到radix tree是分层组织的,每一层有64个元素,层数越多能够保存的page数目就越多,而tree node的分配与释放是根据文件大小来调整的。由于索引是32位的,所以radix tree最多有6层,且当树的深度为6时,最高层最多只有4个元素。
我们就以前面说的vfs层读写高速缓存为例子,去分析一下radix tree的增删改查。
计算page编号
无论读写,都要根据文件偏移量计算page号,很简单,公式如下:
|
|
查找page
首先查看当前radix tree的高度能支持的最大索引号是否超过当前要查询的page号,如果是,则继续查找,否则page不存在。
根据基数的深度去解析索引,如果树只有1层,说明只有64个slot,那么只解析最低的6位;如果有两层,则只解析低12位,高6位作为第一层的索引,低6位作为第二层的索引。
插入新page
假设我们的vfs层的读函数在高速缓冲中找不到相应的page,那么就要新分配page了,如果新page的的索引比当前树深度能支持的最大索引号大,那么就直接沿着路径分配中间节点就行。
如果树的深度不够,那就在树的顶端分配适当数量的节点,增加深度,然后再沿着路径分配中间节点。
修改page
vfs层的写函数有可能会修改原有的page,只要查找到该page,然后修改数据就行。
删除page
如果系统内存不足,可能会触发page写回磁盘,然后释放掉所有非脏非正在写回的page,就需要在radix tree上删除页。
删除页就是要先在radix tree中自顶向下地查找相应的page,然后沿着路径再自底而上地删除,删除了子节点时,判断一下如果父节点的子节点数目是0,那么父节点也可以删除。
标记脏页和正在写回的页
如果写page触发了写回磁盘,那么就要找出当前radix tree有哪些页是脏的,哪些页是正在被写回的。如果全局搜索那么就太慢了,因此linux会在radix tree的每个中间节点上统计脏页和正在写回页的数目,如果是0的话就可以跳过该节点的所有子树。
预读
从vfs层的读写函数可以看到,写是异步的,一般情况下写到内存就返回了;读是同步的,缓存不命中的惩罚非常严重。因此,如果我们的读是顺序读的话,高速缓存其实可以预先异步读取当前的page后面的page,这样相比于一页一页地读,就可以大幅度减小磁盘io的次数;而且等到用户发起下一次读请求时,当前的page已经在缓存中了,读进程就不会阻塞。
预读的基本逻辑是:
- 维护两个窗口,一个是当前窗,一个是预读窗。当前窗的page是正在请求的页或者是预读的页,预读窗仅包含预读的页。
- 预读窗紧挨着当前窗。
- 理想情况下正在请求的页会落在当前窗,同时预读窗不断传送新的页;当进程请求的页落到预读窗时,预读窗会变为当前窗,然后分配新的预读窗,去预读后面的页。
- 预读的大小是动态变化的,如果进程持续顺序读取文件,那么预读会持续增加,直到达到文件系统的上限(默认是128KB);如果出现随机访问,预读会逐渐减少直到完全禁止。
- 当进程重复访问文件的很小一部分,预读就会停止。
buffer cache
其实在linux最初几个版本,是没有page cache的,缓存是以buffer的形式存在的,buffer对应的是一个个磁盘块,例如superblock就是一个磁盘块,inode的磁盘映像也是一个磁盘块,还有文件的所有数据块都是磁盘块,每一个磁盘块对应一个buffer。
但随着时代的发展,访问单独磁盘块的场景越来越少,page cache慢慢占了主要地位,在Linux kernel 2.4以后,buffer cache已经不单独实现了,而是建立在page cache的基础上。
在现代linux系统上,绝大部分的高速缓存都是以Page cache的形式存在,仅在以下情况下会用buffer cache:
- 文件page内的磁盘块不相邻,或者page内有洞。
- 单独访问一个块,例如superblock或者inode。
因此,仅仅在page内的buffer数目不足以填充整个页或者磁盘块不连续的条件下,才会用buffer cache来引用缓存数据。
我们来看一下buffer cache的数据结构:
|
|
b_page
指向buffer cache所在的page。b_this_page
指向同属于一个page的下一个buffer cache。b_blocknr
buffer cache在磁盘中的逻辑块号。b_size
buffer cache的大小。b_data
指向buffer cache数据的地址,该地址必定在b_page
指向的页内。b_bdev
代表该buffer cache所映射的块设备,该字段和b_blocknr
,b_size
一起唯一决定了该数据所在的磁盘位置。
通用块层
前面说到,高速缓存这一层分为page cache和buffer cache,buffer cache是建立在page cache之上的。page cache是面向文件的抽象,而buffer cache则是面向块设备的抽象。由于我们对文件的读写请求最终还是会转化成对磁盘(块设备)的读写请求,这种请求是要落到磁盘扇区的。
从buffer_head
的结构中可以看到,每一个buffer cache都有一个磁盘的逻辑块号,这个磁盘块是文件系统的块号,每一个磁盘块可以包含一个或多个扇区,这取决于buffer cache的大小(文件系统格式化时的块大小)。所以,buffer cache的大小是连续分配磁盘大小的最小单位。但对于大多数情况,整个page都会映射到连续的磁盘区域,因此page的大小将成为一般情况下的连续分配磁盘的最小单位。
当从高速缓存中读某个page发现该页不存在时,就会从高速缓存层进入通用块层,向块设备去提交读磁盘的请求。通用块层就是起到了从高速缓存到块设备的桥梁作用。
通用块层的核心–bio
掌握通用块层只需要掌握一个数据结构–bio,它是通用块层逻辑的核心,它描述了从高速缓存层提交的一次IO请求。
|
|
bi_sector
代表这次IO请求的的磁盘扇区号。对于buffer cache,可以通过b_blocknr * b_size / 512计算得到。如果是page cache,则稍微复杂一点,不过page的第一个磁盘块的逻辑块号也能通过文件的元信息间接计算得到。bio_io_vec
记录了高速缓存层要提交给磁盘的数据。一个bio_io_vec
可看作一个连续的内存段。bi_vcnt
代表内存段的数目。bi_idx
代表当前已经传输到哪个内存段了,这个字段会在传输过程中被修改。bi_bdev
表示该请求指向哪个块设备。bi_rw
表示是读还是写请求。
|
|
不管是buffer cache还是Page cache,它们要提交读写磁盘的请求时,都要把数据封装成bio_vec
的形式,然后放到bio
结构内。
读page请求
回忆之前讲到的vfs层的读请求,是以page为单位读取高速缓存的。如果某个page不在高速缓存中,那么就会调用文件系统定义的read_page函数。
以下xfs定义的针对page一级的函数列表:
|
|
xfs的read_page函数是xfs_vm_readpage
。我们不打算去探究该函数的代码细节,而是直接概括一下对于大部分文件系统,read_page函数的核心业务是什么,xfs_vm_readpage
也只是在这个核心业务的基础上再添加自己的逻辑而已。
- 检查page的PG_private字段,如果是1,则该页被用于buffer cache,就会对该页的每一个buffer cache都生成一个bio结构,提交给下一层。
- 如果该page是一般的page,则根据文件元信息计算该page的第一个文件块的块号以及块数目。
- 分配新的bio结构,用page cache或者buffer cache的元信息初始化
bi_sector
,bi_size
,bi_bdev
,bi_io_vec
,bi_rw
等字段。 - 可能要对
bi_bdev
进行remap,然后再将bio提交给下一层。
所谓的块设备remap,是要进行块设备的逻辑分区到磁盘的映射。一个文件系统是安装在逻辑分区上,所以我们的读写请求所对应的逻辑扇区号都是相对于逻辑分区而言的。但是,块设备在进行读写的时候是相对于整个物理磁盘来寻址的,因此在构建bio的时候,要进行逻辑分区到物理磁盘的remap。
bi_bdev
指向的是block_device的结构,无论是逻辑分区还是物理磁盘都是用这个结构来表示。它主要有两个字段跟remap相关的,分别是bi_contains
以及bd_part
。bi_contains
指向该分区所在的物理磁盘的bi_bdev
,如果该分区是逻辑分区,那么可以通过该字段找到物理磁盘的bi_bdev
结构。bd_part
则保存了物理磁盘的分区描述符hd_struct
,可以通过该结构完成逻辑分区扇区号到整个物理磁盘扇区号的映射。
可以看到,read_page函数的核心逻辑非常简单。不过,高速缓存一般都是采用预读的策略来读page的,因此一次会读多个page,此时一般会调用文件系统的read_pages函数,该函数会生成多个内存段的bio,即带有多个bio_io_vec
,把连续的磁盘块一次读出来,减少io次数。
IO调度程序层
通用块层构建了bio之后,会提交给下一层,但这个下一层还并没有到达硬件,还需要经过一层io调度。通用块层实际上把bio请求提交给了IO调度层。IO调度层的存在主要是为了减小磁盘IO的次数,增大磁盘整体的吞吐量。因为多个bio之间可能是访问的连续的磁盘空间,如果把这些bio不经过排序重组就提交给硬件驱动程序,可能会造成很严重的随机读写现象,造成吞吐量下降。因此,IO调度层的任务就是要把bio进行排序和合并。linux内核里有几种不同的Io调度机制,可供用户选择,它们都有不同的优缺点,适合不同的应用场景。
请求队列与请求
linux内核为每一个设备都维护了一个IO队列,这个队列用来填充上层提交的IO请求。在通用块层我们介绍了bio结构,每一个bio是高速缓存层提交的一个IO请求。而在IO调度层,则用request结构来表达IO请求。
|
|
sector
代表要传送的扇区号。nr_sectors
代表整个请求要传送的扇区数。current_nr_sectors
代表当前bio还需传输的扇区数。bio
表示请求的第一个bio结构biotail
表示请求的最后一个bio结构。
上层提交的bio有可能会新分配一个request结构去存放,或者合并到现有的request结构中。
在请求被处理时,下层的设备驱动程序有可能会修改request结构,使得bio总是指向当前未传输的第一个bio,并且会修改nr_sectors
和current_nr_sectors
字段。
The Linus Elevator
这个调度器是linux kernel 2.4的默认调度器,不过在2.6之后已经被淘汰了,不过其基本思想是其他调度器的基础,所以稍微介绍一下。
- 新请求到达时,先看看能不能与队列内已有的请求合并,凡是在磁盘内连续的都可以合并。
- 如果不能合并,则按照磁盘块的顺序插入到正确的位置,始终保持队列是有序的。
- 为了防止请求饿死,当发现有请求在队列的时间过长,将不执行任何合并与排序的优化,而是直接插入到队列末尾。
优点:使得设备驱动器总是进行顺序读写,最大化了吞吐。
缺点:某些请求的延迟会较大,且有可能会饿死。虽然有一定的措施防止请求饿死,但策略还不完善,没有一个时间上的保证。
Deadline
这个是目前我们项目里采用的调度器。这个调度器在继承了Linus Elevator调度器的优点的同时,还回避了它的缺点。
这个调度器维护了4个中间队列,其中sorted queue与Linus Elevator调度器的队列一样,根据磁盘块位置进行合并和排序, 不过对读写请求进行了区分,读请求和写请求分开排序,因此有两个排序队列。同时还另外增加了两个冗余队列,在请求插入sorted queue时,还会分别填充插入读请求或者写请求的超时队列。这两个冗余队列按照普通FIFO的逻辑,按时间排序。每一个请求都有一个超时时间,默认读请求的超时时间是500ms,写请求是5s。
在一般情况下,磁盘的执行队列会从sorted queues里取出请求,优先从读请求排序队列里取,除非写请求排序队列被放弃多次。如果有读请求或者写请求超时,此时从相应的超时队列中取请求。之所以读写请求要分开队列,是因为读请求一般是同步的,对延迟更敏感,因此超时时间设得很短,并且优先级更高;而写请求一般是异步的,所以超时时间会更长,优先级较低。
anticipatory
deadline调度器有一个问题,就是它会在读写请求之间交互执行,并且优先读请求。这样当有读写混合负载时,会发生磁头来回抖动,大幅度降低吞吐。anticipatory调度器就是解决这个问题。
这个调度器建立在deadline调度器的基础上,仍然有4个队列,核心逻辑都一样。不过在完成了一个读请求时,如果此时队列里还没有请求,则不会马上切换到写队列,而是停止一段时间(默认6ms)。由于大概率会有相邻的读请求到达,因此会减少磁头抖动的现象,对整体吞吐有好处。不过如果这段停止的时间没有读请求到达,这样就纯粹是浪费时间,因此anticipatory会根据相应进程和文件系统以往的一些统计信息去预测会不会有读请求到达,如果预测会有才会停止处理原本要处理的写请求。
这个调度器是最复杂的,而且它的功能可以通过配置其他调度器达到相似的效果,因此在linux 2.6.33版本之后被删除了。
CFQ
叫做完全公平调度器。这个调度器的主要目标在于让磁盘带宽在所有进程中平均分配。该调度器使用多个排序队列(缺省64),当请求到达时,会把请求根据进程ID哈希到不同的队列,最终的调度队列会以轮询的方式扫描每一个排序队列取出请求。
Noop
最简单的调度器,基本不做什么,不排序,但还是会合并。新请求一般都是插入队尾,跟普通的FIFO差不多。一般用于随机读写较多,或者用户层本身已经做了请求排序和合并等优化的场景。
设备驱动层
每一类块设备都有它的驱动程序,该驱动程序负责管理块设备的硬件读写,例如ide,scsi等设备都有自己的驱动程序,IO调度层的每一个请求实际上都会交给相应的设备驱动程序,让它们去执行硬件指令。大部分的磁盘驱动程序都采用DMA的方式去进行数据传输,DMA控制器自行在内存和IO设备间进行数据传送,当数据传送完成再通过中断通知CPU。
scatter-gather传送方式
DMA传送必须满足传送的数据都是磁盘上相邻扇区的。老式的磁盘控制器还有一个限制,就是磁盘必须与内存中的连续的内存区域传送数据。新的磁盘控制器则支持scatter-gather传送方式,即磁盘区域必须要连续,但可以同时传输多段不连续的内存段。
设备驱动程序需要向磁盘控制器发送:
- 要传输的起始磁盘扇区号以及总扇区数。
- 内存区域链表,链表中的每项包含一个内存地址还有长度。
这种scatter-gather的传送方式与bio以及request结构对于内存片段的管理是一致的,实际上通用块层以及IO调度层正是为了适配设备驱动程序的这种scatter-gather传送方式而使用bio以及request结构进行管理。request的请求的合并机制可以让一次DMA传送传输尽可能多的数据。
策略例程
每一个请求队列都有自己的request_fn方法,该方法可以调用块设备驱动程序的函数进行数据传输,这些函数就叫做策略例程。
设备驱动程序顺序地处理请求队列中的每一个请求,并设置在数据传送完成时产生中断。当中断产生时,中断程序重新调用策略例程,如果当前请求还没有全部完成,则重新发起请求,否则在请求队列中删除该请求,并处理下一个请求。
如果块设备控制器支持scatter-gather的传送方式,那么设备驱动程序会一次提交整个request,否则会遍历request中的每一个bio,以及bio的每一个bio_io_vec,一段一段地传送。
中断产生时,如果请求没有完全完成,设备驱动程序会修改以下字段:
- 修改bio字段使其指向第一个未完成的bio。
- 修改未完成的bio结构,使其bi_idx字段指向第一个未完成的bio_io_vec。
- 修改bio_io_vec的bv_offset以及bv_len,使其表示该内存段中仍需要传送的数据。
块设备文件
以上的讲述基本上是针对普通文件的读写,但还有一种特殊的文件需要关注,就是设备文件(/dev/sda,/dev/sdb)。
这些设备文件仍然由VFS进行管理,相当于一个特殊的文件系统。当进程访问设备文件时,将直接驱动设备驱动程序。缺省的块设备文件的函数表如下:
|
|
可以看到,VFS隐藏了底层文件系统的实现细节,如果是块设备的话,则会激活设备驱动程序的函数。
每当文件系统被映射到磁盘或分区上,或者显式执行open()调用时,都会打开设备文件。设备文件也有自己的page cache,buffer cache。
写回机制
最后介绍一下最为复杂的写回机制。前面我们讲述从通用块层到设备驱动层的时候,主要是以read为例子,由于读是同步的,往往一次读的调用将贯穿vfs到设备驱动层的整个IO体系。而对于写操作,这一般是异步的,一般从vfs到高速缓存层就会返回。但是新写的page不可能永远停留在内存,始终还是要写回磁盘的。当满足以下条件时,会触发高速缓存层的写回:
- 脏页缓存占用太多,内存空间不足。
- 脏页存在的时间过长。
- 用户强制刷新脏页。
- write写page时检查是否需要刷新。
在Linux kernel 2.6之前写回是全局性的,一个线程负责所有磁盘的写回任务,这样将无法利用全部的磁盘带宽。新内核为每一个磁盘都建立一个线程,负责该磁盘的写回任务。
写回架构
每一个磁盘都对应一个backing_device_info的结构,可以通过相应块设备的请求队列找到该结构。work_list
存储了该设备的所有写回任务,每一个写回任务由wb_writeback_work
定义,包括要写回多少页,写回哪些页,是否同步等等。bdi_writeback
结构则定义了写回线程执行的函数,写回线程会在必要性被唤醒,然后执行写回逻辑。bdi_writeback
主要有3个队列,其中每当有inode变脏,都会加入到b_dirty
队列中,b_io
则是所有需要写回的inode,wb_writeback_work
所定义的写回任务就是针对b_io
定义的inode。b_more_io
则是保存所有需要再次写回的inode,这个队列的元素往往是因为在处理写回任务时,发现某些在b_io
中的inode被锁住而不能马上写回,而临时转移到到b_more_io
中。
定时写回
写回线程会被定时唤醒,检查每一个inode的变脏时间,把符合要求的inode从b_dirty
队列移到b_io
队列。定时写回的任务一般不会从work_list
里面取,而是尽可能多的写回每一个inode的所有脏页,直到没有脏页或者单个inode写回的时间过长。
内存空间不足
当内存空间不足时,内核会尝试释放page cache,由于只有不为脏的页才能被释放,此时会先唤醒每一个bdi设备的写回线程,并且创建一个写1024页的任务插入work_list里。因此,所有脏的inode都会最多写1024页。
用户强制刷新脏页
如果是sync单个文件,不会唤醒写回线程,也不会从work_list里面取任务,而是由写进程同步地去写回该文件的所有脏页;如果是sync整个文件系统,则唤醒所有设备的写回线程,并创建一个写所有脏页的任务插入每一个bdi的work_list,并等待写回任务完成。
write调用写page时检查是否需要刷新
每当用户写一个page到高速缓存层,则会检查该page所在inode是否为脏,如果首次变脏则把该inode加入superblock的s_dirty队列以及相应bdi结构的b_dirty队列。
为了防止写入速度过快,使得高速缓存占用过高,每写一定数量的page(默认32页),则会检查当前是否需要写回。判断是否需要写回,受到两条水平线的限制,一条是background_thresh, 一条是dirty_thresh。这两条水平线一般会受整个操作系统所有脏页所占内存的影响。如果bdi设备配置了strictlimit,则主要受每一个bdi设备所设置的min ratio和max ratio所限制,此时由全局统计信息以及单个bdi设备的统计信息同时计算出是否超过这两条水平线。如果超过background_thresh,则唤醒写回线程执行写回任务。如果超过dirty_thresh,则当前写进程会阻塞,减缓写入速度。
除了在写page时检查是否超出水平线,写回线程定时唤醒时也会检查是否超出background_thresh,如果超出则即便inode变脏时间没有超时,仍然要执行写回。
如果写回线程被唤醒时work_list为空,则默认为每一个脏inode写回1024页。
writepages
具体的写回业务由具体操作系统的writepages方法执行。每一个写回任务都会转换成一次或多次writepages方法的调用。该方法与前面说到的readpages函数相似,都是构造bio,把请求提交给下层,直到设备驱动程序用DMA的方式写入磁盘。
写回inode本身
不仅是脏页需要写回,inode携带的元信息也要被写回,基本上每执行一次writepages函数,写回一定数量的页,就会把inode也写回磁盘一次。由于inode一般存储在磁盘分区开始的地方,与数据的存储区域不连续,因此这里会造成随机写的情况。
delay allocation
传统的文件系统会选择则把page写到高速缓存层的时候,就为数据预先分配磁盘空间。由于这种策略使得磁盘空间分配与实际写回过程割裂,当多个文件并发写入磁盘时,容易造成严重的随机写现象。现代的文件系统,如xfs,ext4,都是在写回的时候才进行磁盘空间的分配,如此能极大地提升写性能。
open系统调用的关键参数解析
前面讲的linux io其实主要是针对最常见的情况,也就是有缓存的同步阻塞io。有缓存指的是读写都要经过高速缓存层,vfs层实际上只负责与高速缓存(Page cache, buffer cache)打交道,而不直接与磁盘打交道。而同步阻塞io是5种io类型之一,具体的描述可参见之前的文章的开头部分redis-浅析IO多路复用与事件处理
实际上,在open文件的时候我们可以加入一些特殊的参数,来改变io的方式。下面介绍几个关键参数。
O_NONBLOCK
该参数不能用于普通文件,加上该参数将以同步非阻塞方式读写文件。
O_SYNC
在写入高速缓存后不马上返回,而是要马上把高速缓存的数据以及文件元信息都写回到磁盘上,当磁盘写成功后返回。相当于每次写完之后调用一下fsync。
这里的fsync跟fflush有区别,fflush是把用户态的buffer全部写到Kernel buffer,但不保证数据落盘;fsync则是保证用户态和内核态的buffer全部刷新到磁盘上。
O_DSYNC
在写入高速缓存后不马上返回,而是要马上把高速缓存的数据写回到磁盘上,当磁盘写成功后返回。相当于每次写完之后调用一下fdatasync。该参数与O_SYNC不同的是不保证文件元信息刷新到磁盘,但是,如果文件的元信息会影响之后的读取的话,则仍然会马上刷新到磁盘。例如最后修改时间,最后访问时间等不会刷到磁盘上,但文件大小发生改变的话则会发生文件元信息的刷新。
O_DIRECT
该参数会绕开高速缓存,而是直接由vfs层到通用块层,即直接构造用户态buffer到磁盘相应扇区的bio。这种方式避免了在用户态和内核态的多次内存拷贝,这个参数一般适用于用户程序已经构建了用户态的磁盘缓存,而不想再经过一层操作系统的缓存,希望直接管理数据对磁盘的读写。但这个参数有一个限制,就是用户态buffer的首地址以及大小都必须是块大小的整数倍,要进行块大小对齐。
O_ASYNC
信号驱动IO,而并非异步IO。这里提一下linux的异步IO。linux对异步IO支持不是很好,异步IO可以分为用户态异步IO和内核态异步IO。用户态异步IO由glibc提供的aio_read,aio_write函数完成,但评价不是很好,很多人都说有bug,而且性能较差。内核态异步IO性能较好,但限制较大,只有当文件是O_DIRECT打开的时候,才可以进行内核态的异步IO调用。