Linux块设备的I/O操作
参考《Linux内核设计与实现》
1. 块设备概述
系统中能随机(不需要按顺序)访问固定大小数据片的硬件设备,称作块设备。这些固定大小的数据片称作块。块设备包括:硬盘、软盘驱动器、蓝光光驱和闪存等
块设备特点:
1)随机访问(字符设备不可随机访问,按字节流顺序访问)
2)对块设备的性能要求很高,对硬盘每多一份利用都会对整个系统的性能带来提升。
块设备相关概念:
名称 | 别称 | 意义 |
---|---|---|
扇区 | 硬扇区、设备块 | 块设备的最小可寻址单元,常见为512字节 |
块 | 文件块、I/O块 | 块设备的最小逻辑可寻址单元,文件系统最小寻址单元 扇区大小的2的整数倍,小于页大小。 通常为512字节、1KB、4KB 一个磁盘块对应一个buffer_head结构 |
2. 相关数据结构和关系
2.1 缓冲区与缓冲头
缓冲区与块一一对应,包含缓冲区头和真正的缓冲区内容两部分。缓冲区头,用 buffer_head 结构体定义表示,在文件
/* 定义在 <include/linux/buffer_head.h> 中 */
struct buffer_head {
unsigned long b_state; /* 缓冲区状态标志,其值定义在enum bh_state_bits中 */
struct buffer_head *b_this_page; /* 页面缓冲区的循环列表 */
struct page *b_page; /* 当前缓冲区映射到的页面 */
sector_t b_blocknr; /* 起始块号,索引缓冲区对应的磁盘物理块 */
size_t b_size; /* 映像的大小 */
char *b_data; /* 页面内的数据指针 */
struct block_device *b_bdev; /* 块设备的逻辑块号 */
bh_end_io_t *b_end_io; /* I/O 完成方法 */
void *b_private; /* reserved for b_end_io */
struct list_head b_assoc_buffers; /* 相关的映射链表 */
/* associated with another mapping */
struct address_space *b_assoc_map; /* 相关的地址空间 */
/* mapping this buffer is associated with */
atomic_t b_count; /* 缓冲区使用计数,几个进程在使用这个buffer */
}
- b_state域:表示缓冲区状态
- 合法标志存放在bh_state_bits枚举中,在 < linux/buffer_head.h > 中定义
- 在这些标志中,BH_PrivateStart定义了驱动程序自定义的状态标志的起始位置。
- b_count域:表示缓冲区使用计数
- 操作前,调用 get_bh(),增加计数,确保缓冲区投不会再被分配出去;
- 操作后,调用 put_bh(),减少计数。
- b_blocknr域:索引缓冲区对应的磁盘物理块,是b_bdev域指明的块设备中的逻辑块号
- b_page域:表示缓冲区对应的内存物理页
- b_data域:块在b_page所指页面中的起始位置
- b_size域:块的大小
内核2.5版本前,使用缓冲区头作为I/O操作的容器,存在弊端:
- 缓冲区头很大且不易控制,对内核来说,它更倾向于操作页面(page),页面操作更为简便,同时效率也高。使用缓冲区头表示每一个独立的缓冲区(应当比页面结构小)效率低下;2.6版本中,许多I/O操作都通过内核直接对页面或地址空间操作完成,不再使用缓冲区头了。这其中所做的一些工作会在第16章中讨论,具体情况请参考 address_space 结构和 pdflush 等守护进程(daemon)部分。
- buffer_head作为I/O容器使用时,缓冲区头会迫使内核打断对大块数据I/O操作,使其成为对多个buffer_head结构体进行操作,造成不必要的负担和空间浪费。2.5开发版引入轻量级的容器bio结构体,解决这个问题。原来由buffer_head一个结构完成的工作,改由 buffer_head和bio共同完成;buffer_head只给上层提供有关其所描述的块的状态,而bio负责将尽可能多的块拼合,传递给下层驱动程序,并最终写入硬盘。
2.2 bio结构体
bio 结构体主要代表 正在现场执行 的I/O操作,所以该结构体中主要域都是用来管理相关信息的,定义于< linux/bio.h >中
/* 定义于<include/linux/bio.h>中 */
struct bio {
sector_t bi_sector; /* 磁盘上相关的扇区 */
struct bio *bi_next; /* 请求链表 */
struct block_device *bi_bdev; /* 相关的块设备 */
unsigned long bi_flags; /* 状态和命令标志 */
unsigned long bi_rw; /* 读还是写 */
unsigned short bi_vcnt; /* bio_vec的个数 */
unsigned short bi_idx; /* bio_vec的当前索引 */
unsigned short bi_phys_segments; /* 结合后的片断数目 */
unsigned int bi_size; /* I/O计数 */
unsigned int bi_seg_front_size; /* 第一个可合并的段大小 */
unsigned int bi_seg_back_size; /* 最后一个可合并的段大小 */
unsigned int bi_max_vecs; /* bio_vecs数目上限 */
unsigned int bi_comp_cpu; /* completion CPU */
atomic_t bi_cnt; /* 使用计数 */
struct bio_vec *bi_io_vec; /* bio_vecs链表 */
bio_end_io_t *bi_end_io; /* I/O完成方法 */
void *bi_private; /* 拥有者的私有方法 */
bio_destructor_t *bi_destructor; /* destructor */
struct bio_vec bi_inline_vecs[0]; /* 内嵌bio向量 */
}
struct bio_vec {
struct page *bv_page; /* 指向缓冲区所驻留的物理页 */
unsigned int bv_len; /* 缓冲区以字节为单位的大小 */
unsigned int bv_offset; /* 缓冲区所驻留页中以字节为单位的偏移量 */
}
- bi_cnt域:记录bio结构体的使用计数
- 若为0,撤销该bio结构体,并释放它占用的内存
- 通过函数 void bio_get(struct bio * bio) 和 void bio_put(struct bio * bio) 管理bi_cnt
- bi_private域:只有创建了bio结构的拥有着可以读写这个区域
bio 与 buffer_head 的关系:
- 一个bio是一个I/O操作的基本单位,
- 一个bio里包含多个bio_vec,
- 每个bio_vec对应一个segment,
- 每个segment包含几个连续的buffer。
- buffer_head用于保存每一个buffer中物理内存和磁盘块之间的映射结构
- bio根据buffer_head描述的各个块的状态,将相应块收集起来,交给底层驱动程序
利用bio+buffer_head代替纯buffer_head进行I/O操作的好处:
- bio结构体代表的是I/O操作,它可以包括内存中的一个或多个页;另一方面,buffer_head结构体代表的是一个缓冲区,它描述的仅仅是磁盘中的一个块。bio结构体是轻量级的,它描述的块可以不需要连续存储区,并且不需要分割I/O操作。
- bio结构体很容易处理高端内存,因为它处理的是物理页而不是直接指针
- bio结构体既可以代表普通页I/O,也可以代表直接I/O。(指那些不通过页高速缓存的I/O操作)
- bio结构体便于执行分散-集中(矢量化的)块I/O操作,操作中的数据可取自多个物理页面
- bio结构体比缓冲区头属于轻量级的结构体。因为它只需要包含块I/O操作所需的信息,不用包含与缓冲区本身相关的不必要信息。
附加知识:
1. 高端内存最基本思想:借一段地址空间,建议临时映射,打到这段地址空间可以循环使用,访问所有物理内存。访问基于页面
2. 缓存I/O又被称作标准I/O,大多数文件系统的默认I/O操作都是缓存I/O。在Linux的缓存I/O机制中,数据先从磁盘复制到内核空间的缓冲区,然后从内核空间缓冲区复制到应用程序的地址空间。
3. 直接I/O就是应用程序直接访问磁盘数据,而不经过内核缓冲区,这样做的目的是减少一次从内核缓冲区到用户程序缓存的数据复制。比如说数据库管理系统这类应用,它们更倾向于选择它们自己的缓存机制,因为数据库管理系统往往比操作系统更了解数据库中存放的数据,数据库管理系统可以提供一种更加有效的缓存机制来提高数据库中数据的存取性能。
2.3. 请求队列
- 块设备将挂起的块I/O请求保存到请求队列中,由结构体 request_queue 表示,定义在文件< linux/blkdev.h >中,包含一个双向请求链表以及相关控制信息。
- 链表中的每一项都是一个单独的请求,由结构体request表示,定义在文件< linux/blkdev.h >中。
- 一个request可能操作多个连续磁盘块,所以每个request可以包含多个bio结构体(磁盘块必须连续,但映射到内存中却不一定连续,因此需要多个bio结构体)
2.4 几种数据结构之间的关系
3. I/O调度算法
I/O调度程序的工作:
目的:管理块设备请求队列,决定请求在什么时刻派发到块设备,以减少磁盘寻址时间,从而提高全局吞吐量。
方法:
1) 合并:将两个或多个请求合成一个请求。(场景:两个请求寻址相邻的磁盘扇区)
2) 排序:整个请求队列按扇区增长方向有序排列。
3.1 Linus电梯
2.4版内核中,Linus是默认I/O调度程序。在2.6版内核中被另外两种调度程序取代了
当一个新请求加入到队列中时,有可能发生四种操作:
- 若队列中已存在一个对相邻磁盘扇区操作的请求,那么新请求和已存在请求合并;
- 若队列中存在驻留时间过长的请求,那么将新请求插入到队列尾部,以防止饥饿请求;
- 若队列中以扇区方向为序存在合适插入的位置,那么新请求被插入到该位置,保证队列中请求以被访问磁盘物理位置为顺序排列;
- 若队列中不存在合适的插入位置,请求将被插入队列尾部。
3.2 最终期限(deadline)I/O调度程序
目的:减少请求饥饿现象,尤其是读请求饥饿。减少请求饥饿必须以降低全局吞吐量为代价。(写请求是异步的而读请求是同步的,读请求对性能的影响更大)
当一个新请求加入到队列中时:
- 类似Linus电梯算法,维护一个以磁盘扇区为序的排序队列
- 维护一个读请求FIFO队列,默认情况下,读请求的超时时间是500ms
- 维护一个写请求FIFO队列,默认情况下,写请求的超时时间是5s(时间比读请求长很多,防止写请求堵塞读请求)
- 若写FIFO队列或读FIFO队列头的请求超时,那么便提取请求进行服务
最后期限I/O调度程序的实现在文件block/deadline-iosched.c中,其对数据库环境来说是最好的选择。
3.3 预测(Anticipatory)I/O调度程序
场景:系统写操作频繁,每次提交读请求,I/O程序都会迅速处理读请求,完成后进行写操作(也即写请求FIFO和读请求FIFO轮替)。损害了系统全局吞吐量。
目的:保持良好度相应的同时也能提供良好的全局吞吐量
方法:在最后期限I/O调度程序基础上,增加 预测启发能力。
- 如果两个I/O请求之间的I/O请求在等待期到来,那么I/O调度程序可节省两次寻址操作,片刻的等待可能会避免大量的寻址操作。当然,如果没有I/O请求在等待期到来,那么预测I/O调度程序会给系统性能带来轻微的损失,浪费掉几ms。
- 预测I/O调度程序跟踪并统计每个应用程序块I/O操作的习惯行为,以便正确预测应用程序的未来行为。如果预测准确率足够高,那么预测调度程序便可大大减少读请求所需的寻址开销。
预测I/O调度程序的实现在block/as-iosched.c中,它对大多数工作负荷执行良好。但在某些非常见又有严格工作负荷的服务器上,执行效果不好。(AS对数据库环境表现很差)
3.4 完全公平的队列(Complete Fair Queuing, CFQ)I/O调度程序
目的:为专有工作负荷设计
算法:
- 把I/O请求放入特定队列中,队列根据引起I/O请求的进程组织。例如,来自foo进程的I/O请求进入foo队列,来自bar进程的I/O请求进入bar队列。
- 在每个队列中,刚进入的请求与相邻请求合并和插入操作。
- CFQ以时间片轮转调度队列,从每个队列中选取请求数(默认为4)的请求,执行完后进行下一轮调度。提供进程级的公平。
完全公平的队列I/O调度程序位于block/cfq-iosched.c。主要推荐给 桌面工作负荷 使用,但若无异常情况,几乎可以满足所有的工作负荷。
3.5 空操作的I/O调度程序
场景:用于真正随机访问的块设备,没有“寻道”负担
算法:只进行合并操作,不进行排序操作,维护一个FIFO请求队列
空操作I/O调度程序位于block/noop-iosched.c,专为 随机访问设备 而设计
3.6 I/O调度程序的选择
缺省:完全公平的I/O调度程序(CFQ)
启动时,可通过命令行选项elevator=value来覆盖缺省。
I/O调度程序 | 预测 | 完全公平的队列 | 最终期限 | 空操作 |
---|---|---|---|---|
其对应的elevator参数值 | as | cfq | deadline | noop |