Linux内核设计与实现---块I/O层


系统中能够 随机访问 固定大小数据片的设备被称为块设备,这些数据片称作块,最常见的块设备是硬盘。

另一种基本的设备类型是字符设备。字符设备按照字符流的方法被有序访问,像串口和键盘就都属于字符设备。

如果一个硬件设备是以字符流的方法被访问的话,那就将它归于字符设备;反过来,如果一个设备是随机(无序)访问的,那么它就属于块设备。这两种类型的设备的根据区别在于是否可以随机访问,就是能否在访问设备时随意地 从一个位置跳转到另一个位置。

内核管理块设备要比管理字符设备细致得多,需要考虑的问题和完成的工作相比字符设备来说要复杂的多。这是因为字符设备仅仅需要控制当前位置一个位置,而块设备访问的位置必须能够在介质的不同区间移动。所以事实上内核不必提供一个专门的子系统来管理字符设备,但是对块设备的管理却必须要有一个专门的提供服务子系统。

1 解刨一个块设备

块设备最小的可寻址单元是扇区。扇区大小一般是2的整数倍,扇区的大小是设备的物理属性,扇区是所有块设备的基本单元,块设备无法对比扇区还小的单元进行寻址和操作。

块是文件系统的一种抽象,只能基于块来访问文件系统。虽然物理磁盘寻址是按照扇区级进行的,但是内核执行的所有磁盘操作都是按照块进行的。由于扇区是设备的最小可寻址单元,所以块不能比扇区还小,只能数倍于扇区的大小,另外内核还要求块大小是2的整数倍,而且不能超过一个页的长度。所以,对块大小的最终要求是,必须是扇区大小的2的整数倍,并且要小于页面大小。

我们在这总结,扇区:设备的最小寻址单元,有时会被称作硬扇区或设备块。块:文件系统的最小寻址单元,有时会被称作文件块或I/O块。

2 缓冲区和缓冲区头

当一个块被调入内存时,它要存储在一个缓冲区。每个缓冲区与一个块对应,前面提到过,块包含一个或多个扇区,但大小不能超过一个页面,所以一个页可以容纳一个或多个内存中的块。由于内核在处理数据时需要一些相关的控制信息,所以每一个缓冲区都有一个对应的描述符,该描述符用buffer_head表示,被称作缓冲区头,在文件linux/buffer_head.h中定义,它包含了内核操作缓冲区所需要的全部信息。

/*
 * Keep related fields in common cachelines.  The most commonly accessed
 * field (b_state) goes at the start so the compiler does not generate
 * indexed addressing for it.
 */
struct buffer_head {
	/* First cache line: */
	unsigned long b_state;		/* buffer state bitmap (see above) */
	struct buffer_head *b_this_page;/* circular list of page's buffers */
	struct page *b_page;		/* the page this bh is mapped to */
	atomic_t b_count;		/* users using this block */
	u32 b_size;			/* block size */

	sector_t b_blocknr;		/* block number */
	char *b_data;			/* pointer to data block */

	struct block_device *b_bdev;
	bh_end_io_t *b_end_io;		/* I/O completion */
 	void *b_private;		/* reserved for b_end_io */
	struct list_head b_assoc_buffers; /* associated with another mapping */
};

缓冲区头的目的在于描述磁盘块和物理内存缓冲区之间的映射关系。这个结构体在内核中只扮演一个角色,说明从缓冲区到块的映射关系。

在2.6内核以前,缓冲区头的作用比现在还重要。因为缓冲区头作为内核的I/O操作单元,不仅仅描述了从磁盘块到物理内存的映射,而且还是所有块I/O操作的容器。可是,将缓冲区头作为I/O操作单元带来了两个弊端。

  • 首先,缓冲区头是一个很大且不易控制的数据结构体,而且缓冲区头对数据的操作既不方便,也不清晰。
  • 仅能描述单个缓冲区,当作为所有I/O的容器使用时,缓冲区头会迫使内核打断对大块数据的I/O操作,使其成为对多个buffer_head结构体进行操作。这样做必然会造成不必要的负担和空间浪费。

所以2.5开发版内核的主要目标就是为块I/O操作引入一种新型、灵活并且轻量级的容器,也就是下面要介绍的bio结构体。

3 bio结构体

目前内核中块I/O操作的基本容器由bio结构体表示,它定义在文件linux/bio.h中,该结构体代表了正在现场的以片断链表形式组织的I/O操作。一个片段是一小块连续的内存缓存区。这样的话,就不需要保证单个缓冲区一定要连续。所以通过片段来描述缓冲区,即使一个缓冲区分散在内存的多个位置上,bio结构体也能对内核保证I/O操作的执行。

/*
 * main unit of I/O for the block layer and lower layers (ie drivers and
 * stacking drivers)
 */
struct bio {
	sector_t		bi_sector;
	struct bio		*bi_next;	/* request queue link */
	struct block_device	*bi_bdev;
	unsigned long		bi_flags;	/* status, command, etc */
	unsigned long		bi_rw;		/* bottom bits READ/WRITE,
						 * top bits priority
						 */

	unsigned short		bi_vcnt;	/* how many bio_vec's */
	unsigned short		bi_idx;		/* current index into bvl_vec */

	/* Number of segments in this BIO after
	 * physical address coalescing is performed.
	 */
	unsigned short		bi_phys_segments;

	/* Number of segments after physical and DMA remapping
	 * hardware coalescing is performed.
	 */
	unsigned short		bi_hw_segments;

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

	/*
	 * To keep track of the max hw size, we account for the
	 * sizes of the first and last virtually mergeable segments
	 * in this bio
	 */
	unsigned int		bi_hw_front_size;
	unsigned int		bi_hw_back_size;

	unsigned int		bi_max_vecs;	/* max bvl_vecs we can hold */

	struct bio_vec		*bi_io_vec;	/* the actual vec list */

	bio_end_io_t		*bi_end_io;
	atomic_t		bi_cnt;		/* pin count */

	void			*bi_private;

	bio_destructor_t	*bi_destructor;	/* destructor */
};

使用bio结构体的目的主要是代表正在现场执行的I/O操作,所以该结构体中的主要域都是用来管理相关信息的,其中最重要的几个域是bi_io_vecs、bi_vcnt和bi_idx。请添加图片描述
bi_io_vecs域指向一个bio_vec结构体数组,该结构体链表包含一个特定I/O操作所需要使用的所有片段。每个bio_vec结构都是一个形式<page,offset,len>的向量,它描述的是一个特定的片段:片段所在的物理页、块在物理页中的偏移位置、从给定偏移量开始的块长度。整个bio_io_vec结构体数组表示了一个完整的缓冲区。bio_vec结构体定义在linux/bio.h文件中。

/*
 * was unsigned short, but we might as well be ready for > 64kB I/O pages
 */
struct bio_vec {
	struct page	*bv_page;	/* 指向这个缓冲区所驻留的物理页 */
	unsigned int	bv_len; /* 这个缓冲区以字节为单位的大小 */
	unsigned int	bv_offset;	/* 缓冲区所驻留的页中以字节为单位的偏移量 */
};

在每个给定的块I/O操作中,bi_vcnt域用来描述bi_io_vec所指向的bio_vec数组中的向量数目,当块I/O操作执行完毕后,bi_idx域指向数组的当前bio_vec片段。bi_cnt域记录bio结构体的使用计数,如果该域值减为0,就应该销毁该bio结构体,并释放它所占用的内存。

新老方法对比

缓冲区头和新的bio结构体之间存在明显差别,bio结构体代表的是I/O操作,它可以包括内存中的一个或多个页,另一方面,buffer_head结构体代表一个缓冲区,它描述的是磁盘中的一个块。因为缓冲区头关联的是单独页中的单独磁盘块,所以它可能会引起不必要的分割,将请求按块为单位划分,只能靠以后才能再重新组合,由于bio结构是轻量级的,它描述的块可以不需要连续存储区,并且不需要分割I/O操作。

4 请求队列

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

5 I/O调度程序

如果简单地以内核产生请求的次序直接将请求发向块设备的话,性能肯定让人难以接受。磁盘寻址是整个计算机中最慢的操作之一,所以尽量缩短寻址时间无疑是提供系统性能的关键。

为了优化寻址操作,内核既不会简单地按请求接受次序,也不会立即将其提交给磁盘。相反,它会在提交前,先执行名为合并与排序的预操作,这种操作可以极大地提高系统的整体性能。在内核中负责提交I/O请求的子系统被称为I/O调度程序。

I/O调度程序将磁盘I/O资源分配给系统中所有挂起的块I/O请求,具体来说,这种资源分配是通过将请求队列中挂起的请求合并和排序来完成的。注意不要将I/O调度程序和进程调度程序混淆。进程调度程序的作用是将处理器资源分配给系统中的运行进程。进程调度程序和I/O调度程序都是将一个资源虚拟给多个对象,对进程调度程序来说,处理器被虚拟并被系统中的运行进程共享。I/O调度程序虚拟块设备多个磁盘请求,以便降低磁盘寻址时间。

I/O调度程序的工作

I/O调度程序的工作是管理块设备的请求队列。它决定队列中的请求排序顺序以及在什么时刻派发请求到块设备。这样做有利于减少磁盘寻址时间。

I/O调度程序通过两种方法减少磁盘寻址时间:合并与排序。合并指将两个或多个请求结合成一个新请求。比如系统文件提交请求到请求队列,如果这是请求队列已经存在一个请求,并且它访问的磁盘扇区和当前请求访问的磁盘扇区相邻,那么这两个请求就可以合并为一个对单个和多个相邻磁盘扇区操作的新请求。通过合并请求,I/O调度程序就可以将多次请求的开销压缩成一次请求的开销,请求合并和只需要传递给磁盘一条寻址命令,就可以访问到请求合并前必须多次寻址才能访问完的磁盘区域了,因此合并请求能减少系统开销和磁盘寻址次数。

注意,相邻的磁盘才可以合并,如果请求队列中没有与当前请求磁盘扇区相邻,就不能合并,会将其插入请求队列的尾部。I/O调度程序将整个请求队列按扇区增长方向有序排序,使所有请求按硬盘扇区的排序顺序有序排列的目的不仅是为了缩短单独一次请求的寻址时间,更重要的是,通过保持磁盘头以直线方向移动,缩短了所有请求的磁盘寻址时间。该排序算法类似于电梯调度,电梯不能随意的从一层跳到另一层,它应该向一个方向移动,当抵达同一方向上的最后一层后,再掉头向另一个方向移动。处于这种相似性,所以I/O调度程序被称为电梯调度。

Linus电梯

在2.4内核中,Linus电梯是默认的I/O调度程序。虽然后来在2.6内核中它被另外两种调度程序取替了,但是由于这个电梯算法比后来的调度程序简单,而且它们执行的许多功能类似,所以仍然值得讨论一下。

Linus电梯能执行合并与排序预处理。当有新的请求加入队列时,它首先会检查其他每一个挂起的请求是否可以和新请求合并(请求的扇区是否相邻)。如果合并尝试失败,那么就需要寻找可能的插入点(新请求在队列中的位置必须符合请求以扇区方向有序排序的原则)。如果找到,请求将被插入到该点,如果没有合适的位置,那么新请求就被加入到队列尾部。另外,如果发现队列中有驻留时间过长的请求,那么新请求也将被加入到队列尾部,即使插入后还要排序。这样做是为了避免由于访问相近磁盘位置的请求太多,从而造成访问磁盘其他位置的请求难以得到执行机会这一问题。不幸的事,这种方案并不很有效,因为它仅仅是在经过一段时间后停止插入,并非是给等待了一段时间的请求提供实质性服务,这改善了等待时间但最终还是会导致请求饥饿现象的发生。

总而言之,当一个请求加入大队列时,有可能发生四种操作,它们依次是:

  • 如果队列中已存在一个相邻磁盘扇区操作,那么新请求将和这个已经存在的请求合并成一个请求。
  • 如果队列中存在一个驻留时间过长的请求,那么新请求将被插入到队列尾部,以防止旧的其他请求发生饥饿
  • 如果队列中以扇区方向为序存在合适的插入位置,那么新的请求将被插入到该位置,保证队列中的请求是以被访问磁盘物理位置为序进行排序的
  • 如果队列中不存在合适的请求插入位置,请求将被插入队列尾部

最终期限I/O调度程序

最终期限I/O调度程序是为了解决Linus电梯所带来的饥饿问题而提出来的。出于减少磁盘寻址时间的考虑,对某个磁盘区域上的繁重操作,无疑会使得磁盘其他位置上的操作请求得不到运行机会。

如下图,在最后期限I/O调度程序中,每个请求都有一个超时时间。默认情况下,读请求的超时时间是500毫秒,写请求的超时时间是5秒。最后期限I/O调度请求类似于Linus电梯,也以磁盘物理位置为次序维护请求队列这个队列被称为排序队列。当一个新请求递交给排序队列时,最后期限I/O调度程序类似于Linus电梯,合并插入请求,但是最后期限I/O调度程序同时也会以请求类型为依据将它们插入到额外队列中。读请求按次序被插入到特定的读FIFO队列中,写请求被插入到特定的写FIFO队列中。虽然普通队列以磁盘扇区为序进行排序,但是这些队列(写请求、读请求)是以FIFO组织的。

对于普通操作来说,最后期限I/O调度程序将请求从排序队列的头部取下,再推入到派发队列中,派发队然后将请求提交给磁盘驱动,从而保证了最小化的请求寻址。如果在写FIFO队列头,或是在读FIFO队列头的请求超时,那么最后期限I/O调度程序便从FIFO队列中提取请求进行服务,依靠这种方法,最后期限I/O调度程序试图保证不会发生有请求在明显超期的情况下仍不能得到服务的现象。
在这里插入图片描述
最后期限I/O调度程序的实现在文件drivers/block/deadline-iosche.c中。

预测I/O调度程序

预测I/O调度程序在最终期限调度程序的基础上,增加了一个空闲时间,也就说当请求提交后,不会立即返回处理其他请求,而是可以空闲几个ms,等待磁盘相邻位置的请求。对于应用程序来说,这是一个提交请求的机会,任何相邻磁盘位置操作请求都会得到立即处理。在等待时间结束后,预测IO处理程序返回原来位置基础执行以前剩下的请求。如果一个相邻磁盘位置的请求在空闲时刻到来,那么它就会立刻得到执行,从而减小了二次寻址。

完全公正的排队I/O调度程序

该方法是为了专有工作负荷设计的,在实际中也为多挣工作负荷提供了较好的性能。该方法是将I/O请求根据引起该请求的进程来组织的,比如进程foo发起的请求放进foo队列,bar进程发起的请求放入bar队列。然后,以时间轮片一次从每个队列中选取请求。

空操作的I/O调度程序

所谓空操作是指,该调度方法仅仅进行请求的合并,而不进行其他操作。这主要针对闪存这一类的块设备,这类设备是正在的随机存储,没有必要按序访问。

I/O调度程序的选择

在2.6内核中,有以上四种不同的I/O调度程序,每一种I/O调度程序都可以被启用,并内置在内核中,作为默认,块设备使用预测I/O调度程序,在启动时,可以通过命令行选项elevator = foo来覆盖默认,这里foo是一个有效而激活I/O调度程序。如下表:
在这里插入图片描述
内核命令行选项elevator=cfq会启用完全公正的I/O调度程序给所有的块设备。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值