块层介绍 第二篇: request层

原文连接:https://lwn.net/Articles/738449
做者: Neil Brown
注意:方括弧内的文字是笔者添加的node

Linux 块层向上为文件系统和块设备提交接口,使得上层可以以统一的方式访问各类类型的后端存储设备。同时,它也向下为设备驱动提供接口,让驱动层可以以一致的方式来接受请求。一些驱动如上一篇文章中提到的DRBD和RBD设备,只使用bio层提供的一些接口,对bio请求进行处理。其它的驱动则能够从IO请求plugging机制,各类请求排序和请求合并中受益。为了给驱动层提供服务,块层作了很多事情,我且称之为"request层"。ios

如今,"request层"并存着两种模型:单队列(single-queue) 和 多队列(multi-queue)。多队列的出现也就是近几年的事情,也许总有一天会彻底取代单队列的,可是目前来看二者在内核的使用都至关活跃。有了这两种不一样的排队方法(queuing approach)作参考,咱们就能够两相对比来学习学习,因此咱们将花点时间都看看,分析他们如何把请求呈现给驱动层的。首先,咱们来看看二者的共同之处,分析这两个关键的结构体是个不错的入手点: struct request_queue 和 struct request。算法

请求队列和请求

struct request_queue之于struct request的关系,很是像struct gendisk之于struct bio的关系:一个表明具体的设备,一个表明IO请求。须要注意的是,每一个gendisk都有一个关联的request_queue结构体,可是只有那些使用了"request层"的设备才会分配request结构体。按理说,适用于全部块设备的域,好比struct queue_limits,都应该放到gendisk结构体里面,而对于一些仅适用于"request层"队列管理的域,其实只应当分配给那些使用了"request层"的设备。如今来看,这些结构体里有些域的安排可能就是历史巧合,也不值得去纠正了。各类队列相关的域,均可以在/sys/block/*/queue/目录下查看。后端

request结构体表明单个IO请求,最终要传递到底层设备。一个request包含一个或多个表明连续IO请求的bio,一些跟踪整体状态的信息(好比时间戳,哪一个CPU发来的请求),和一些用来链入更大数据结构的“锚点”,好比用struct list_head queuelist链入一个简单的队列结构,用struct hlist_node hash链入一个哈希表(用来查找与新bio相邻的请求),用struct rb_node rb_node来把请求放在一棵红黑树上。在分配一个request结构体的时候,有时候要分配一些额外的空间来给底层驱动保存一些额外的信息。有时候这些空间用来保存命令头部,以便发送给底层设备,好比 SCSI command descriptor block,驱动能够自由地决定如何使用这块空间。缓存

相应的make_request_fn()函数 (单队列是blk_queue_bio,多队列是blk_mq_make_request())为IO请求建立一个request结构体,而后把交给IO调度器, 也叫电梯算法"elevator", 这个名字来自电梯算法( elevator algorithm),电梯算法曾是磁盘IO调度一个里程碑式的成就。咱们将快速看一下单队列的实现,而后再对比地去看多队列。数据结构

单队列上的请求调度

过去,大多存储设备都是机械硬盘,要经过磁头寻道,盘片旋转来定位数据。机械盘一次只能处理一个请求,从盘片上一个位置挪动到下一个位置代价很大。单队列的实现就是为这种类型的设备而生的,而后渐渐地也能支持其它快速设备,然而单队列总体结构依然反映了旋转类型存储设备的需求。app

单队列调度器主要有三个任务:异步

  • 积聚表明连续IO操做的多个bio,合并成更大的IO请求,充分发挥硬件性能,可是请求不能太大超高硬件设备的限制;
  • 把请求进行排序来减小寻道时间,可是又要保证重要的请求获得及时处理。不断寻求较优方案,来解决这个问题,是这一部分代码复杂度的根源。通常很难知道这两点:一个请求有多重要,和一个请求须要多少寻道时间,咱们只能依赖启发式方法来判断怎样排序比较好,然而启发式方法毫不是完美的;
  • 把通过整理的请求列表交给底层驱动,让驱动从队列取下请求去处理,同时提供一个通知机制来告诉上层请求处理的结果。

最后一个任务很直接了当。驱动会经过blk_init_queue_node()来注册一个request_fn()策略例程,只要队列上有新请求已经准备好,策略例程被调用去处理这个请求。驱动负责用blk_peek_request()来从队列上取下请求,而后进行处理。当请求处理完毕 [有些设备能够并行处理多个请求],驱动会从队列上摘下另外一个请求来处理,而不用再去调用request_fn() [由于request_fn()一次拿到了整个列表上的全部request]。请求一旦处理完毕,就可调用blk_finish_request()来通知上层。函数

第一个任务有部分是用elv_attempt_insert_merge()完成的,它会快速地检查队列,看是否能够找到一个已经存在的request,把新的bio合并进入。若是成功,调度器就会容许合并,若是失败,调度器还有一次机会尝试在调度器内部进行一次合并。若是没有机会合并,就分配一个新的请求,而后交给调度器。稍后一会,若是某个请求因合并而长大,使得它与该请求变成了连续的,调度器就能够再次尝试合并操做。oop

第二个任务多是最复杂的。如何把请求按照"适当"顺序排队很是依赖咱们如何来解释"适当"的含义。这三种不一样的单队列调度器: noop, deadline和cfq,对“适当”的解释就很是不同。

  • "noop"会对请求作一些简单的排序,不容许把读请求挪到写请求以前,反之亦然;依据电梯算法,一个同类型的请求能够插入到另外一个以前。除了elv_dispatch_sort()所作简单排序,"noop"就是一个FIFO队列。

  • “deadline”会把提交时间相近的请求放在一批。在同一批中,请求会被排序。当一批请求达到了大小上限或着定时器超时,这批请求就会提交到设备队列上去。这个算法尝试给每个请求都设置一个延迟时间上限,同时尽可能汇集比较大的一批请求。

  • "cfq"即"Complete Fairness Queuing",相比其它几个调度器要复杂不少,目的是在不一样进程或进程组间保证IO资源使用的公平性。cfq调度器内部维护了多个队列,每个进程都有一个队列来保存来自该进程的同步请求(一般是读),而对于异步请求(一般是写),每个优先级都有一个队列,全部请求不论来自哪一个进程都按照优先级放到相应的队列上。在提交请求时,按照优先级每一个队列都有机会获得调度。每一个队列都有必定的时间片,在时间片内才能提交必定数量的请求。当一个同步队列中的请求不足必定数量时,这个设备能够空闲一会,即便其它队列里可能有请求等待处理。一般,同步请求之间在磁盘上的物理位置是连续的,因此让磁盘稍等一会来接收更多连续的请求,这样作能够提升吞吐量。以上对CFQ的描述仅仅是点皮毛。内核文档(Documentation/block/cfq-iosched.txt)讲的更详细点,还列出了全部参数,经过调整这些参数可以适应各类不一样的场景。

以前提到过,一些高端点的设备能够一次处理多个请求,即在一个请求尚未处理完成以前,就可以处理新的请求。一般,这个要用到"tagging"功能,给每个请求加一个标签,这样请求完成通知就能和原来的请求正确的对应起来。单队列的"request层"能够对任意深度的设备提供"tagging"功能。

一个设备内部能够经过真正地并行处理请求,来支持被标记的命令,好比经过访问一个内存缓存,经过设计多模块而每一个模块都能处理一个请求,或者经过其内部队列,这样的队列比"request层"更加了解设备。

多队列和多CPU

多队列的另外一个动机就是减小锁的开销,由于咱们的系统处理器愈来愈多,而请求从多个处理器放到一个队列中时须要加锁,锁的开销变得愈来愈大。"plugging"机制能帮得上一些忙,可是不够理想。若是咱们可以分配更多队列:每一个NUMA节点一个队列,或者一个CPU一个队列,那么把请求放到队列的锁开销就会减小不少。若是硬件支持并行处理多个请求,那么这样作的优点就更大了。若是硬件只支持一次提交一个请求,那么多个per-CPU队列仍然须要合并。若是他们比"plugging"机制批处理的效果更好,那么这样作也是有益处的。假如不能提升批处理的效果,写程序的时候当心点应该也可以保证,至少不会有什么损失。

以前说过,cfq调度器内部已经有多个队列,可是它们跟multi-queue的目的彻底不同,它们把请求与进程和优先级关联起来,而multi-queue的队列是跟硬件密切相关的。multi-queue "request层"维护着两组硬件相关的队列:软件的"staging"队列和硬件的"dispatch"队列。

软件staging队列(struct blk_mq_ctx)是依CPU硬件状况而分配的,每一个CPU分配一个,或每一个NUMA节点分配一个。当块层的"plugging"机制拔开"塞子"时(blk_mq_flush_plug_list()),request请求在一个spinlock的保护下被添加到队列上,锁竞争应该不多。multi-queue的队列能够选择由某一个multi-queue调度器来管理, 如今有三种multi-queue调度器:bfq, kyber和mq-deadline.

硬件dispatch队列是基于目标硬件块设备进行分配的,因此有可能只有一个,也有可能多达2048个队列 (或与硬件支持的中断源个数同样)。"request层"为每个硬件队列(或"硬件上下文")分配一个数据结构struct blk_mq_hw_ctx,维护着一个CPU和队列之间的映射表,而队列自己就是为底层驱动而服务的。“request 层”时不时地把硬件队列中的请求传递给底层驱动。接下来,请求就全由驱动处理了,一般状况下,又会很快按照接收的顺序交给硬件。

与single-queue相比有另外一个重要区别,multi-queue使用的request结构体都是预分配的。每一个request结构体都关联着一个不一样tag number,这个tag number会随着请求传递到硬件,再随着请求完成通知传递回来。早点为一个请求分配tag number,在时机到来的时候,request 层可随时向底层发送请求。

single-queue只须要一个request_fn()就能够了,可是multi-queue须要底层驱动提供一个struct blk_mq_ops结构体,包含了多达11个函数。其中,最核心的一个函数是queue_rq(),其它的函数实现了超时,请求完成轮询,请求初始化,等相似操做。

一旦请求就绪,而且调度器不想再把请求保持在队列上来排序或扩展,调度器就会调用queue_rq()函数。single-queue把收集请求的责任交给了驱动层,与之不一样,multi-queue却把这个责任交给了"request层"。queue_rq()有个参数表明的是硬件上下文,一般会把请求放在内部FIFO队列上,也有可能会直接处理。queue_rq()能够拒绝一个请求,而后返回BLK_STS_RESOURCE,让请求继续在staging队列上等待。除了BLK_STS_RESOURCE和BLK_STS_OK,其它返回值都被视为IO错误。

多队列调度

multi-queue不是必需要配置一个调度器,若是不指定的话,那么就会用相似单队列中的“noop”调度器。连续的bio会被合并到一个请求,而不连续的bio各成为一个独立的请求。这些请求以FIFO的顺序被放到staging队列中,尽管有多个submission队列,默认的调度器会尝试直接提交新请求,仅仅在收到BLK_STS_RESOURCE返回值时才会使用staging队列。当块设备上的plugging机制拔开“塞子”时,调度器会调用blk_mq_run_hw_queue()或blk_mq_delay_run_hw_queue()把软件队列传递给驱动层处理。

可插拔的multi-queue调度器有22个入口点,其中有两个函数,一个是insert_requests()把一组请求添加到软件staging队列中,另外一个是dispatch_request()会选择一个请求而后传递给硬件设备。若是没有实现insert_request()函数,请求就会被简单的插入到列表末尾。若是没有提供dispatch_request()函数,就会从staging队列里取下请求,而后以任意顺序传递到相应的硬件队列。This "collect everything" step is the main point of cross-CPU locking and can hurt performance, so it is best if a device with a single hardware queue accepts all the requests it is given.[最后一句我理解不到那个点,大体理解是说staging队列有多个,硬件队列只有一个的状况,那么多个软件队列上的请求最终要收集到这个硬件队列上来,那么必然要加锁,会比较影响性能]

mq-deadline调度器跟单队列的deadline调度器发挥的功能很类似。它有个insert_request()函数,不会使用多个staging队列,而是把请求放到两个全局的基于时间的队列中 - 一个放读请求,一个放写请求,先尝试把该新请求与已经存在的请求合并,若是合并不了,则把这个新请求放到队列尾部。dispatch_request()函数会从这些队列中返回第一个请求:基于时间的队列,基于请求批大小,以及避免写饥饿的队列。

bfq调度器,即Budget Fair Queueing, 必定程度上是借鉴cfq实现的。内核里有介绍bfq的文档(Documentation/block/bfq-iosched.txt),几年前lwn上有篇讲bfq的文章(https://lwn.net/Articles/601799/),后来又出了一篇文章(https://lwn.net/Articles/709202/), 跟进了bfq被吸纳进multi-queue的状况。bfq有一点比较像mq-deadline,没有使用多个per-CPU staging队列。bfq有多个队列,每个队列由一把自旋锁保护。

与mq-deadline和bfq不同,kyber IO调度器,在这篇文章中(https://lwn.net/Articles/720675/)有简单介绍,它使用了per-CPU的staging队列,也没有实现本身的insert_request()函数,而用的是默认行为。dispatch_request()函数为每个硬件上下文都维护着各类内部队列,若是这些队列是空的,它就会从给定的硬件上下文所对应的全部staging队列来收集请求,而后在内部将请求作个分布,若是硬件队列不是空的,它就直接从对应的内部队列里下发请求。关于kyber调度器的这几个方面内核没有相应的注释和文档:策略解释,请求分布,以及处理顺序。

单队列要寿终正寝了?

在块层中,并存两套不一样的队列系统,两套调度器和两套不一样的驱动接口很明显是不理想的。咱们能够期待single-queue的代码很快被移除掉吗?multi-queue到底又有多好呢?

不幸的是,这些问题的回答须要在不一样的硬件上作不少测试,而笔者只是个作软件的。从软件的角度来讲,很清楚,在支持多队列并行提交请求的硬件上,multi-queue应该能带来不少好处。当用在单队列硬件上时,multi-queue至少应该和单队列旗鼓至关,可是不要指望一晚上之间就能达到旗鼓至关的水平,由于任何新事物都不是完美的。

说到不完美,举个例子,最近一个补丁集进了Linux 4.15。这些补丁对mq-deadline调度器作了一些修改来解决redhat在内部存储系统测试中发现的性能问题。假如切换到multi-queue, 存储领域的各家厂商颇有可能在测试中发现相似的性能回退。在将来几个月里,这些被发现的问题颇有但愿获得修复。2017可能不会成为multi-queue-ony Linux的一年,可是这样的一天不会太远。

相关文章
相关标签/搜索