linux内核分析笔记----块I/O层

来源:岁月联盟 编辑:exp 时间:2011-10-28

 

如果您记性好的话,应该记得我在linux设备驱动实例帖中说的最多的就是字符设备驱动程序,那么今天的块I/O层是一个和字符设备驱动相对应的设备。两者最根本的区别就是看它们能否被随机访问,换句话说就是看它们能否在访问设备时从一个位置随意地调到另外一个位置,如果可以就是块设备,否则就字符设备。

 

      块设备中最小的可寻址单元是扇区。扇区的大小一般是2的整数倍,最常见的大小是512个字节。扇区的大小是设备的物理属性,扇区是所有块设备的基本单元,块设备无法对比它还小的单元进行寻址和操作,不过许多块设备能够一次就传输多个扇区。从软件角度来讲,最小的逻辑可寻址单元却是块,块是文件系统的一种抽象-----只能基于块来访问文件系统。虽然物理磁盘寻址是按照扇区级进行的,但是内核执行的所有磁盘操作都是按照块进行的。前边已经说过,扇区是设备的最小可寻址单元,所以块不能比扇区还小,只能数倍于扇区大小。另外内核还要求块大小是2的整数倍,=而且不能超过一个页的长度,所以大小的最终要求是,必须是扇区大小的2的整数倍,并且要小于页面大小。所以通常块大小是512字节,1k或4k。

 

当一个块被调入内存时,它要存储在一个缓冲区中,每个缓冲区与一个块对应,它相当于是磁盘块在内存中的表示。另外,由于内核在处理数据时需要一些相关的控制信息,所以每个缓冲区都有一个叫做buffer_head的描述符来表示,被称为缓冲区头,在linux/buffer_head.h中定义,它包含了内核操作缓冲区所需要的全部信息,如下:

 

 

01 struct buffer_head { 

 

02         unsigned long        b_state;          /* buffer state flags */

 

03         atomic_t             b_count;          /* buffer usage counter */

 

04         struct buffer_head   *b_this_page;     /* buffers using this page */

 

05         struct page          *b_page;          /* page storing this buffer */

 

06         sector_t             b_blocknr;        /* logical block number */

 

07         u32                  b_size;           /* block size (in bytes) */

 

08         char                 *b_data;          /* buffer in the page */

 

09         struct block_device  *b_bdev;          /* device where block resides */

 

10         bh_end_io_t          *b_end_io;        /* I/O completion method */

 

11         void                 *b_private;       /* data for completion method */

 

12         struct list_head     b_assoc_buffers;  /* list of associated mappings */

 

13 };

 

      其中的b_state域表示缓冲区的状态,下表给出一种标志或多种标志的组合,在linux/buffer_head.h中定义了所有合法标志的bh_state_bite列表,如下所示:

 

    

image

 

      bh_state_bits列表包含了一个特殊标志----BH_PrivateStart,该标志不是可用状态标志,使用它是为了指明可能其它代码使用的起始位。块I/O层不会使用BH_PrivateStart或更高的位,那么某个驱动程序希望通过b_state域存储信息时就可以安全地使用这些位。驱动程序可以在这些位中定义自己的状态标志,只要保证自定义的状态标志不会与块IO层的专用位发生冲突就可以了。b_count域表示缓冲区的使用计数,可通过两个定义在文件linux/buffer_head.h中的内联函数对此域进行增减:

 

 

1 static inline void get_bh(struct buffer_head *bh) 

 

2 { 

 

3         atomic_inc(&bh->b_count); 

 

4 } 

 

5 static inline void put_bh(struct buffer_head *bh) 

 

6 { 

 

7         atomic_dec(&bh->b_count); 

 

8 }

 

      在操作缓冲区头之前,应该先使用get_bh()函数增加缓冲区头的引用计数,确保缓冲区头不会再被分配出去,当完成对缓冲区头的操作之后,还必须使用put_bh()函数减少引用计数。与缓冲区对应的磁盘物理块由b_blocknr域索引,该值是b_bdev域指明的块设备中的逻辑块号。与缓冲区对应的内存物理页由b_page域表示,另外,b_data域直接指向相应的块(它位于b_page域所指明的页面的某个位置上),块的大小由b_size域表示,所以块在内存中的起始位置在b_data处,结束位置在(b_data+b_size)处。缓冲区头的目的在于描述磁盘块和物理内存缓冲区(在特定页面上的字节序列)之间的映射关系。这个结构体在内核中扮演一个描述符的角色,说明从缓冲区到块的映射关系。使用缓冲区头作为I/O操作有它的弊端,这里不细说,你明白就好。我们只需知道现在的内核采用了一种新型,灵活而且轻量级的容器---bio结构体。

 

      bio结构体定义在linux/bio.h中,该结构体代表了正在现场的(活动)以片断(segment)链表形式组织的块I/O操作。一个片断是一小块连续的内存缓冲区。这样的话,就不需要保证单个缓冲区一定要连续,所有通过片断来描述缓冲区,即使一个缓冲区分散在内存的多个位置上,bio结构体也能保证I/O操作的执行。下面给出bio结构体和各个域的描述,如下:

 

 

01 struct bio { 

 

02         sector_t             bi_sector;         /* associated sector on disk */

 

03         struct bio           *bi_next;          /* list of requests */

 

04         struct block_device  *bi_bdev;          /* associated block device */

 

05         unsigned long        bi_flags;          /* status and command flags */

 

06         unsigned long        bi_rw;             /* read or write? */

 

07         unsigned short       bi_vcnt;           /* number of bio_vecs off */

 

08         unsigned short       bi_idx;            /* current index in bi_io_vec */

 

09         unsigned short       bi_phys_segments;  /* number of segments after coalescing */

 

10         unsigned short       bi_hw_segments;    /* number of segments after remapping */

 

11         unsigned int         bi_size;           /* I/O count */

 

12         unsigned int         bi_hw_front_size;  /* size of the first mergeable segment */

 

13         unsigned int         bi_hw_back_size;   /* size of the last mergeable segment */

 

14         unsigned int         bi_max_vecs;       /* maximum bio_vecs possible */

 

15         struct bio_vec       *bi_io_vec;        /* bio_vec list */

 

16         bio_end_io_t         *bi_end_io;        /* I/O completion method */

 

17         atomic_t             bi_cnt;            /* usage counter */

 

18         void                 *bi_private;       /* owner-private method */

 

19         bio_destructor_t     *bi_destructor;    /* destructor method */

 

20 };

 

      使用bio结构体的目的主要是代表正在现场执行的I/O操作,所有该结构体中的主要域都是用来管理相关信息的。其中最重要的几个域是bi_io_vecs,bi_vcnt和bi_idx.它们之间的关系如下图所示:

 

     

image

 

      我在前边已经给出了struct bio的结构体,下面给出struct bio_vec的描述:

 

 

1 struct bio_vec { 

 

2         struct page     *bv_page;   /* pointer to the physical page on which this buffer resides */ 

 

3         unsigned int    bv_len;     /* the length in bytes of this buffer */ 

 

4         unsigned int    bv_offset;   /* the byte offset within the page where the buffer resides */ 

 

5 };

 

      下面来分析以上上面的那个图,我们说:每一个块I/O请求都通过一个bio结构体表示。每个请求包含一个或多个块,这些块存储在bio_vec结构体数组中,这些结构体描述了每个片断在物理页中的实际位置,并且像向量一样地组织在一起,IO操作的第一个片断由b_io_vec结构体所指向,其他的片断在其后依次放置,共有bi_vcnt个片断。当块IO开始执行请求,需要使用各个片段时,bi_idx域会不断更新,从而总指向当前片断。bi_idx域指向数组中的当前bio_vec片段,块IO层通过它跟踪块IO操作的完成进度。但该域更重要的作用是分割bio结构体。bi_cnt域记录bio结构体的使用计数,如果为0,则应该销毁该bio结构体,并释放它占用的内存。通过下面两个函数管理使用计数:

 

 

1 void bio_get(struct bio *bio); 

 

2 void bio_put(struct bio *bio);

 

      最后一个域是bi_private域,这是一个属于拥有者的私有域,谁创建了bio结构,谁就可以读写该域。

 

      块设备将它们挂起的块IO请求保存在请求队列中,该队列有request_queue结构体体表示,定义在文件linux/blkdev.h中,包含一个双向请求链表以及相关控制信息。通过内核中想文件系统这样高层的代码将请求加入到队列中。请求队列只要不为空,队列对应的块设备驱动程序就会从队列头获取请求,然后将其送入对应的块设备上去请求队列表中的每一项都是一个单独的请求,有reques结构体体表示。队列中的请求由结构体request表示,定义在文件linux/blkdev.h表示。因为一个请求可能要操作多个连续的磁盘块,所有每个请求可有由多个bio结构体组成,注意,虽然磁盘上的块必须连续,但是在内存中的这些块并不一定要连续----每个bio结构都可以描述多个片段,而每个请求也可以包含多个bio结构体。

 

      好了,我们明白了块IO请求,下面的就是IO调度了。每次寻址的操作就是定位磁盘磁头到特定块上的某个位置,为了优化寻址操作,内核既不会简单地按请求接收次序,也不会立即将其提交给磁盘,相反,它会在提交前,先执行名为合并与排序的预操作,这种预操作可以极大地提高系统的整体性能。

 

      IO调度程序通过两种方法减少磁盘寻址时间:合并与排序。合并指将两个或多个请求结合成一个一个新请求。关于排序的,最有名的当然就是大名鼎鼎的电梯调度。排序就是整个请求队列将按扇区增长方向有序排列,使所有请求按磁盘上扇区的排列顺序有序排列的目的不仅是为了缩短单独一次请求的寻址时间,更重要的优化在于,通过保持磁盘头以直线方向移动,缩短了所有请求的磁盘寻址的时间。关于linux中的电梯调度程序,很多操作系统的书上都已经说的很明白,我这里给出一个大致流程:

 

1.首先,如果队列中已存在一个对相邻磁盘扇区操作的请求,那么新请求将和这个已经存在的请求合并为一个请求。

2.如果队列中存在一个驻留时间过长的请求,那么新请求将被插入到队列尾部,以防止其他旧的请求发生饥饿。

3.如果队列中以扇区方向为序存在合适的插入位置,那么新的请求将被插入到该位置,保证队列中的请求是以被访问磁盘物理位置为序进行排序的。

4.如果队列中不存在合适的请求插入位置,请求将被插入到队列尾部。

 

      我前边提到过电梯调度,但是有一个问题一直没提,那就是电梯调度程序的缺点:饥饿。出于减少磁盘寻址时间的考虑,对某个磁盘区域上的繁重操作,无疑会使得磁盘其他位置上的操作得不到运行机会,实际上,一个对磁盘同一位置操作的请求流可以造成较远位置的其他请求永远得不到运行机会,这是一种很不公平的饥饿现象。更糟糕的是,普通的请求饥饿还会带来写--饥饿--读这种特殊问题。我们知道写操作通常发生在内核有空时,而读操作却必须阻塞知道读请求被满足,这对系统性能影响是非常大的。而且我们知道读请求往往相互依靠,比如要读大量的文件,每次都是针对一块很小的缓冲区进行读操作,而应用程序只有将上一个数据区域从磁盘中读取并返回之后,才能继续读取下一个数据区,所以如果每一次请求都发生饥饿现象,那么对读取文件的应用程序来说,全部延迟加起来会造成过长的等待时间。减少饥饿请求必须以降低全局吞吐量为代价。为了避免这种问题,提出了最后期限IO调度程序,既要尽量提高全局吞吐量,又要使请求得到公平处理。在最后期限IO调度程序中,每个请求都有一个超时时间。默认情况下,读请求的超时时间是500ms,写请求的超时时间是5s。最后期限IO调度请求类似与linux电梯,也以磁盘物理位置为次序维护请求队列,这个队列被称为排序队列。当一个新请求递交给排序队列时,最后期限IO调度程序类似于linux电梯,合并和插入请求,但是最后期限IO调度程序同时也会以请求类型为依据将它们插入到额外队列中。读请求按次序被插入到特定的读FIFO队列中,写请求被插入到特定的写FIFO队列中。虽然普通队列以磁盘扇区为序进行排序,但是这些队列是以FIFO形式组织的,结果新队列总是被加入到队列尾部。对于普通操作来说,最后期限IO调度将请求从排序队列的头部去下,再推入到派发队列中,派发队列然后将请求提交给磁盘驱动,从而保证了最小化的请求寻址。如果在写FIFO队列头,或是在读FIFO队列头的请求超时,那么最后期限IO调度程序便从FIFO队列中提取请求进行服务。依靠这种方法,最后期限IO调度程序试图保证不会发生有请求在明显超期的情况下仍不能得到服务的现象,如下图所示:

 

        

image

 

      最后期限IO调度程序的实现在文件driver/block/deadline-iosched.c中。

 

      虽然最后期限IO调度程序为降低读操作响应时间做了许多工作,但同时也降低了系统吞吐量。考虑这样的情况,假设一个系统正处于很繁重的写操作期间,每次提交新请求,IO调度程序都会迅速处理读请求,这样磁盘会首先为读操作寻址,执行读操作,然后返回再寻址进行写操作,并且对每个读操作都重复这个过程。这种做法明显损害了系统全局吞吐量。这事就有了预测IO调度程序。它的基础就是最后期限IO调度程序。最主要的改进是它增加了预测启发能力。它的不同之处在于读操作提交后并不直接返回处理其他请求,而是会有意空闲片刻。这空闲的几秒钟,对应用程序来说是个提交其他读请求的好机会-----任何对相邻磁盘位置操作的请求都会立刻得到处理。在等待时间结束后,预测IO调度程序重新返回原来的位置,继续执行以前剩下的请求。要注意,如果等待可以减少读请求所带来的向后再向前(back-and-forth)寻址操作,那么完全值得花一些时间来等待更多的请求(这里的时间花在对更多请求的预测上),如果一个相邻的IO请求在等待期带来,那么IO调度程序可以节省两次寻址操作。如果存在愈来愈多的访问同样区域的读请求到来,那么片刻等待无疑会避免大量的寻址操作。当然,不得不说,如果没有IO请求在等待期到来,那么预测IO调度程序会给系统性能带来轻微的损失,浪费掉几毫秒。预测调度程序所带来的优势在于能否正确预测应用程序和文件系统的行为。这种预测依靠一系列的启发和统计工作。预测IO调度程序的实现在文件driver/block/as-iosched.c中。块设备使用哪个IO调度程序是可以选择的。默认的IO调度程序就是预测IO调度程序。