(转)linux⽂件读写的流程
转⾃
在《》这篇⽂章中,我们看到⽂件是如何被打开、⽂件的读写是如何被触发的。
对⼀个已打开的⽂件fd进⾏read/write系统调⽤时,内核中该⽂件所对应的file结构的f_op->read/f_op->write被调⽤。
本⽂将顺着这条路⾛下去,⼤致看看普通磁盘⽂件的读写是怎样实现的。
linux内核响应⼀个块设备⽂件读写的层次结构如图(摘⾃ULK3):
1、VFS,虚拟⽂件系统。
之前我们已经看到f_op->read/f_op->write如何被调⽤,这就是VFS⼲的事(参见:《》);
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上的页⾯回收,这些内容都不在这⾥深⼊探讨了(可以参考《》)。
当需要读写的⽂件内容尚未载⼊到对应的radix树时,read/write的执⾏过程会向底层的“通⽤块层”发起读请求,以便将数据读⼊。
⽽如果⽂件打开时指定了O_DIRECT选项,则表⽰绕开磁盘⾼速缓存,直接与“通⽤块层”打交道。
既然磁盘⾼速缓存提供了有利于提⾼读写效率的缓存机制,为什么⼜要使⽤O_DIRECT选项来绕开它呢?⼀般情况下,这样做的应⽤程序会⾃⼰在⽤户态维护⼀套更利于应⽤程序使⽤的专⽤的缓存机制,⽤以取代内核提供的磁盘⾼速缓存这种通⽤的缓存机制。(数据库程序通常就会这么⼲。)
既然使⽤O_DIRECT选项后,⽂件的缓存从内核提供的磁盘⾼速缓存变成了⽤户态的缓存,那么打开同⼀⽂件的不同进程将⽆法共享这些缓存(除⾮这些进程再创建⼀个共享内存什么的)。⽽如果对于同⼀个⽂件,某些进程使⽤了O_DIRECT选项,⽽某些⼜没有呢?没有使⽤O_DIRECT选项的进程读写这个⽂件时,会在磁盘⾼速缓存中留下相应的内容;⽽使⽤了O_DIRECT选项的进程读写这个⽂件时,需要先将磁盘⾼速缓存⾥⾯对应本次读写的脏数据写回磁盘,然后再对磁盘进⾏直接读写。
关于O_DIRECT选项带来的direct_IO的具体实现细节,说来话长,在这⾥就不做介绍了。可以参考《》。
3、Generic Block Layer,通⽤块层。
linux内核为块设备抽象了统⼀的模型,把块设备看作是由若⼲个扇区组成的数组空间。扇区是磁盘设备读写的最⼩单位,通过扇区号可以指定要访问的磁盘扇区。
上层的读写请求在通⽤块层被构造成⼀个或多个bio结构,这个结构⾥⾯描述了⼀次请求--访问的起始扇区号?访问多少个扇区?是读还是写?相应的内存页有哪些、页偏移和数据长度是多少?等等……
这⾥⾯主要有两个问题:要访问的扇区号从哪⾥来?内存是怎么组织的?
前⾯说过,上层的读写请求通过⽂件pos可以定位到要访问的是相应的磁盘⾼速缓存的第⼏个页,⽽通
过这个页index就可以知道要访问的是⽂件的第⼏个扇区,得到扇区的index。
但是,⽂件的第⼏个扇区并不等同于磁盘上的第⼏个扇区,得到的扇区index还需要由特定⽂件系统提供的函数来转换成磁盘的扇区号。⽂
件系统会记载当前磁盘上的扇区使⽤情况,并且对于每⼀个inode,它依次使⽤了哪些扇区。(参见《》)
于是,通过⽂件系统提供的特定函数,上层请求的⽂件pos最终被对应到了磁盘上的扇区号。
可见,上层的⼀次请求可能跨多个扇区,可能形成多个⾮连续的扇区段。对应于每个扇区段,⼀个bio结构被构造出来。⽽由于块设备⼀般都⽀持⼀次性访问若⼲个连续的扇区,所以⼀个扇区段(不⽌⼀个扇区)可以包含在代表⼀次块设备IO请求的⼀个bio结构中。
接下来谈谈内存的组织。既然上层的⼀次读写请求可能跨多个扇区,它也可能跨越磁盘⾼速缓存上的多个页。于是,⼀个bio⾥⾯包含的扇区请求可能会对应⼀组内存页。⽽这些页是单独分配的,内存地址很可能不连续。
linux内核文件放在哪那么,既然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_osy
nc_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⼏乎就⼀样了……

版权声明:本站内容均来自互联网,仅供演示用,请勿用于商业和其他非法用途。如果侵犯了您的权益请与我们联系QQ:729038198,我们将在24小时内删除。