[IO系统]07 IO写流程分析

        本文从整体来分析缓存IO的控制流和数据流,并基于IO系统图来解析IO写流程:

        [IO系统]07 IO写流程分析_第1张图片

注:对上述层次图的理解参见文章《[IO系统]01 IO子系统》

        一步一步往前走。

1.1   用户态

        程序的最终目的是要把数据写到磁盘上,如前所述,用户态有两个“打开”函数——write和fwrite。其中fwrite是glibc对write的封装。

        参见《[IO系统]02 用户态的文件IO操作》

        fwrite的流程在用户态的操作比较复杂,涉及到更多的数据复制和处理流程,本文不做介绍,后续单独分析。

Poxis接口write直接通过系统调用write

1.2   write系统调用/VFS层

        Write系统调用是在fs/read_write.c中实现的:

SYSCALL_DEFINE3(write, unsigned int,fd, const char __user *, buf,
       size_t,count)
{
   structfd f = fdget(fd);
   ssize_tret = -EBADF;
 
   if(f.file) {
       loff_tpos = file_pos_read(f.file);
       ret= vfs_write(f.file, buf, count, &pos);
       if(ret >= 0)
           file_pos_write(f.file,pos);
       fdput(f);
   }
 
   returnret;
}

函数解析:

1. 通过函数fdget将整形的句柄ID fd转化为内核数据结构fd,可称之为文件句柄描述符。

          fd结构体如下:

struct fd {
    structfile *file;/* 文件对象 */
    unsignedint flags;
};

        内核中文件系统各结构体之间的关系参照文章《[IO系统]因OPEN建立的结构体关系图》

2. 获取文件file的操作位置,也可以理解光标(记录在pos);

3. 调用VFS接口vfs_write()实现写操作;通过vfs_write函数调用具体文件系统(如ext4,xfs,btrfs,ocfs2)的write函数。

ssize_t vfs_write(struct file *file,const char __user *buf, size_t count, loff_t *pos)
{
   …
       if(file->f_op->write)
           ret= file->f_op->write(file, buf, count, pos);
       else
           ret= do_sync_write(file, buf, count, pos);
   …
   returnret;
}

        当然如果没有定义具体的write函数,则调用默认的do_sync_write函数执行数据写操作。

4. 更新file的pos变量。

 

1.3   具体文件系统层

        当具体文件系统层(像ext2/3/4等,我称之为具体文件系统)接到写IO请求时,会判断该IO是否具有DIRECTIO(直接IO)标示,如果有,则进入直接IO;没有,则进入BufferIO。

static ssize_t
ext4_file_write(struct kiocb *iocb,const struct iovec *iov,
       unsignedlong nr_segs, loff_t pos)
{
   structinode *inode = file_inode(iocb->ki_filp);
   ssize_tret;
   …
   if(unlikely(iocb->ki_filp->f_flags & O_DIRECT))
      ret =ext4_file_dio_write(iocb, iov, nr_segs, pos);
   else
      ret =generic_file_aio_write(iocb, iov, nr_segs, pos);
 
   returnret;
}

直接IO:

        控制流,若进入直接IO,则调用具体文件系统层的直接IO处理函数,然后调用通过submit_bio函数将IO提交到通用块层。

        数据流,数据依旧存放在用户态缓存中,并不需要将数据复制到pagecache中,减少了数据复制次数。

缓存IO:

        控制流,若进入BufferIO,则调用具体文件系统的write_begin进入的准备:比如空间分配,缓存映射(涉及到page cache)等;然后将数据copy到内核的page中,最后调用write_end函数来完成IO写操作。至此,IO操作结果一步一步返回到用户态。

        数据流,数据从用户态复制到内核态page cache中。

 

1.4   Page Cache层

        Page Cache是文件数据在内存中的副本,因此Page Cache管理与内存管理系统和文件系统都相关:一方面Page Cache作为物理内存的一部分,需要参与物理内存的分配回收过程,另一方面Page Cache中的数据来源于存储设备上的文件,需要通过文件系统与存储设备进行读写交互。从操作系统的角度考虑,Page Cache可以看做是内存管理系统与文件系统之间的联系纽带。因此,PageCache管理是操作系统的一个重要组成部分,它的性能直接影响着文件系统和内存管理系统的性能。

        本文不做详细介绍,后续讲解。

 

1.5   通用块层

        通用块层:由于绝大多数情况的IO操作是跟块设备打交道,所以Linux在此提供了一个类似vfs层的块设备操作抽象层。下层对接各种不同属性的块设备,对上提供统一的Block IO请求标准。

        无论是DirectIO还是BufferIO,最后都会通过submit_bio()将IO请求提交到通用块层,通过generic_make_request转发bio ,generic_make_request函数主要是获取请求队列,然后调用make_request_fn方法处理bio

        submit_bio函数通过generic_make_request转发bio,generic_make_request是一个循环,其通过每个块设备下注册的q->make_request_fn函数与块设备进行交互。如果访问的块设备是一个有queue的设备,那么会将系统的__make_request函数注册到q->make_request_fn中;否则块设备会注册一个私有的方法。在私有的方法中,由于不存在queue队列,所以不会处理具体的请求,而是通过修改bio中的方法实现bio的转发,在私有make_request方法中,往往会返回1,告诉generic_make_request继续转发比bio。

        Generic_make_request的执行上下文可能有两种,一种是用户上下文,另一种为pdflush所在的内核线程上下文。

        在通用块层,提供了一个通用的请求队列压栈方法:blk_queue_bio。在初始化一个有queue块设备驱动的时候,最终都会调用blk_init_allocated_queue函数对请求队列进行初始化,初始化的时候会将blk_queue_bio方法注册到q->make_request_fn。在generic_make_request转发bio请求的时候会调用q->make_request_fn,从而可以将bio压入请求队列进行IO调度。一旦bio进入请求队列之后,可以好好的休息一番,直到unplug机制对bio进行进一步处理。

 

1.6   IO调度层

        IO调度层:因为绝大多数的块设备都是类似磁盘这样的设备,所以有必要根据这类设备的特点以及应用的不同特点来设置一些不同的调度算法和队列。以便在不同的应用环境下有针对性的提高磁盘的读写效率。

        到目前为止,文件系统(pdflush或者address_space_operations)发下来的bio已经merge到request queue中。

        如果为sync bio,那么直接调用__generic_unplug_device,否则需要在unplug timer的软中断上下文中执行q->unplug_fn。后继request的处理方法应该和具体的物理设备相关,但是在标准的块设备上如何体现不同物理设备的差异性呢?这种差异性就体现在queue队列的方法上,不同的物理设备,queue队列的方法是不一样的。

        queue是块设备的驱动程序提供的一个请求队列。make_request_fn函数将bio放入请求队列中进行调度处理。当前linux kernel提供的调度器有CFQ、Deadline和Noop等等。设置请求队列的目的是考虑了磁盘介质的特性,普通磁盘介质一个最大的问题是随机读写性能很差。为了提高性能,通常的做法是聚合IO,因此在块设备层设置请求队列,对IO进行聚合操作,从而提高读写性能。

        举例中的sda是一个scsi设备,在scsi middle level将scsi_request_fn函数注册到了queue队列的request_fn方法上。在q->unplug_fn(具体方法为:generic_unplug_device)函数中会调用request队列的具体处理函数q->request_fn。Ok,到这一步实际上已经将块设备层与scsi总线驱动层联系在了一起,他们的接口方法为request_fn。

 

1.7   设备驱动层

        设备驱动程序要做的事情就是从request_queue里面取出请求,然后操作硬件设备,逐个去执行这些请求。除了处理请求,设备驱动程序还要选择IO调度算法,因为设备驱动程序最知道设备的属性,知道用什么样的IO调度算法最合适。甚至于,设备驱动程序可以将IO调度器屏蔽掉,而直接对上层的bio进行处理。(当然,设备驱动程序也可实现自己的IO调度算法。)可以说,IO调度器是内核提供给设备驱动程序的一组方法。用与不用、使用怎样的方法,选择权在于设备驱动程序。

 

(摘抄)

        接下来的过程实际上和具体的scsi总线操作相关了。在scsi_request_fn函数中会扫描request队列,通过elv_next_request函数从队列中获取一个request。在elv_next_request函数中通过scsi总线层注册的q->prep_rq_fn(scsi层注册为scsi_prep_fn)函数将具体的request转换成scsi驱动所能认识的scsi command。获取一个request之后,scsi_request_fn函数直接调用scsi_dispatch_cmd函数将scsi command发送给一个具体的scsi host。到这一步,有一个问题:scsi command具体转发给那个scsi host呢?秘密就在于q->queuedata中,在为sda设备分配queue队列时,已经指定了sda块设备与底层的scsi设备(scsidevice)之间的关系,他们的关系是通过request queue维护的。

        在scsi_dispatch_cmd函数中,通过scsi host的接口方法queuecommand将scsi command发送给scsi host。通常scsi host的queuecommand方法会将接收到的scsi command挂到自己维护的队列中,然后再启动DMA过程将scsi command中的数据发送给具体的磁盘。DMA完毕之后,DMA控制器中断CPU,告诉CPUDMA过程结束,并且在中断上下文中设置DMA结束的中断下半部。DMA中断服务程序返回之后触发软中断,执行SCSI中断下半部。

         在SCSi中断下半部中,调用scsi command结束的回调函数,这个函数往往为scsi_done,在scsi_done函数调用blk_complete_request函数结束请求request,每个请求维护了一个bio链,所以在结束请求过程中回调每个请求中的bio回调函数,结束具体的bio。Bio又有文件系统的buffer head生成,所以在结束bio时,回调buffer_head的回调处理函数bio->bi_end_io(注册为end_bio_bh_io_sync)。自此,由中断引发的一系列回调过程结束,总结一下回调过程如下:scsi_done->end_request->end_bio->end_bufferhead。

        经设备驱动层,将数据复制到disk cache中。

1.8   设备层

暂不分析。

 

1.9   参考文献

[博客] http://blog.chinaunix.net/uid-27105712-id-3270102.html

 

你可能感兴趣的:(数据存储,深入理解EXT4文件系统)