Linux块设备I/O栈浅析

Linux存储系统包括两个部分:第一部分是给在用户的角度提供读/写接口,数据以流为表现形式;第二部分是站在存储设备的角度提供读/写接口,数据以块为表现形式。文件系统位于两者中间起到承上启下的作用。

以块为表现形式,既块存储,简单来说就是使用块设备来为系统提供存储服务。本问重点在于块设备的IO栈。



0x01 块设备基本概念

块设备将信息存储在固定大小的块中,每个块都有自己的地址。对操作系统来说,块设备是以字符设备的外观展现的,虽然对这种字符设备可以按照字节为单位进行访问,但是实际上到块设备上却是以块为单位(最小512byte,既一个扇区)。这之间的转换是由操作系统来完成的。

下面介绍块设备的基本概念:

  1. 扇区:磁盘盘片上的扇形区域,逻辑化数据,方便管理磁盘空间,是硬件设备数据传输的基本单位,一般为512byte。
  2. 块:块是VFS(虚拟文件系统)和文件系统数据传输的基本单位,必须是扇区的整数倍,格式化文件系统时,可以指定块大小。

0x02 块设备I/O栈

1 基本概念

本节介绍几个块设备I/O栈的基本概念。

  1. bio:bio是通用块层I/O请求的数据结构,表示上层提交的I/O求情。一个bio包含多个page(既page cache 内核缓冲页 在内存上),这些page对应磁盘上的一段连续的空间。由于文件在磁盘上并不连续存放,文件I/O提交到块这杯之前,极有可能被拆分成多个bio结构。
  2. request:表示块设备驱动层I/O请求,经由I/O调度层转换后(既电梯算法合并)的I/O请求,将会被发送到块设备驱动层进行处理。
  3. request_queue: 维护块设备驱动层I/O请求的队列,所有的request都插入到该队列,每个磁盘设备都只有一个queue(多个分区也只有一个)。

这三个结构的关系如下图所示,一个request_queue中包含多个request,每个request可能包含多个bio,请求的合并就是根据各种算法(1.noop 2.deadline 3.CFQ)将多个bio加入到同一个request中。


2. 请求处理流程

在这里插入图片描述

先说一个Direct I/O和Buffer I/O的区别:

  1. Direct I/O绕过page cache,Buffer I/O是写到page cache中表示写请求完成,然后由文件系统的刷脏页机制把数据刷到硬盘。因此,使用Buffer I/O,掉电时有可能page cache中的脏数据还未刷到磁盘上,导致数据丢失。
  2. Buffer I/O机制中,DMA方式可以将数据直接从磁盘读到page cache中(与直接读不同的是,不需要CPU参与),或者从page cache中将数据写回到磁盘上,而不能直接在应用程序地址空间和磁盘之间进行数据传输,这样的话,数据在传输过程中需要在应用程序地址空间和page cache之间进行多次数据拷贝操作,这些数据拷贝操作所带来的CPU以及内存的开销是非常大的(磁盘到page cache使用DMA CPU不参与,page cache到应用地址空间的数据复制需要CPU的参与)。而Direct I/O的优点就是通过减少操作系统内核缓冲区和应用程序地址空间的数据拷贝次数,降低了对文件读取和写入时所带来的的CPU的使用和内存的占用,但是Direct I/O的读操作不能从page cache中获取数据,而是直接从磁盘上读取,带来性能上的损失。为了解决这个问题,Direct I/O会与异步I/O结合起来使用。
  3. Direct I/O一般用于需要自己管理缓存的应用,如数据库系统。

下面说I/O的读写流程,简单描述如下:

  1. 用户调用系统调用write写一个文件,会调到sys_write函数
  2. 经过VFS虚拟文件系统层,调用vfs_write, 如果是缓存写方式,则写入page cache,然后就返回,后续就是刷脏页的流程;如果是Direct I/O的方式,就会走到do_blockdev_direct_IO的流程
  3. 如果操作的设备是逻辑设备如LVM(logical volume manager),MDRAID设备等,会进入到对应内核模块的处理函数里进行一些处理,否则就直接构造bio请求,调用submit_bio往具体的块设备下发请求,submit_bio函数通过generic_make_request转发bio,generic_make_request是一个循环,其通过每个块设备下注册的make_request_fn函数与块设备进行交互
  4. 请求下发到底层的块设备上(应该是属于I/O调度层),调用块设备请求处理函数__make_request进行处理,在这个函数中就会调用blk_queue_bio,这个函数就是合并bio到request中,也就是I/O调度器的具体实现:如果几个bio要读写的区域是连续的,就合并到一个request;否则就创建一个新的request,把自己挂到这个request下。合并bio请求也是有限度的,如果合并后的请求超过阈值(在/sys/block/xxx/queue/max_sectors_kb里设置),就不能再合并成一个request了,而会新分配一个request
  5. 接下来的I/O操作就与具体的物理设备有关了,交由相应的块设备驱动程序进行处理,这里以scsi设备为例说明,queue队列的处理函数request_fn对应的scsi驱动的就是scsi_request_fn函数,将请求构造成scsi指令下发到scsi设备进行处理,处理完成后就会依次调用各层的回调函数进行完成状态的一些处理,最后返回给上层用户

3. bcache

bcache是Linux内核的块层缓存,它使用固态硬盘作为硬盘驱动器的缓存,既解决了各台硬盘容量太小的问题,有解决了硬盘驱动器运行速度太慢的问题。

bcache从3.10版本开始被集成进内核,支持三种缓存策略,分别是写回(writeback)、写透(writethrough)、writearound, 默认使用writethrough,缓存策略可被动态修改。、


0x03 参考

  1. 《Linux开源存储全栈详解》

 

0x04 另一个版本

注:此版本参考《Linux内核设计与实现》第14章

 

0x05 简要再说一下概念

块设备:可以随机访问数据的设备。

扇区:块设备中最小的可寻址单元。

块:内核对磁盘执行的所有操作都是按照块来执行的,并且只能基于块来访问文件系统。

 

由于扇区是设备的最小可寻址单元,所以块不能比扇区还小,只能数倍于扇区大小。另外,内核(对有扇区的硬件设备)还要求块的大小是2的整数倍,而且不能超过一个也的长度。所以对块大小的最终要求是,必须是扇区大小的2的整数倍,并且要小于页面大小,所以通常块大小是512字节,1K或者4K。

 

当一个块被调入到内存时(在读入或准备写出),它要存储在一个缓冲区中,每个缓冲区与一个块对应,它相当于磁盘块在内存中的表示。

因为块的大小不能超过一个页,所以一个也可以容纳一个或多个内存中的块。

 

0x06 buffer_head

块在内存缓冲区中,需要一个数据结构来进行管理,在内核2.6以前,都是用buffer_head进行管理的。

一个buffer_head长这样。

在这里插入图片描述
 
直接使用这个数据结构对块进行管理有两个弊端,第一个是,缓冲区头(buffer_head)是一个很大且不容易控制的数据结构体(现在是缩减过的了),而且缓冲区头对数据的操作既不方便也不清晰。对内核来说,它倾向于操作页面结构,因为页面操作起来更为简便,同时效率也高。第二个是,它仅能描述单个缓冲区,当作为所有IO容器使用时,缓冲区头会促使内核把对大块数据的IO操作分解为多个buffer_head结构体进行操作,这样做必然会造成不必要的负担和空间浪费。

 

0x07 bio

2.6之后内核出了一个新的数据结构要改良buffer_head。

大概长这样。

在这里插入图片描述
 
从数据结构中可以看出,bio是通过页和页内偏移来管理块信息的。同时,bio可以描述多个块信息。

在这里插入图片描述
在这里插入图片描述

bio的好处有:bio可以更方便的使用高端内存,因为它只与page打交道,并不直接的使用地址;bio结构体即可以代表普通页IO也可以代表直接IO;有利于执行分赛-集中块IO操作,操作中的数据可取自多个物理页面。;bio属于轻量级结构体。

但是也不能抛弃buffer_head直接使用bio,因为buffer_head还负责描述磁盘块到页面的映射,bio中并没有。
 

0x08 调度程序

如果简单的以内核产生请求的次序直接将请求发送给块设备的话,性能肯定会很差。因为磁盘寻址需要花费不少时间。所以尽量缩短寻址时间是提高系统性能的关键。下面介绍几个调度算法。

 

Linus电梯

嗯,这确实是一个人的名字。

 

Linus电梯能执行合井与排序预处理。当有新的请求加入队列时, 它首先会桧查其他每一个挂起的请求是否可以和新请求合井。Linus电梯IO调度程序可以执行向前和向后合井, 合并类型描述的是请求向前面还是向后面, 这一点和已有请求相连。如果新请求正好连在一个现存的请求前, 就是向前合井;相反如果新请求直接连在一个现存的请求后, 就是向后合井。鉴于文件的分布(通常以扇区号的增长表现)特点和IO操作执行方式具有典型性(一般都是从头读向尾,很少从反方向读), 所以向前合并相比向后合井要少得多, 但是Linus电梯还是会对两种合井类型都进行检查。

如果合并尝试失败, 那么就需要寻找可能的插入点(新请求在队列中的位置必须符合请求以扇区方向有序排序的原则)。 如果找到, 新请求将被插人到该点:如果没有合适的位置, 那么新请求就袚加入到队列尾部。 另外, 如果发现队列中有驻留时间过长的请求, 那么新请求也将被加入到队列尾部, 即使插入后还要排序。 这样做是为了避免由于访问相近磁盘位置的请求太多, 从而造成访问磁盘其他位置的请求难以得到执行机会这一问题。

虽然采取了措施避免了一定程度上IO饥饿的问题,但是这种措施的改进并不明显,最终还是会导致饥饿的发生。

 

最终期限IO调度程序

这个调度程序是为了解决linus调度饥饿的问题而提出的。

 

在最后期限IO调度程序中, 每个请求都有一个超时时间。默认情况下, 读请求的超时时间是500ms, 写请求的超时时间是5s。 最后期限IO调度请求类似于Linus电梯, 也以磁盘物理位置为次序维护请求队列, 这个队列称为排序队列。 当一个新请求递交给排序队列时, 最后期限IO调度程序在执行合井和插人请求时类似于Linus电梯, 但是最后期限IO调度程序同时也会以请求类型为依据将它们插入到额外队列中。 读请求按次序被插入到特定的读FIFO队列中, 写请求被插入到特定的写FIFO队列中。 虽然普通队列以磁盘扇区为序进行排列, 但是这些队列是以FIFO (很有效,以时间为基准排序)形式组织的, 结果新队列总是被加入到队列尾部。 对于普通操作来说, 最后期限IO调度程序将请求从排序队列的头部取下, 再推入到派发队列中, 派发队列然后将请求提交给磁盘驱动, 从而保证了最小化的请求寻址。
如果在写FIFO队列头, 或是在读FIFO队列头的请求超时(也就是, 当前时间超过了请求指定的超时时间), 那么最后期限IO调度程序便从FIFO队列中提取请求进行服务。 依靠这种方法, 最后期限IO调度程序试图保证不会发生有请求在明显超期的情况下仍不能得到服务的现象。

简要来说,就是有三个队列,一个是request_queue,类似于Linus算法里的队列,也提供合并排序操作;两个FIFO队列,一个负责放写入请求,一个放读取请求。当FIFO队列队首元素超时时,则通过FIFO队列进行请求的调度,如果都没超时,则正常按照request_queue来调度。

在这里插入图片描述

图中第三个队列不太清晰,原文叫排序队列。

 

预测IO调度程序

这个调度算法考虑了这么一种情况。每次提交读情请求的时候,系统先进行读操作,再进行写操作,那么就会进行两次寻址。如果写任务繁重的话,那么寻址次数时很可观的。

该调度算法基于最后期限算法提出了这么一种思想,当请求提交后并不会立即返回处理其他请求,而是有意空闲片刻(默认6ms)。在这段时间内系统可以提交读请求,任何对相邻磁盘位置操作的读请求都会立刻得到处理。在等待时间结束后,预测IO调度程序重新返回原来的位置,继续执行以前剩下的请求。

 
对大多数工作负荷来说执行良好,对服务器也是理想的。不过,在某些非常见而又有严格工作符合的服务器上,这个调度程序执行的效果不好。

 

完全公正的排队IO调度程序

这种调度算法讲究完全公平。

思想是这样的,对每个进程维护一个IO队列,以时间片轮转的方式对执行每个线程的请求。

虽然简单,但是它几乎在所有的工作负荷中都能很好的执行。

 

空操作的IO调度程序

维护一个FIFO队列,请求到来时不排序,但是进行合并。

 

0x09 结束

强行凑一个,哈哈,白了个白

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值