Chapter 14 - 块 I/O 层
Created by : Mr Dk.
2019 / 10 / 30 20:25
Nanjing, Jiangsu, China
系统中能以 随机 顺序访问固定大小的数据块 (chunk) 的硬件称为块设备,都是以安装文件系统的方式使用的。字符设备按照字符流的方式被有序访问。内核管理块设备要比管理字符设备细致,因为对于字符设备仅需要维护一个 当前位置;块设备访问的位置需要在介质的不同区间前后移动,且对执行性能的要求很高。
14.1 剖析一个块设备
块设备中最小的可寻址单元是 扇区,是所有块设备的基本单元。块设备无法对比它还小的单元进行寻址和操作。块 是文件系统的一种抽象,只能基于块来访问文件系统。内核执行的磁盘操作都是按照块进行的。块不能比扇区还小,只能数倍于扇区大小:
- 块大小必须是 2 的整数倍
- 不能超过一个页的长度
- 通常为 512B、1KB、4KB
14.2 缓冲区和缓冲区头
当一个块被调入内存(读入或等待写出),要存储在一个缓冲区中。每个缓冲区与一个块对应,相当于磁盘块在内存中的表示。每个缓冲区都有一个对应的描述符,保存内核处理该缓冲区时的相关控制信息——buffer_head
。
struct buffer_head {
unsigned long b_state; // 缓冲区状态标志
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;
struct list_head b_assoc_buffers; // 相关的映射链表
sturct address_space *b_assoc_map; // 相关的地址空间
atomic_t b_count; // 缓冲区使用计数
};
在操作缓冲区头之前,应该先使用 get_bh()
增加缓冲区头的引用计数,确保不会再被分配出去。完成对缓冲区头的操作之后,使用 put_bh()
函数减少引用计数:
static inline void get_bh(struct buffer_head *bh)
{
atomic_inc(&bh->b_count);
}
static inline void put_bh(struct buffer_head *bh)
{
atomic_dec(&bh->b_count);
}
缓冲区头 的目的在于描述磁盘块和物理内存缓冲区之间的映射关系。这是一个很大且不易控制的结构体,对其进行操作,既不方便又不清晰。仅能描述单个缓冲区,迫使 I/O 操作被分解为多个 buffer_head
,造成了不必要的负担和空间浪费。需要为块 I/O 操作引入新型、灵活的轻量级容器。
14.3 bio 结构体
该结构体代表了正在活动的,以链表形式组织的块 I/O 操作,一个片段是一小块连续的内存缓冲区。不需要保证单个缓冲区一定要连续。
14.3.1 I/O 向量
在 bio
结构体中,bi_io_vec
指向一个 bio_vec
结构体数组,每个 bio_vec
都是一个 <page, offset, len>
形式的向量:
struct bio_vec {
struct page *bv_page;
unsigned int bv_len;
unsigned int bv_offset;
};
描述的是:
- 片段所在的物理页
- 块在物理页中的偏移
- 从偏移量开始的块长度
整个 bio_io_vec
表示了一个完整的缓冲区,每个块 I/O 请求都通过一个 bio 结构体表示,每个请求包含一个或多个块,组织在 bio 结构体的 bio_vec
结构体数组中。
14.3.2 新老方法对比
bio 结构体是轻量级的,描述的块可以不需要连续存储区,并且不需要分割 I/O 操作。
14.4 请求队列
挂起的块 I/O 请求保存在请求队列中。请求队列只要不为空,对应的块设备驱动程序就会从队列头获取请求,并将其送入对应的块设备。请求由 request 结构体表示,每个请求可以由多个 bio 结构体组成 (操作多个磁盘块)。在磁盘上这些块是连续的,在内存中这些块不一定连续。
14.5 I/O 调度程序
以内核产生请求的次序直接发向块设备,性能肯定让人难以接受。为优化寻址操作,内核既不会简单按请求次序处理,也不会立即提交请求给磁盘。在提交前,先进行 合并 和 排序 的预操作,极大提升了系统性能。在内核中负责提交 I/O 请求的子系统称为 I/O 调度程序。I/O 调度程序负责将磁盘 I/O 资源分配给系统中所有挂起的 I/O 请求。
14.5.1 I/O 调度程序的工作
决定队列中的请求排列顺序,以及什么时候派发请求到块设备。通过两种方法减少磁盘寻址时间:
- 合并
- 访问的扇区和已有请求要访问的扇区相邻
- 将两个或多个请求结合成一个新请求
- 将多次请求的开销压缩成一次请求的开销
- 排序
- 使所有请求按硬盘上扇区的排列顺序有序排列
- 保持磁头以直线移动,缩短了所有请求的磁盘寻址时间
14.5.2 Linus 电梯
最简单的 I/O 调度程序。当有新请求加入队列时:
- 首先检查每个挂起的请求是否可以和新请求合并
- 支持 向前合并 和 向后合并
- 一般来说向后合并比较多
- 合并尝试失败,则寻找可能的插入点并插入
- 如果发现队列中有驻留时间过长的请求
- 新请求就加入到队列尾部
- 避免访问相近磁盘位置的请求太多,其它位置产生饥饿
14.5.3 最终期限 I/O 调度程序
解决 Linus 电梯所带来的饥饿问题。Writes-starving-reads 问题:
- 写操作是在内核有空时才提交请求给磁盘的,和提交它的应用程序异步执行
- 读操作会阻塞应用程序直到请求被满足,同步执行
写响应时间不会给系统响应速度造成较大影响,读响应时间对系统响应时间来说非同小可,且读操作彼此之间往往互相依靠。如果每次请求都发生饥饿,累计的延迟会造成过长的等待时间。减少请求饥饿必须以降低全局吞吐量为代价。
在该调度程序中
- 每个请求有一个超时时间
- 读超时为 500ms
- 写超时为 5s
- 排序队列也以磁盘物理位置为次序维护请求队列
- 根据请求类型,同时分别插入额外的读、写 FIFO 队列中
- 对于普通操作,将请求从排序队列头部取下,推入派发队列
- 若读、写 FIFO 队列头部超时,就从 FIFO 队列头部取下
保证不会发生有请求在明显超时的情况下,仍得不到服务,但是并不能严格保证请求时间。读写超时时间的差异也更加照顾了读操作。
14.5.4 预测 I/O 调度程序
最终期限 I/O 调度降低了系统的吞吐量。在一个写请求很多的情况下,读操作每次都能被立刻响应,磁头的两次移动损害了全局吞吐量。预测 I/O 调度的基础仍然是最后期限 I/O 调度:三个队列 + 派发队列,为每个请求设置了超时时间。最主要的改进:增加了预测启发能力 (anticipation-heuristic):试图减少处理新的读请求所带来的寻址数量。读请求依旧会在超时前得到处理:
- 请求提交后,不立即返回处理其它请求,而是有意空闲片刻
- 在这期间,任何相邻磁盘位置的请求都会立刻得到处理
- 如果相邻的 I/O 请求在等待期到来
- 那么可以节省两次寻址操作
- 如果越来越多的相邻请求到来,可以避免大量寻址操作
如果没有 I/O 在等待期到来,会给系统性能带来轻微损失。预测 I/O 调度程序跟踪并统计每个应用程序的块 I/O 操作习惯行为。
14.5.5 完全公正的排队 I/O 调度程序
Complete Fair Queueing, CFQ 是为专有工作负荷设计的。CFQ I/O 调度程序把进入的 I/O 请求放入特定的队列中,该队列由引起 I/O 请求的进程组织,对刚进入的请求进行合并和插入,与其它调度程序类似。CFQ I/O 调度程序以时间片轮转调度队列,从每个队列选取请求处理,然后进行下一轮调度,在进程级提供了公平。
预定的工作负荷:多媒体。使音频播放器总能及时从磁盘填满它的音频缓冲区。
14.5.6 空操作的 I/O 调度程序
该调度程序不进行排序,只进行合并,维护请求队列以近乎 FIFO 的顺序排列。主要用于块设备。块设备只有一点或没有寻道的负担,那么没有必要对请求进行插入排序。专门为随机访问设备设计。
14.5.7 I/O 调度程序的选择
每种调度程序都可以被启用,并内置在内核中。缺省情况下,块设备使用完全公平的 I/O 调度程序。