Linux内核设计与实现(八)| 块I/O层(IO调度程序)

块I/O层

  • 块设备

系统中能够随机(不需要按顺序)访问固定大小数据片(chunks))的硬件设备称作块设备,这些固定大小的数据片就称作块。最常见的块设备是硬盘。

  • 字符设备

字符设备按照字符流的方式被有序访问,像串口和键盘就属于字符设备。如果一个硬件设备是以字符流的方式被访问的话,那就应该将它归于字符设备﹔反过来,如果一个设备是随机(无序的)访问的,那么它就属于块设备。

  • 两者的区别

对于这两种类型的设备,它们的区别在于是否可以随机访问数据——换句话说,就是能否在访问设备时随意地从一个位置跳转到另一个位置。

  • 举个例子

键盘这种设备提供的就是一个数据流,当你输入“wolf”这个字符串时,键盘驱动程序会按照和输入完全相同的顺序返回这个由四个字符组成的数据流。如果让键盘驱动程序打乱顺序来读字符串,或读取其他字符,都是没有意义的。所以键盘就是一种典型的字符设备,它提供的就是用户从键盘输入的字符流。对键盘进行读操作会得到一个字符流,首先是“w”,然后是“o”,再是“l",最后是“x”。当没人敲键盘时,字符流就是空的。硬盘设备的情况就不大一样了。

硬盘设备的驱动可能要求读取磁盘上任意块的内容,然后又转去读取别的块的内容,而被读取的块在磁盘上位置不一定要连续。所以说硬盘的数据可以被随机访问,而不是以流的方式被访问,因此它是一个块设备。

  • 总结

其实可以看到内核管理块设备要比管理字符设备细致得多,需要考虑的问题和完成的工作相对于字符设备来说要复杂许多。这是因为字符设备仅仅需要控制一个位置——当前位置,而块设备访问的位置必须能够在介质的不同区间前后移动。所以事实上内核不必提供一个专门的子系统来管理字符设备,但是对块设备的管理却必须要有一个专门的提供服务的子系统。不仅仅是因为块设备的复杂性远远高于字符设备,更重要的原因是块设备对执行性能的要求很高;对硬盘每多一份利用都会对整个系统的性能带来提升,其效果要远远比键盘吞吐速度成倍的提高大得多。

另外,我们将会看到,块设备的复杂性会为这种优化留下很大的施展空间。这一章的主题就是讨论内核如何对块设备和块设备的请求进行管理。该部分在内核中称作块IO层。

1.剖析一个块设备

  • 概述

块设备中最小的可寻址单元是扇区。扇区大小一般是2的整数倍,而最常见的是512字节扇区的大小是设备的物理属性,扇区是所有块设备的基本单元一一块设备无法对比它还小的单元进行寻址和操作,尽管许多块设备能够一次对多个扇区进行操作。
在这里插入图片描述

因为各种软件的用途不同,所以它们都会用到自己的最小逻辑可寻址单元:块。块是文件系统的一种抽象——只能基于块来访问文件系统。虽然物理磁盘寻址是按照扇区级进行的,但是内核执行的所有磁盘操作都是按照块进行的。由于扇区是设备的最小可寻址单元,所以块不能比扇区还小,只能数倍于扇区大小。另外,内核(对有扇区的硬件设备)还要求块大小是2的整数倍,而且不能超过一个页的长度。所以,对块大小的最终要求是,必须是扇区大小的2的整数倍,并且要小于页面大小。所以通常块大小是512字节、1KB或4KB。

  • 总结
  • 扇区——设备的最小寻址单元,有时会称作“硬扇区”或“设备块”;同样的,
  • 块——文件系统的最小寻址单元,有时会称作“文件块”或“IO块”。
    在这里插入图片描述

2.缓冲区和缓冲区头

  • 概述

当一个块被调入内存时(也就是说,在读入后或等待写出时),它要存储在一个缓冲区中。每个缓冲区与一个块对应,它相当于是磁盘块在内存中的表示。前面提到过,块包含一个或多个扇区,但大小不能超过一个页面,所以一个页可以容纳一个或多个内存中的块。由于内核在处理数据时需要一些相关的控制信息(比如块属于哪一个块设备,块对应于哪个缓冲区等),所以每一个缓冲区都有一个对应的描述符。该描述符用buffer_head结构体表示,称作缓冲区头,它包含了内核操作缓冲区所需要的全部信息。

  • 友情提示

看到这里你可能有些许的晕,我们梳理一下磁盘块、缓冲区和块的关系,磁盘块对应一个缓冲区,而缓冲区中会有很多的块,每个块包含多个或一个的扇区,但单个块的大小不能超过一个页,即单个页能容纳多个块;所以我们说磁盘块是实际磁盘上的,而后面两个则是内存抽象出的数据结构

  • 缓冲头结构
  • b_state属性,该属性表达缓冲区的状态,比如是否包含可用数据、是否包含脏数据、写入写出时发生的错误等
  • b_count属性,表示缓冲区的使用计数,通过函数进行加减,两个函数都是对应的,使用和使用完
  • b_page属性,该属性表示缓冲区对应的内存物理页
  • b_data属性,表示当前块在页面的那个位置,所以块在页面的开始位置应该是b_data,结束处为b_data+b_size
    在这里插入图片描述
  • 为什么要有缓冲头

缓冲区头的目的在于描述磁盘块和物理内存缓冲区(在特定页面上的字节序列)之间的映射关系。这个结构体在内核中只扮演一个描述符的角色,说明从缓冲区到块的映射关系。

3.bio结构体

  • 为什么有bio结构体

试设想一个问题:如果将缓冲区头结构作为IO操作单元的话会有一个弊端:它仅能描述单个缓冲区,当作为所有I/O的容器使用时,缓冲区头会促使内核把对大块数据的IO操作(比如写操作)分解为对多个buffer_head结构体进行操作(因为你写入的很多数据的存放位置不一定都是在一个buffer_head中)。这样做必然会造成不必要的负担和空间浪费。

  • 引入bio结构体

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

  • bio结构体
  • bi_cnt域记录bio结构体的使用计数,如果该域值减为0,就应该撤销该bio结构体,并释放它占用的内存。
  • bi_private域,这是一个属于拥有者(也就是创建者)的私有域,只有创建了bio结构的拥有者可以读写该域。

在这里插入图片描述

第二张图我们描述一下,结构体中掏钱的结构体他们之间的关系
在这里插入图片描述

3.1 I/O向量

  • 概述

bi_io_vec域指向一个bio_vec结构体数组,该结构体链表包含了一个特定I/O操作所需要使用到的所有片段。每个bio_vec结构都是一个形式为<page, offset, len>的向量,它描述的是一个特定的片段:片段所在的物理页、块在物理页中的偏移位置、从给定偏移量开始的块长度。整个bio_io_vec结构体数组表示了一个完整的缓冲区。当块操作完成后,bi_idx指向数组的当前索引(我们就知道IO操作的完成进度)

struct bio_vec {
/*指向这个缓冲区所驻留的物理页*/
struct page		*bv _page;
/*这个缓冲区以字节为单位的大小*/
unsigned int	bv_len;
/*缓冲区所驻留的页中以字节为单位的偏移量*/
unsigned int	bv_offset ;
};
  • 总结

总而言之,每一个块I/O请求都通过一个 bio结构体表示。每个请求包含一个或多个块,这些块存储在bio_vec结构体数组中。这些结构体描述了每个片段在物理页中的实际位置,并且像向量一样被组织在一起。IO操作的第一个片段由b_io_vec结构体所指向,其他的片段在其后依次放置,共有bi_vcnt个片段。当块I/O层开始执行请求、需要使用各个片段时,bi_idx域会不断更新,从而总指向当前片段。

3.2 新老方法对比

  • 概述

所谓新老方法就是指将缓冲区头作为IO操作的容器的老方法还是将bio作为操作容器的新方法;

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

  • 总结

即bio结构体对应一次IO操作,该操作所涉及到的当前缓冲区(如果不同缓冲区是需要多个bio结构体的,所谓不同缓冲区就是不同的磁盘块,单个缓冲区对应单个磁盘块)的所有页都会通过bio_vec链表(底层是数组)连接起来不会引入冗余的多个bio结构;但是缓存头市政描述单个块,而单个块有局限于单个页,那么IO操作会将需要的所有位置的不同缓存头都加在进来这就十分消耗操作了

  • bio的优点
  • bio结构体很容易处理高端内存,因为它处理的是物理页而不是直接指针。
  • bio结构体既可以代表普通页IO,同时也可以代表直接IO(指那些不通过页高速缓存的IO操作)
  • bio结构体便于执行分散-集中(矢量化的)块IO操作,操作中的数据可取自多个物理页面。
  • bio结构体相比缓冲区头属于轻量级的结构体。因为它只需要包含块IO操作所需的信息就行了,不用包含与缓冲区本身相关的不必要信息。

4.请求队列

  • 概述

这个请求队列就是块设备将它们挂起的块IO请求保存在队列中,为一个双向队列。请求队列只要不为空,队列对应的块设备驱动程序就会从对头获取请求,送入对应的块设备,每一项都是一个单独的请求

因为单个请求会操作多个连续的磁盘块,所以每个请求可以由多个bio结构体组成。注意,虽然磁盘上的块必须连续,但是在内存中这些块并不一定要连续——每个bio结构体都可以描述多个片段(回忆下,片段是内存中连续的小区域),而每个请求也可以包含多个bio结构体。

所以一个bio结构体肯定对应一个请求,但是一个请求会对应多个bio结构体

5.I/O调度程序

  • 为什么需要调度程序

如果简单地以内核产生请求的次序直接将请求发向块设备的话,性能肯定让人难以接受。磁盘寻址是整个计算机中最慢的操作之一,每一次寻址(定位硬盘磁头到特定块上的某个位置)需要花费不少时间。所以尽量缩短寻址时间无疑是提高系统性能的关键。

  • IO调度程序

为了优化寻址操作,内核既不会简单地按请求接收次序,也不会立即将其提交给磁盘。相反,它会在提交前,先执行名为合并与排序的预操作,这种预操作可以极大地提高系统的整体性能。在内核中负责提交IO请求的子系统称为IO调度程序。所以IO调度程序就是将请求队列中的请求进行合并排序后进行资源(磁盘IO资源)分配

  • IO调度程序和进程调度程序

这两种子系统看起来非常相似,但并不相同。进程调度程序和I/O调度程序都是将一个资源虚拟给多个对象

  • IO调度程序:I/O调度程序虚拟块设备给多个磁盘请求,以便降低磁盘寻址时间,确保磁盘性能的最优化。
  • 进程调度程序:进程调度程序的作用是将处理器资源分配给系统中的运行进程。对进程调度程序来说,处理器被虛拟并被系统中的运行进程共享。这种虚拟提供给用户的就是多任务和分时操作系统,像Unix系统。

5.1 I/O调度程序的工作

  • 概述

IO调度程序的工作是管理块设备的请求队列。它决定队列中的请求排列顺序以及在什么时刻派发请求到块设备。这样做有利于减少磁盘寻址时间,从而提高全局吞吐量。注意,全局这个定语很重要,坦率地讲,一个UO调度器可能为了提高系统整体性能,而对某些请求不公。

  • 调度程序通过两种方法减少磁盘寻址时间

合并:将两个请求或多个请求合并成一个新请求

就是说新情求到请求队列中,访问读取一个数据,此时队列中已经存在一个请求,他访问的磁盘扇区和当前请求访问的磁盘扇区相邻(比如,同一个文件中早些时候被读取的数据区),那么这两个请求就可以合并为一个对单个和多个相邻磁盘扇区操作的新请求。通过合并请求,IO调度程序将多次请求的开销压缩成一次请求的开销。更重要的是,请求合并后只需要传递给磁盘一条寻址命令,就可以访问到请求合并前必须多次寻址才能访问完的磁盘区域了,因此合并请求显然能减少系统开销和磁盘寻址次数。

排序

我们依照上面的例子,新请求到队列中,队列里面没有操作相邻扇区的请求,此时无法进行合并;但是如果有其他请求需要操作磁盘上类似的位置呢?如果存在一个请求,它要操作的磁盘扇区位置与当前请求比较接近那么是不是该让这两个请求在请求队列上也相邻呢?

事实上,IO调度程序的确是这样处理上述情况的,整个请求队列将按扇区增长方向有序排列。使所有请求按硬盘上扇区的排列顺序有序排列(尽可能的)的目的不仅是为了缩短单独一次请求的寻址时间,更重要的优化在于,通过保持磁盘头以直线方向移动,缩短了所有请求的磁盘寻址时间。该排序算法类似于电梯调度——电梯不能随意地从一层跳到另一层,它应该向一个方向移动,当抵达了同一方向上的最后一层后,再掉头向另一个方向移动。出于这种相似性,所以IO调度程序(或这种排序算法)称作电梯调度。

5.2 Linus

  • 概述

在2.4版本中,该调度程序是默认调度,后在2.6中被另外两种替代

  • 预处理

对于合并和排序的预处理,当新请求进来时会检查其他每个挂起的请求是否可以和新请求合并(向前合并或向后合并),如果新请求正好合并在现存的请求前就是向前合并,向后反之;鉴于文件的分布(扇区号增长)和IO执行方式的典型(从头读向尾),所以向后合并要更常见

  • 合并失败

如果合并尝试失败,那么就需要寻找可能的插入点(新请求在队列中的位置必须符合请求I扇区方向有序排序的原则)。如果找到,新请求将被插入到该点﹔如果没有合适的位置,那么新请求就被加入到队列尾部。

  • “年龄”检测

如果发现队列中有驻留时间过长的请求,那么新请求也将被加入到队列尾部,即使插入后还要排序。这样做是为了避免由于访问相近磁盘位置的请求太多,从而造成访问磁盘其他位置的请求难以得到执行机会这一问题。不幸的是,这种“年龄”检测方法并不很有效,因为它并非是给等待了一段时间的请求提供实质性服务,它仅仅是在经过了一定时间后停止插入一排序请求,这改善了等待时间但最终还是会导致请求饥饿现象的发生,所以这是一个2.4内核IO调度程序中必须要修改的缺陷。

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

5.3 最终期限I/O调度程序

  • 为什么会有最终期限I/O调度程序

最终期限(deadline)I/O调度程序是为了解决Linus电梯所带来的饥饿问题而提出的。出于减少磁盘寻址时间的考虑,对某个磁盘区域上的繁重操作,无疑会使得磁盘其他位置上的操作请求得不到运行机会。实际上,一个对磁盘同一位置操作的请求流可以造成较远位置的其他请求永远得不到运行机会,这是一种很不公平的饥饿现象。

  • 写一饥饿一读(writes-starving-reads)

更糟糕的是,普通的请求饥饿还会带来名为写一饥饿一读(writes-starving-reads)这种特殊问题。写操作通常是在内核有空时才将请求提交给磁盘的,写操作完全和提交它的应用程序异步执行﹔**读操作则恰恰相反,通常当应用程序提交一个读请求时,应用程序会发生堵塞直到读请求被满足,也就是说,读操作是和提交它的应用程序同步执行的。**所以虽然写反应时间(提交写请求花费的时间)不会给系统响应速度造成很大影响,但是读响应时间(提交读请求花费的时间对系统响应时间来说却非同小可。虽然写请求时间对应用程序性能带来的影响不大,但是应用程序却必须等待读请求完成后才能运行其他程序,所以读操作响应时间对系统的性能非常重要。

我们知道读请求往往相互依靠,例如读取大量的文件,每次都是针对于很小的缓冲区进行读操作,应用程序只有在上个数据区中读取完成返回才会继续读取下一个数据区;无论是读还是写都要读取索引节点等的元数据,那么宏观上来看IO操作变成串行化了,这是急需优化的

  • 小结

综上所述,读操作具有同步性,并且彼此之间往往相互依靠,所以读请求响应时间直接影响系统性能,因此2.6版本内核新引入了最后期限IO调度程序来减少请求饥饿现象,特别是读请求饥饿现象。后续我们知道请求虽然不饥饿了,但是会造成吞吐量下降

  • 饥饿与吞吐量的关系

减少请求饥饿必定会降低吞吐量,在上面提到的linus电梯调度中可以提供很好的吞吐量(最小化寻址),我们看到linus调度程序为了平衡两者的关系再后来会去检查驻留时间过长的请求,然后尾插新请求;但是这还不够,最后期限调度做了更多的努力

  • 最终期限I/O调度程序

超时时间

在最后期限VO调度程序中,每个请求都有一个超时时间。默认情况下,读请求的超时时间是500ms,写请求的超时时间是5s。(根据时间设置,几乎读请求来了就会被处理因为会触发超时处理)

排序队列

最后期限I/O调度请求类似于Linus电梯,也以磁盘物理置为次序维护请求队列,这个队列称为排序队列。当一个新请求递交给排序队列时,调度程序在执行合并和插入请求时类似于Linus电梯,但是最后期限IO调度程序同时也以请求类型为依据将它们插入到额外队列中。

读/写FIFO队列

读请求按次序被插入到特定的读FIFO队列中,写请求被插人到特定的写FIFO队列中。虽然普通队列以磁盘扇区为序进行排列,但是这些队列是以FIFO(很有效,以时间为基准排序)形式组织的,结果新请求总是被加入到队列尾部。对于普通操作来说,最后期限IO调度程序将请求从排序队列的头部取下,再推入到派发队列中,派发队列然后将请求提交给磁盘驱动,从而保证了最小化的请求寻址。

配合结构图

如果在写FIFO队列头,或是在读FIFO队列头的请求超时(也就是,当前时间超过了请求指定的超时时间),那么最后期限IO调度程序便从FIFO队列中提取请求进行服务。依靠这种方法,最后期限IO调度程序试图保证不会发生有请求在明显超期的情况下仍不能得到服务的现象

在这里插入图片描述

注意

注意,最后期限IO调度算法并不能严格保证请求的响应时间,但是通常情况下,可以在请求超时或超时前提交和执行,以防止请求饥饿现象的发生。由于读请求给定的超时时间要比写请求短许多,所以最后期限IO调度器也确保了写请求不会因为堵塞读请求而使读请求发生饥饿。这种对读操作的照顾确保了读响应时间尽可能短。

5.4 预测I/O调度程序

  • 最终期限IO程序的缺点

虽然最后期限I/O调度程序为降低读操作响应时间做了许多工作,但是它同时也降低了系统吞吐量。假设一个系统处于很繁重的写操作期间,每次提交读请求,I/O调度程序都会处迅理读请求,所以磁盘首先为读操作进行寻址,执行读操作,然后返回再寻址进行写操作,并且对每个读请求都重复这个过程。这种做法对读请求来说是件好事,但是两次寻址操作(一次对读操作定位,一次返回来进行写操作定位)却损害了系统全局吞吐量。

  • 最终期限和预测调度程序

预测IO调度的基础仍然是最后期限IO调度程序,所以它们有很多相同之处。预测IO调度程序也实现了三个队列(加上一个派发队列),并为每个请求设置了超时时间,这点与最后期限VO调度程序一样。预测IO调度程序最主要的改进是它增加了预测启发(anticipation-heuristic)能力。

  • 预测启发

预测IO调度试图减少在进行IО操作期间,处理新到的读请求所带来的寻址数量。和最后期限IO调度程序一样,读请求通常会在超时前得到处理,但是预测IO调度程序的不同之处在于,请求提交后并不直接返回处理其他请求,而是会有意空闲片刻(实际空闲时间可以攻置,默认为6ms)。这几 ms,对应用程序来说是个提交其他读请求的好机会(当前如果没有新请求那么会随时这么几毫秒)——任何对相邻磁盘位置(当前位置)操作的请求都会立刻得到处理。在等待时间结束后,预测IO调度程序重新返回原来的位置,继续执行以前剩下的请求。

  • 总结

如果预测准确率足够高,那么预测调度程序便可以大大减少服务读请求所需的寻址开销(越多访问同样区域的请求效果就越明显),而且同时仍能满足请求所需要的系统响应时间要求。这样的话,预测IO调度程序既减少了读响应时间,又能减少寻址次数和时间,所以说它既缩短了系统响应时间,又提高了系统吞吐量。

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

  • 概述

完全公正的排队VO调度程序(Complete Fair Queuing ,CFQ)是为专有工作负荷设计的,不过,在实际中,也为多种工作负荷提供了良好的性能。但是,它与前面介绍的IO调度程序有根本的不同

CFQ I/O调度程序把进入的I/O请求放入特定的队列中,这种队列是根据引起IO请求的进程组织的。例如,来自foo进程的I/O请求进入foo队列,而来自bar进程的I/O请求进入 bar队列。在每个队列中,刚进入的请求与相邻请求合并在一起,并进行插入分类。队列由此按扇区方式分类,这与其他IO调度程序队列类似。CFQ I/O调度程序的差异在于每一个提交VO的进程都有自己的队列。

  • 调度队列

CFQTO调度程序以时间片轮转调度队列从每个队列中选取请求数(默认值为4,可以进行配置),然后进行下一轮调度。这就在进程级提供了公平,确保每个进程接收公平的磁盘带宽片断。预定的工作负荷是多媒体,在这种媒体中,这种公平的算法可以得到保证,比如,音频播放器总能够及时从磁盘再填满它的音频缓冲区。不过,实际上,CFQUO调度程序在很多场合都能很好地执行

5.6 空操作的I/O调度程序

  • 概述

该调度程序就是最轻量级的调度,只提供指向合并的功能。这就像它的家务事。当一个新的请求提交到队列时,就把它与任一相邻的请求合并。除了这一操作,空操作IO调度程序的确再不做什么,只是维护请求队列以近乎FIFO的顺序排列,块设备驱动程序(随机访问的特性)便可以从这种队列中摘取请求。

5.7 调度程序的设置

  • 概述

你现在已经看到2.6内核中四种不同的IO调度程序。其中的每一种IO调度程序都可以被启用,并内置在内核中。作为缺省,块设备使用完全公平的I/O调度程序(默认)。在启动时,可以通过命令行选项elevator=foo来覆盖缺省,这里foo是一个有效而激活的IO调度程序
在这里插入图片描述

  • 0
    点赞
  • 6
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值