早期的 Block 框架是单队列(single-queue)架构,适用于“硬件单队列”的存储设备(比如机械磁盘),随着存储器件技术的发展,支持“硬件多队列”的存储器件越来越常见(比如 NVMe SSD),传统的单队列架构也因此被改成了多队列(multi-queue)架构。早在 3.13 内核就已经加入了多队列代码,但是还不太稳定,经过多年的发展 multi-queue 越来越稳定,linux 5.0+ 已经默认使用 multi-queue。本篇文章介绍 Block 层框架及调度器相关知识,让读者对 Block 层有一个宏观的认识。
用户发起读写操作时,并不是直接操作存储设备,而是需要经过较长的 IO 栈才能完成数据的读写。读写操作大体上需依次经过虚拟文件系统 vfs、磁盘文件系统、block 层、设备驱动层,最后到达存储器件,器件处理完成后发送中断通知驱动程序,流程见图 1。
图1 IO 栈
备注:page cache 机制用来提高性能。在内存资源不紧张的情况下,用户访问过的数据不会被丢弃,而是缓存在内存中,下次可以访问快速的内存中数据,无需访问慢速的存储设备。mapper layer 用来将用户操作文件偏移量转换成磁盘文件系统的 block 偏移量。
Block 层连接着文件系统层和设备驱动层,从 submit_bio 开始,bio 就进入了 block 层,这些 bio 被 Block 层抽象成 request 管理,在适当的时候这些 request 离开 Block 层进入设备驱动层。IO 请求完成后,Block 层的软中断负责处理 IO 完成后的工作。Block 层主要负责:
管理 IO 请求
IO 请求暂存、合并,以及决定以何种顺序处理IO请求。这里面涉及到 single-queue、multi-queue 框架以及具体的 IO 调度器。
IO 统计
主要是task io accounting统计各个进程的读写情况,统计信息见struct task_io_accounting。
注意,虽然 Block 层中存放着很多的 request,但正常情况下 Block 层不会主动“下发” request 给设备驱动程序(在线切换 IO 调度器、存储器件 offline 场景会主动下发 request)。当设备空闲时,设备驱动程序从 Block 层的“分发队列”头部依次取 request 进行处理,设备驱动程序拿到 request 后,根据 request 中的信息及器件协议生成 cmd 命令交由器件处理。
Block 层软件设计与存储器件的特性紧密相关,大致经历了 2 个阶段
图2 single-queue与multi-queue 架构
*引用自Linux Block IO: Introducing Multi-queue SSD Access on Multi-core Systems
single-queue 框架
早期的存储设备是磁盘,特点是机械运动寻址、且不支持多硬件队列并发处理 io,所以代码逻辑自然地设计了一个软件分发队列,这种软件逻辑上只有一个分发队列的架构称作 single-queue 架构,由于软件本身的开销(多核访问 request queue 需要获取 request_queue->queue_lock 等原因),single-queue 的 IOPS 能达到百万到千万级别的数据量,由于早期存储器件速度慢,百万的 IOPS 已经完全能够满足需求。
multi-queue 架构
当支持多队列的高速存储器件出现后,器件端处理的时间变短,single-queue 引入的软件开销变得突出,软件成为性能瓶颈,导致性能瓶颈的因素有 3 个:
1) 所有 cpu 共享一个 request queue,对 request_queue->queue_lock 的竞争比较多。
2) 大多数情况下,完成一次 io 需要两次中断,一个是硬件中断,一个是 IPI 核间中断用于触发其他 cpu 上的软中断。
3) 如果提交 io 请求的 cpu 不是接收到硬件中断的 cpu,还存在远端内存访问的问题。
Jens Axboe(block maintainer) 针对 single-queue 存在的问题,提出了 multi-queue 架构,这种架构为每个 cpu 分配一个软件队列(称为 soft context dispatch q),又根据存储器件的硬件队列(hardware q)数量分配了相同数量的硬件上下文分发队列(hard context dispatch q,这是软件逻辑上的队列),通过固定的映射关系,将 1 个或多个 soft context dispatch q 映射到 1 个 hard context dispatch q,再将 hard context dispatch q 与存储器件的 hardware q 一一对应起来,达到并发处理的效果,提升 IO 性能。
相关的数据结构可以分成两大类,一是 IO 请求本身,二是管理 IO 请求用到的队列,理解这些数据结构是了解 block 层设计逻辑的基础,数据结构描述如下。
按照 IO 请求的生命周期,IO 请求被抽象成了 bio、request(简称 rq)、cmd,见图 3。访问存储器件上相邻区域的 bio、request 可能会被合并,称为 bio merge、request merge。若 bio 的长度超过软件或者硬件的限制,bio 会被拆分成多个,称为 bio split。Block 层接收到一个 bio 后,这个 bio 将生成一个新的 request,或者合并到已有的 request 中。图 3 中 bio2 被拆分,bio3、bio4 合并到一个 request 中。
request是IO调度的最小单位,多个bio访问存储器件上相邻的区域数据并且是同种类型的(读/写),则会被合并到一个request中,所以一个request可能包含多个bio。系统中request数量是有限制的,不是无限多的,否则当io请求太多,而存储器件来不及处理时,就会出现struct request结构体占用太多内存的情况。
在single-queue中用rq资源池管理request的申请释放,一个存储器件的读写请求数量各自限制最多q->nr_requests个(默认的q->nr_requests = BLKDEV_MAX_RQ = 128)。当该器件上待处理的读或写请求数超过=7/8 * q->nr_requests时进入拥塞状态,此时会限制新生成request的速度。比如读请求拥塞时,page_cache_async_readahead函数中关闭预读功能以较少新生成的request。当存储器件待处理读或写的request比低时退出拥塞状态。以默认q->nr_requests = 128个请求为例:
图5 single-queue request 数量管理
在multi-queue中,没有上述的congestion state逻辑,因为multi-queue用于支持多硬件队列存储器件的场景(当然也可以用于单硬件队列的存储器件),这些存储器件的速度很快,不需要过多考虑器件处理慢的问题(但是也不能一味地任由rqeuest增长)。在multi-queue中request最大数量与调度器的tag数量有关,同single-queue一样,默认值也是q->nr_requests = BLKDEV_MAX_RQ = 128,当存储器件待处理的读/写超过调度器tag数量时,申请rq的task睡眠,当有rq处理完成被释放后,再唤醒当前的task。默认的调度器tag数量可以通过sysfs接口修改,内核里通过blk_mq_update_nr_requests更新。
cmd是设备驱动处理的IO请求,设备驱动程序根据器件协议,将request转换成cmd,然后发送给器件处理。cmd已经不属于block层管理了,所以这篇文章不做描述。
2.IO队列
上面的IO请求需要经过多级缓冲队列管理,见图6。
图6 IO队列
注:multi-queue如果器件支持hardware multi q,plug list功能关闭
所有的bio都由submit_bio提交到block层,bio依次经过下面队列:
进程私有的plug list
队列中存放的是io请求(rq),引入这个缓冲队列的目的是为了性能。进程提交一个bio后,短时间类很可能还会有新的bio,这些bio被暂存在plug list中,因为这个队列只有本进程能操作,所以不用加锁就可以进行bio merge操作(在后面提到的调度器队列中做merge需要加锁)。
调度器队列elevator q
队列中存放的是io请求(rq)。single-queue的调度器有noop、cfq;multi-queue的调度器有mq-deadline、bfq、kyber。每个调度器有都实现了专门的数据结构管理rq(链表、红黑树等),这里统以elevator q称呼。
系统中的调度器队列可能有很多,比如cfq为每个进程维护各自的同步请求队列,又为所有进程维护了公用的异步请求队列。调度器需要决定先处理哪个队列以及队列中的哪个rq。一般情况下,调度器不会主动将rq移到设备分发队列中,而是由设备驱动程序主动来取rq。
设备分发队列device dispatch q(也可以称作hardware dispatch q)
这是软件实现的队列。存储器件空闲时,其设备驱动程序主动从调度器中拉取一个rq存在设备分发队列中,分发队列中的rq按照先进先出顺序被封装成cmd下发给器件。
对于multi-queue,设备分发队列包中还额外包含per-core软件队列,它是为硬件分发队列服务的,可以把它理解成设备分发队列中的一部分。
硬件队列HW q
队列中存放的是按器件协议封装的cmd,一些器件是单HW队列,比如UFS内部是一个队列深度为32的HW q,NVMe SSD最大支持的队列数量为64K、队列深度64K。
single-queue用到的调度器有noop,deadline,cfq。
multi-queue用到的调度器有none(类似于noop),mq-deadline(类似于deadline),bfq(类似于cfq),kyber。这里选取几个具有代表意义的调度器对比分析。
noop调度器
最简单的调度器,IO请求放入一个FIFO队列,逐个执行这些IO请求(rq)。
noop调度器基本上对rq不做额外,仅仅在将rq插入到调度器队列时,将rq与已有的rq做前向、后向合并(2个rq的sector连续)。从调度器队列中发送一个rq给设备驱动程序代码如下:
cfq调度器(Completely Fair Queuing)
CFQ公平对待每个进程,给每个进程分配相同的“虚拟”时间片,在时间片内进程可以访问存储器件,时间片用完后,选择下一个进程运行。
1)cfq支持“优先级”策略
“虚拟”时间片 = 实际访问存储设备时间*优先级系数,优先级越高实际获得的时间片越长,优先级越低实际获得的时间片越短。
cfqq用完时间片后,通过cfq_resor_rr_list调整cfqq在红黑树中的位置,由于红黑树key值rb_key近似等于jiffies,所以cfqq近似于按照round-roubin执行,代码如下。
图7 cfqq round-roubin策略
2)cfq支持“权重策略”
多个cfqq可以归属于一个group,这些group按照占用存储设备的时长(cfq中称作vdisktime)组织在红黑树中。当group被调度运行结束后,cfq_group_served更新group 的vdisktime(vdisktime增长量=实际占用disk时间*权重系数),权重越高vdisktime增长的越慢,权重越低vdisktime增长的越快。
cfq优先选择vdisktime小的group执行,所以权重越大,group被调度的越频繁。
cfq_group_served更新vdisktime的代码如下:
vfr表示在group与其他group构成的父group中,group在父group中的权重占比。
cfqg_scale_charge将group使用存储设备的时长(以charge表示)做个虚拟转换,本质上等价于cfqg->vdisktime = charge / vfr
bfq调度器(Budget Fair Queuing)
bfq从cfq演变过来,大部分代码也借鉴cfq的。cfq在各个进程间平分存储器件时间来达到公平,这样做是有问题的,一个随机访问存储设备的进程与一个顺序访问存储设备的进程,虽然占用存储器件的时间是一样的,但二者访问的数据量有很大差距,难以保证公平。
bfq通过budget(就是block sector)确保公平,不管进程占用了存储设备多长时间,只管进程访问存储设备的数据量。
bfq的这种思想带来了一个优势,即提高了交互式进程的响应性。因为交互式进程每次IO的数据量很少,而batch类进程数据量很大,为了确保budget公平(访问相同数量的block sector),必须频繁调度交互式进程运行,从而提高了交互式进程的响应性。
图8 bfqq红黑树
bfq同cfq一样,也为每个进程分配一个队列称作bfqq,通过时间戳维护在红黑树中(cfq是通过vdisktime维护在红黑树中)。与cfqq轮询机制不同,bfqq红黑树中只有eligible状态的bfqq才会被选择调度。我们定义如下变量:
与cfqq权重的作用不一样,cfqq中权重越大时间片越长,但bfqq中权重与时间片无关,与调度频率有关,假设有3个进程P1~P3,每次访问100个budget,权重比例P1:P2:P3 = 2:1:1,bfqq执行效果如下(绿色表示eligible,灰色表示不可选):
表2 bffq调度
从上面执行步骤可以看出,P权重占比50%,第一次到第三次下来,P1执行2次,P2、P3累计执行2次,执行的频率与各进程的权重占比相等
以上3个调度器的差异对比如下:
Block主要涉及框架和调度器两部分,都是为了吞吐量合IO响应性设计的。框架代码与存储器件紧密相关,从慢速的存储设备到高速的存储设备,Block框架变成了multi-queue架构,软件、硬件的紧密结合才能把存储器件性能发挥到最大,期待未来新存储器件的出现,将存储性能再提高一个级别。调度器也越来越智能,能够兼顾交互进程的响应性和batch类进程的吞吐量,用户体验在Block新框架、新调度器的支持下将会越来越好。
[1] Linux Block IO: Introducing Multi-queue SSD Access on Multi-core Systems
[2] https://kernelnewbies.org/Linux_5.0#Block_layer