在《linux内核虚拟文件系统浅析》这篇文章中,我们看到文件是如何被打开、文件的读写是如何被触发的。
对一个已打开的文件fd进行read/write系统调用时,内核中该文件所对应的file结构的f_op->read/f_op->write被调用。
本文将顺着这条路走下去,大致看看普通磁盘文件的读写是怎样实现的。
linux内核响应一个块设备文件读写的层次结构如图(摘自ULK3):
1、VFS,虚拟文件系统。
之前我们已经看到f_op->read/f_op->write如何被调用,这就是VFS干的事(参见:《linux内核虚拟文件系统浅析》);
2、Disk Caches,磁盘高速缓存。
将磁盘上的数据缓存在内存中,加速文件的读写。实际上,在一般情况下,read/write是只跟缓存打交道的。(当然,存在特殊情况。下面会说到。)
read就直接从缓存读数据。如果要读的数据还不在缓存中,则触发一次读盘操作,然后等待磁盘上的数据被更新到磁盘高速缓存中;write也是直接写到缓存里去,然后就不用管了。后续内核会负责将数据写回磁盘。
为了实现这样的缓存,每个文件的inode内嵌了一个address_space结构,通过inode->i_mapping来访问。address_space结构中维护了一棵radix树,用于磁盘高速缓存的内存页面就挂在这棵树上。而既然磁盘高速缓存是跟文件的inode关联上的,则打开这个文件的每个进程都共用同一份缓存。
radix树的具体实现细节这里可以不用关心,可以把它理解成一个数组。数组中的每个元素就是一个页面,文件的内容就顺序存放在这些页面中。
于是,通过要读写的文件pos,可以换算得到要读写的是第几页(pos是以字节为单位,只需要除以每个页的字节数即可)。
inode被载入内存的时候,对应的磁盘高速缓存是空的(radix树上没有页面)。随着文件的读写,磁盘上的数据被载入内存,相应的内存页被挂到radix树的相应位置上。
如果文件被写,则仅仅是对应inode的radix树上的对应页上的内容被更新,并不会直接写回磁盘。这样被写过,但还没有更新到磁盘的页称为脏页。
内核线程pdflush定期将每个inode上的脏页更新到磁盘,也会适时地将radix上的页面回收,这些内容都不在这里深入探讨了(可以参考《linux页面回收浅析》)。
当需要读写的文件内容尚未载入到对应的radix树时,read/write的执行过程会向底层的“通用块层”发起读请求,以便将数据读入。
而如果文件打开时指定了O_DIRECT选项,则表示绕开磁盘高速缓存,直接与“通用块层”打交道。
既然磁盘高速缓存提供了有利于提高读写效率的缓存机制,为什么又要使用O_DIRECT选项来绕开它呢?一般情况下,这样做的应用程序会自己在用户态维护一套更利于应用程序使用的专用的缓存机制,用以取代内核提供的磁盘高速缓存这种通用的缓存机制。(数据库程序通常就会这么干。)
既然使用O_DIRECT选项后,文件的缓存从内核提供的磁盘高速缓存变成了用户态的缓存,那么打开同一文件的不同进程将无法共享这些缓存(除非这些进程再创建一个共享内存什么的)。而如果对于同一个文件,某些进程使用了O_DIRECT选项,而某些又没有呢?没有使用O_DIRECT选项的进程读写这个文件时,会在磁盘高速缓存中留下相应的内容;而使用了O_DIRECT选项的进程读写这个文件时,需要先将磁盘高速缓存里面对应本次读写的脏数据写回磁盘,然后再对磁盘进行直接读写。
关于O_DIRECT选项带来的direct_IO的具体实现细节,说来话长,在这里就不做介绍了。可以参考《linux异步IO浅析》。
3、Generic Block Layer,通用块层。
linux内核为块设备抽象了统一的模型,把块设备看作是由若干个扇区组成的数组空间。扇区是磁盘设备读写的最小单位,通过扇区号可以指定要访问的磁盘扇区。
上层的读写请求在通用块层被构造成一个或多个bio结构,这个结构里面描述了一次请求--访问的起始扇区号?访问多少个扇区?是读还是写?相应的内存页有哪些、页偏移和数据长度是多少?等等……
这里面主要有两个问题:要访问的扇区号从哪里来?内存是怎么组织的?
前面说过,上层的读写请求通过文件pos可以定位到要访问的是相应的磁盘高速缓存的第几个页,而通过这个页index就可以知道要访问的是文件的第几个扇区,得到扇区的index。
但是,文件的第几个扇区并不等同于磁盘上的第几个扇区,得到的扇区index还需要由特定文件系统提供的函数来转换成磁盘的扇区号。文件系统会记载当前磁盘上的扇区使用情况,并且对于每一个inode,它依次使用了哪些扇区。(参见《linux文件系统实现浅析》)
于是,通过文件系统提供的特定函数,上层请求的文件pos最终被对应到了磁盘上的扇区号。
可见,上层的一次请求可能跨多个扇区,可能形成多个非连续的扇区段。对应于每个扇区段,一个bio结构被构造出来。而由于块设备一般都支持一次性访问若干个连续的扇区,所以一个扇区段(不止一个扇区)可以包含在代表一次块设备IO请求的一个bio结构中。
接下来谈谈内存的组织。既然上层的一次读写请求可能跨多个扇区,它也可能跨越磁盘高速缓存上的多个页。于是,一个bio里面包含的扇区请求可能会对应一组内存页。而这些页是单独分配的,内存地址很可能不连续。
那么,既然bio描述的是一次块设备请求,块设备能够一次性访问一组连续的扇区,但是能够一次性对一组非连续的内存地址进行存取吗?
块设备一般是通过DMA,将块设备上一组连续的扇区上的数据拷贝到一组连续的内存页面上(或将一组连续的内存页面上的数据拷贝到块设备上一组连续的扇区),DMA本身一般是不支持一次性访问非连续的内存页面的。
但是某些体系结构包含了io-mmu。就像通过mmu可以将一组非连续的物理页面映射成连续的虚拟地址一样,对io-mmu进行编程,可以让DMA将一组非连续的物理内存看作连续的。所以,即使一个bio包含了非连续的多段内存,它也是有可能可以在一次DMA中完成的。当然,不是所有的体系结构都支持io-mmu,所以一个bio也可能在后面的设备驱动程序中被拆分成多个设备请求。
每个被构造的bio结构都会分别被提交,提交到底层的IO调度器中。
4、I/O SchedulerLayer,IO调度器。
我们知道,磁盘是通过磁头来读写数据的,磁头在定位扇区的过程中需要做机械的移动。相比于电和磁的传递,机械运动是非常慢速的,这也就是磁盘为什么那么慢的主要原因。
IO调度器要做的事情就是在完成现有请求的前提下,让磁头尽可能少移动,从而提高磁盘的读写效率。最有名的就是“电梯算法”。
在IO调度器中,上层提交的bio被构造成request结构,一个request结构包含了一组顺序的bio。而每个物理设备会对应一个request_queue,里面顺序存放着相关的request。
新的bio可能被合并到request_queue中已有的request结构中(甚至合并到已有的bio中),也可能生成新的request结构并插入到request_queue的适当位置上。具体怎么合并、怎么插入,取决于设备驱动程序选择的IO调度算法。大体上可以把IO调度算法就想象成“电梯算法”,尽管实际的IO调度算法有所改进。
除了类似“电梯算法”的IO调度算法,还有“none”算法,这实际上是没有算法,也可以说是“先来先服务算法”。因为现在很多块设备已经能够很好地支持随机访问了(比如固态磁盘、flash闪存),使用“电梯算法”对于它们没有什么意义。
IO调度器除了改变请求的顺序,还可能延迟触发对请求的处理。因为只有当请求队列有一定数目的请求时,“电梯算法”才能发挥其功效,否则极端情况下它将退化成“先来先服务算法”。
这是通过对request_queue的plug/unplug来实现的,plug相当于停用,unplug相当于恢复。请求少时将request_queue停用,当请求达到一定数目,或者request_queue里最“老”的请求已经等待很长一段时间了,这时候才将request_queue恢复。
在request_queue恢复的时候,驱动程序提供的回调函数将被调用,于是驱动程序开始处理request_queue。
一般来说,read/write系统调用到这里就返回了。返回之后可能等待(同步)或是继续干其他事(异步)。而返回之前会在任务队列里面添加一个任务,而处理该任务队列的内核线程将来会执行request_queue的unplug操作,以触发驱动程序处理请求。
5、Device Driver,设备驱动程序。
到了这里,设备驱动程序要做的事情就是从request_queue里面取出请求,然后操作硬件设备,逐个去执行这些请求。
除了处理请求,设备驱动程序还要选择IO调度算法,因为设备驱动程序最知道设备的属性,知道用什么样的IO调度算法最合适。甚至于,设备驱动程序可以将IO调度器屏蔽掉,而直接对上层的bio进行处理。(当然,设备驱动程序也可实现自己的IO调度算法。)
可以说,IO调度器是内核提供给设备驱动程序的一组方法。用与不用、使用怎样的方法,选择权在于设备驱动程序。
于是,对于支持随机访问的块设备,驱动程序除了选择“none”算法,还有一种更直接的做法,就是注册自己的bio提交函数。这样,bio生成后,并不会使用通用的提交函数,被提交到IO调度器,而是直接被驱动程序处理。
但是,如果设备比较慢的话,bio的提交可能会阻塞较长时间。所以这种做法一般被基于内存的“块设备”驱动使用(当然,这样的块设备是由驱动程序虚拟的)。
下面大致介绍一下read/write的执行流程:
sys_read。通过fd得到对应的file结构,然后调用vfs_read;
vfs_read。各种权限及文件锁的检查,然后调用file->f_op->read(若不存在则调用do_sync_read)。file->f_op是从对应的inode->i_fop而来,而inode->i_fop是由对应的文件系统类型在生成这个inode时赋予的。file->f_op->read很可能就等同于do_sync_read;
do_sync_read。f_op->read是完成一次同步读,而f_op->aio_read完成一次异步读。do_sync_read则是利用f_op->aio_read这个异步读操作来完成同步读,也就是在发起一次异步读之后,如果返回值是-EIOCBQUEUED,则进程睡眠,直到读完成即可。但实际上对于磁盘文件的读,f_op->aio_read一般不会返回-EIOCBQUEUED,除非是设置了O_DIRECT标志aio_read,或者是对于一些特殊的文件系统(如nfs这样的网络文件系统);
f_op->aio_read。这个函数通常是由generic_file_aio_read或者其封装来实现的;
generic_file_aio_read。一次异步读可能包含多个读操作(对应于readv系统调用),对于其中的每一个,调用do_generic_file_read;
do_generic_file_read。主要流程是在radix树里面查找是否存在对应的page,且该页可用。是则从page里面读出所需的数据,然后返回,否则通过file->f_mapping->a_ops->readpage去读这个页。(file->f_mapping->a_ops->readpage返回后,说明读请求已经提交了。但是磁盘上的数据还不一定就已经读上来了,需要等待数据读完。等待的方法就是lock_page:在调用file->f_mapping->a_ops->readpage之前会给page置PG_locked标记。而数据读完后,会将该标记清除,这个后面会看到。而这里的lock_page就是要等待PG_locked标记被清除。);
file->f_mapping是从对应inode->i_mapping而来,inode->i_mapping->a_ops是由对应的文件系统类型在生成这个inode时赋予的。而各个文件系统类型提供的a_ops->readpage函数一般是mpage_readpage函数的封装;
mpage_readpage。调用do_mpage_readpage构造一个bio,再调用mpage_bio_submit将其提交;
do_mpage_readpage。根据page->index确定需要读的磁盘扇区号,然后构造一组bio。其中需要使用文件系统类型提供的get_block函数来对应需要读取的磁盘扇区号;
mpage_bio_submit。设置bio的结束回调bio->bi_end_io为mpage_end_io_read,然后调用submit_bio提交这组bio;
submit_bio。调用generic_make_request将bio提交到磁盘驱动维护的请求队列中;
generic_make_request。一个包装函数,对于每一个bio,调用__generic_make_request;
__generic_make_request。获取bio对应的块设备文件对应的磁盘对象的请求队列bio->bi_bdev->bd_disk->queue,调用q->make_request_fn将bio添加到队列;
q->make_request_fn。设备驱动程序在其初始化时会初始化这个request_queue结构,并且设置q->make_request_fn和q->request_fn(这个下面就会用到)。前者用于将一个bio组装成request添加到request_queue,后者用于处理request_queue中的请求。一般情况下,设备驱动通过调用blk_init_queue来初始化request_queue,q->request_fn需要给定,而q->make_request_fn使用了默认的__make_request;
__make_request。会根据不同的调度算法来决定如何添加bio,生成对应的request结构加入request_queue结构中,并且决定是否调用q->request_fn,或是在kblockd_workqueue任务队列里面添加一个任务,等kblockd内核线程来调用q->request_fn;
q->request_fn。由驱动程序定义的函数,负责从request_queue里面取出request进行处理。从添加bio到request被取出,若干的请求已经被IO调度算法整理过了。驱动程序负责根据request结构里面的描述,将实际物理设备里面的数据读到内存中。当驱动程序完成一个request时,会调用end_request(或类似)函数,以结束这个request;
end_request。完成request的收尾工作,并且会调用对应的bio的的结束方法bio->bi_end_io,即前面设置的mpage_end_io_read;
mpage_end_io_read。如果page已更新则设置其up-to-date标记,并为page解锁,唤醒等待page解锁的进程。最后释放bio对象;
sys_write。跟sys_read一样,对应的vfs_write、do_sync_write、f_op->aio_write、generic_file_aio_write被顺序调用;
generic_file_aio_write。调用__generic_file_aio_write_nolock来进行写的处理,将数据写到磁盘高速缓存中。写完成之后,判断如果文件打开时使用了O_SYNC标记,则再调用sync_page_range将写入到磁盘高速缓存中的数据同步到磁盘(只同步文件头信息);
__generic_file_aio_write_nolock。进行一些检查之后,调用generic_file_buffered_write;
generic_file_buffered_write。调用generic_perform_write执行写,写完成之后,判断如果文件打开时使用了O_SYNC标记,则再调用generic_osync_inode将写入到磁盘高速缓存中的数据同步到磁盘(同步文件头信息和文件内容);
generic_perform_write。一次异步写可能包含多个写操作(对应于writev系统调用),对于其中牵涉的每一个page,调用file->f_mapping->a_ops->write_begin准备好需要写的磁盘高速缓存页面,然后将需要写的数据拷入其中,最后调用file->f_mapping->a_ops->write_end完成写;
file->f_mapping是从对应inode->i_mapping而来,inode->i_mapping->a_ops是由对应的文件系统类型在生成这个inode时赋予的。而各个文件系统类型提供的file->f_mapping->a_ops->write_begin函数一般是block_write_begin函数的封装、file->f_mapping->a_ops->write_end函数一般是generic_write_end函数的封装;
block_write_begin。调用grab_cache_page_write_begin在radix树里面查找要被写的page,如果不存在则创建一个。调用__block_prepare_write为这个page准备一组buffer_head结构,用于描述组成这个page的数据块(利用其中的信息,可以生成对应的bio结构);
generic_write_end。调用block_write_end提交写请求,然后设置page的dirty标记;
block_write_end。调用__block_commit_write为page中的每一个buffer_head结构设置dirty标记;
至此,write调用就要返回了。如果文件打开时使用了O_SYNC标记,sync_page_range或generic_osync_inode将被调用。否则write就结束了,等待pdflush内核线程发现radix树上的脏页,并最终调用到do_writepages写回这些脏页;
sync_page_range也是调用generic_osync_inode来实现的,而generic_osync_inode最终也会调用到do_writepages;
do_writepages。调用inode->i_mapping->a_ops->writepages,而后者一般是mpage_writepages函数的包装;
mpage_writepages。检查radix树中需要写回的page,对每一个page调用__mpage_writepage;
__mpage_writepage。这里也是构造bio,然后调用mpage_bio_submit来进行提交;
后面的流程跟read几乎就一样了……
linux文件读写浅析
最新推荐文章于 2024-09-14 16:20:57 发布