首页 > 代码库 > linux块设备的IO调度算法和回写机制

linux块设备的IO调度算法和回写机制

**************************************************************************************

参考:

《Linux内核设计与实现》 

http://laokaddk.blog.51cto.com/368606/699028/

http://www.cnblogs.com/zhenjing/archive/2012/06/20/linux_writeback.html

**************************************************************************************

1 Linux块IO请求

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

当一个块被调入内存时,它要存储在一个缓冲区中,每个缓冲区与一个块对应,它相当于是磁盘块在内存中的表示。另外,由于内核在处理数据时需要一些相关的控制信息,所以每个缓冲区都有一个叫做buffer_head的描述符来表示,被称为缓冲区头,在linux/buffer_head.h中定义。
这个结构体在内核中扮演一个描述符的角色,说明从缓冲区到块的映射关系。使用缓冲区头作为I/O操作有它的弊端,这里不细说,你明白就好。我们只需知道现在的内核采用了一种新型,灵活而且轻量级的容器---bio结构体。
      bio结构体定义在linux/bio.h中,该结构体代表了正在现场的(活动)以片断(segment)链表形式组织的块I/O操作。一个片断是一小块连续的内存缓冲区。这样的话,就不需要保证单个缓冲区一定要连续,所有通过片断来描述缓冲区,即使一个缓冲区分散在内存的多个位置上,bio结构体也能保证I/O操作的执行。下面给出bio结构体和各个域的描述,如下:

struct bio {
	        sector_t             bi_sector;         /* associated sector on disk */
	        struct bio           *bi_next;          /* list of requests */
	        struct block_device  *bi_bdev;          /* associated block device */
	        unsigned long        bi_flags;          /* status and command flags */
	        unsigned long        bi_rw;             /* read or write? */
	        unsigned short       bi_vcnt;           /* number of bio_vecs off */
	        unsigned short       bi_idx;            /* current index in bi_io_vec */
	        unsigned short       bi_phys_segments;  /* number of segments after coalescing */
	        unsigned short       bi_hw_segments;    /* number of segments after remapping */
	        unsigned int         bi_size;           /* I/O count */
	        unsigned int         bi_hw_front_size;  /* size of the first mergeable segment */
	        unsigned int         bi_hw_back_size;   /* size of the last mergeable segment */
                unsigned int         bi_max_vecs;       /* maximum bio_vecs possible */
                struct bio_vec       *bi_io_vec;        /* bio_vec list */
	        bio_end_io_t         *bi_end_io;        /* I/O completion method */
	        atomic_t             bi_cnt;            /* usage counter */
	        void                 *bi_private;       /* owner-private method */
	        bio_destructor_t     *bi_destructor;    /* destructor method */
	};

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

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

struct bio_vec {
	   struct page     *bv_page;   /* pointer to the physical page on which this buffer resides */ 
	   unsigned int    bv_len;     /* the length in bytes of this buffer */ 
	   unsigned int    bv_offset;   /* the byte offset within the page where the buffer resides */ 
	};


      下面来分析以上上面的那个图,我们说:每一个块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结构体,并释放它占用的内存。通过下面两个函数管理使用计数:

void bio_get(struct bio *bio);
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调度了。每次寻址的操作就是定位磁盘磁头到特定块上的某个位置,为了优化寻址操作,内核既不会简单地按请求接收次序,也不会立即将其提交给磁盘,相反,它会在提交前,先执行名为合并与排序的预操作,这种预操作可以极大地提高系统的整体性能。

2 Linux内核块设备IO子系统

Linux IO调度程序是块设备I/O子系统的主要组件,它介于通用块层和块设备驱动程序之间,如下图所示。当Linux内核组件要读写数据时,并非一有请求便立即执行,而是将请求放入请求(输入)队列,并推迟执行。为什么如此设计?原因在于Linux需要应对的最核心的块设备是磁盘。磁盘的寻道时间严重制约磁盘性能,若想提高磁盘IO性能必须想尽办法减少磁盘寻道次数。

块设备I/O子系统最核心的任务也就是提高块设备的整体性能,为此Linux实现了四种IO调度算法,算法基本思想就是通过合并和排序IO请求队列中的请求大大降低所需的磁盘寻道时间,从而提供整体IO性能。

2.6内核实现了四种IO调度算法,分别为预期(Anticipatory)算法、最后期限(Deadline)算法、完全公平对列(CFQ)算法以及NOOP算法(No Operation)。用户可在内核引导时指定一种I/O调度算法,也可在运行时通过 sysfs 文件系统/sys/block/sda/queue/scheduler改变块设备的I/O调度算法(cat可查看当前使用IO调度算法)。默认的IO调度程序是"预测"IO调度程序。

“Noop”算法

最简单的 I/O调度算法。该算法仅适当合并用户请求,并不排序请求:新的请求通常被插在调度队列的开头或末尾,下一个要处理的请求总是队列中的第一个请求。这种算法是为不需要寻道的块设备设计的,如SSD。

“CFQ”算法

"CFQ(完全公平队列)”算法的主要目标是在触发I/O请求的所有进程中确保磁盘I/O带宽的公平分配。为了达到这个目标,算法使用许多个排序队列——缺省为64——它们存放了不同进程发出的请求。当算法处理一个请求时,内核调用一个散列函数将当前进程的线程组标识符(PID);然后,算法将一个新的请求插人该队列的末尾。因此,同一个进程发出的请求通常被插入相同的队列中。

算法本质上采用轮询方式扫描I/O输入队列,选择第一个非空队列,依次调度不同队列中特定个数(公平)的请求,然后将这些请求移动到调度队列的末尾

“最后期限”算法

除了调度队列外,“最后期限”算法还使用了四个队列。其中的两个排序队列分别包含读请求和写请求,其中的请求是根据起始扇区号排序的。另外两个最后期限队列包含相同的读和写请求,但这是根据它们的“最后期限”排序的。引人这些队列是为了避免请求饿死,由于电梯策略(曾经的调度算法)优先处理与上一个所处理的请求最近的请求,因而就会对某个请求忽略很长一段时间,这时就会发生这种情况。请求的最后期限本质上就是一个超时定时器,当请求被传给电梯算法时开始计时。缺省情况下,读请求的超时时间是500ms,写请求的超时时间是5s——读请求优先于写请求,因为读请求通常阻塞发出请求的进程。最后期限保证了调度程序照顾等待很长一段时间的那个请求,即使它位于排序队列的末尾。


当算法要补充调度队列时,首先确定下一个请求的数据方向。如果同时要调度读和写两个请求,算法会选择“读”方向,除非该“写”方向已经被放弃很多次了(为了避免写请求饿死)。

接下来,算法检查与被选择方向相关的最后期限队列:如果队列中的第一个请求的最后期限已用完,那么算法将该请求移到调度队列的末尾。同时,它也会移动该过期的请求后面的一组来自排序队列的相同扇区号的请求。如果将要移动的请求在磁盘上物理相邻,那么这一批队列的长度会很长,否则就很短。

最后,如果没有请求超时,算法对来自于排序队列的最后一个请求连带之后的一组相同扇区的请求进行调度。当指针到达排序队列的末尾时,搜索又从头开始(“单方向算法”)。

“预期”算法

“预期”算法是Linux提供的最复杂的一种1/O调度算法。基本上,它是“最后期限”算法的一个演变,借用了“最后期限”算法的基本机制:两个最后期限队列和两个排序队列;I/O调度程序在读和写请求之间交互扫描排序队列,不过更倾向于读请求。扫描基本上是连续的,除非有某个请求超时。读请求的缺省超时时间是125ms,写请求的缺省超时时间是250ms。但是,该算法还遵循一些附加的启发式准则:

有些情况下,算法可能在排序队列当前位置之后选择一个请求,从而强制磁头从后搜索。这种情况通常发生在这个请求之后的搜索距离小于在排序队列当前位置之后对该请求搜索距离的一半时。

算法统计系统中每个进程触发的I/O操作的种类。当刚刚调度了由某个进程p发出的一个读请求之后,算法马上检查排序队列中的下一个请求是否来自同一个进程p。如果是,立即调度下一个请求。否则,查看关于该进程p的统计信息:如果确定进程p可能很快发出另一个读请求,那么就延迟一小段时间(缺省大约为7ms)。因此,算法预测进程p发出的读请求与刚被调度的请求在磁盘上可能是“近邻”。

3 Linux 回写机制

不管如何优化块设备调度算法,也不可能解决磁盘IO和CPU速度严重不匹配的问题,为此Linux引入了页高速缓存。页高速缓存最开始是为内存管理而设计的,在2.6内核中,各种基于页的数据管理都纳入页高速缓存。因此块设备的IO缓冲区也属于页高速缓存。这些和使用者无关,是内核开发者需要关心的。对于开发者,需要知道的是:所有文件的IO操作都是“读写缓存”。对于读操作,只有当数据不在缓存时才需要IO操作。对于写操作,一定需要IO操作,但内核把数据写到高速缓存后write系统调用立马返回,内核采用特定的写进程统一回写dirty的缓存页。即内核对读写是分别对待的:“同步读,异步写”!

Linux的写回由特定进程按照特定算法来进行回写。在2.6.32之前的内核,pdflush后台回写例程负责完成这个工作。啥时回写呢? 下面两种情况下,脏页会被写会到磁盘:

1.在空闲内存低于一个特定的阈值时,内核必须将脏页写回磁盘,以便释放内存。
2.当脏页在内存中驻留超过一定的阈值时,内核必须将超时的脏页写会磁盘,以确保脏页不会无限期地驻留在内存中。

回写开始后,pdflush会持续写数据,直到满足以下两个条件:

1.已经有指定的最小数目的页被写回到磁盘。
2.空闲内存页已经回升,超过了阈值dirty_background_ration。

系统管理员可以在/proc/sys/vm中设置回写相关的参数,也可以通过sysctl系统调用来设置它们。下表给出了可以设置的量:

回写机制看上去很完美,在实际工作中却未必。一个问题是:pdflush线程数据是可变的(2-8),但面对的是所有块设备的数据,当某个块设备很慢,必然阻塞其他块设备的回写。为此2.6.32引入新的pdflush线程模型,每个块设备拥有独立的pdflush线程,设备之间的回写不再互相干扰。

回写缺陷

看上去如此完美的回写机制,实际中有2个缺陷:1) 回写不及时引发丢数据(sync|fsync);2) 回写期间读IO性能极差,尤其是大数据量时。

Linux:2.6.16 
内存:32G
测试过程:限速情况下持续追加写入磁盘,速度20-10MB/s, 
实测曲线:

结论:pdflush回写期间极其消耗磁盘IO,严重影响读性能。



linux块设备的IO调度算法和回写机制