说明
在请求处理流程(1)中我们分析了每个IO请求是如何从文件系统发出并进入到块设备层,加入到块设备调度队列中,在这里我们将仔细阐述每个IO请求如何从块设备的请求队列被下发至更底层处理。
数据结构
与块设备层IO相关的主要数据结构有2:
BIO
struct bio {
sector_t bi_sector;
struct bio *bi_next; /* request queue link */
struct block_device *bi_bdev;
unsigned long bi_flags; /* status,command,etc */
unsigned long bi_rw;
unsigned short bi_vcnt; /* how many bio_vec's */
unsigned short bi_idx;
unsigned int bi_phys_segments;
......
// bio完成时的回调函数 bio_end_io_t *bi_end_io;
void *bi_private;
bio_destructor_t *bi_destructor;
struct bio_vec bi_inline_vecs[0];
};
REQUEST
struct request {
struct list_head queuelist;
struct call_single_data csd;
struct request_queue *q;
unsigned int cmd_flags;
enum rq_cmd_type_bits cmd_type;
unsigned long atomic_flags;
......
}
将linux io相关最重要的两个数据结构列在这里,不作过多分析,都很简单。以后需要再仔细分析吧。
蓄流和泄流
在前面我们分析的__make_request最后,当一切都准备妥当之后,每个bio请求要么被merge到现有的某个request中,要么被创建一个新的request添加到设备的request_queue中。
接下来,开始泄流。所谓的泄流,其实也就是将request_queue中的请求开始发往底层设备去处理。块设备层不会来一个请求就发往下层去处理,而是蓄流至某个时刻,发送一批至底层处理,这样可能会达到更好的batch效果,显而易见。
我们首先来看看块设备层泄流的时机。关于Linux“蓄流”和“泄流”的时机,Linux存储技术原理分析里面写的非常明确,以下摘自:
Linux块IO层为请求队列的“蓄流”和“泄流”分别提供了一系列的公共函数,如blk_plug_device和blk_unplug_device。
blk_plug_device函数为块设备,或更精确地说,是块设备的请求队列进行“蓄流”。它设置queue_flags域的QUEUE_FLAG_PLUGGED标志,然后启动运行在unplug_timer域中的“泄流”定时器。
在调用blk_plug_device函数“蓄流”块设备的请求队列时,更新泄流时间为当前时间后的某个时间,具体值取决于队列描述符的unplug_delay域的值,在blk_queue_make_request中被设置为3ms。
超时处理函数在blk_queue_make_request中被设置为blk_unplug_timeout。在这个函数中,最终调用了请求队列的unplug_fn方法,通常被实例化为generic_unplug_device函数,参见blk_init_queue_node函数。
generic_unplug_device负责泄流块设备:首先,它检查请求队列是否还处在活跃状态:然后,清除请求队列的蓄流标志,删除蓄流定时器,最后,执行request_fn开始处理队列中的下一个请求。
此外,如果队列中等待处理的请求书超过请求队列描述符的unplug_thresh的值(默认是4),则IO调度器也会开始为请求队列泄流。在elv_insert(被__evl_add_request 调用)函数中,如果出现这种情况,会调用__generic_unplug_device函数。
综上所述,泄流的时机有2:1. 定时器超时了,开始泄流,这是应对请求较少场景下request不会被饿死;
2. 请求队列中的请求数超过一定量(4),开始泄流,这是为了应对突发请求量较大的场景。
那什么时候开始蓄流呢?1. 第一次向该设备提交请求时要蓄流,因为此时该设备的request_queue是空的;
2. 如果泄流完成或者泄流过程中发现底层设备已经疲于应付(发送请求返回错误了),主动退出泄流模式,进入蓄流状态。这是非常合理的,因为底层设备有处理能力限制,而且上层是异步发送,我们不能不管底层设备的死活。
如果在泄流的时候上层又下发了bio,怎么办呢?等泄流完成吗?
底层泄流的过程其实是很快的,因为每个请求发下去并给它一个回调函数就可以了,无需等着它完成。而且在泄流过程中,在从request_queue中获取到一个request后,就会解除request_queue的lock,这也就意味着文件系统可以向该块设备层继续提交新请求。
接下来,让我们仔细研究下是如何实现的:
// 如果QUEUE_PLUGGED被设置,应该蓄流static inline bool queue_should_plug(struct request_queue *q){
return !(blk_queue_nonrot(q) &&
blk_queue_tagged(q));
}
static int __make_request(struct request_queue *q, struct bio *bio)
{
......
spin_lock_irq(q->queue_lock);
......
out:
// 如果是request_queue不应该蓄流了,此 // 时开始泄流,在此之前,已经lock了该 // queue if (unplug || ! queue_should_plug (q)) __generic_unplug_device(q);
spin_unlock_irq(q->queue_lock);
return 0;
}
/** remove the plug and let it rip..* Note: this is under q->lock*/
void __generic_unplug_device(struct request_queue *q)
{
if (unlikely(blk_queue_stopped(q)))
return;
// if queue is un-plugging, return if (!blk_remove_plug(q) &&
!blk_queue_nonrot(q))
return;
// init to scsi_request_fn(): q->request_fn(q);
}
scsi_request_fn()函数由于篇幅原因,就不贴了,分析该函数的代码,总结其处理流程:请注意:进入函数时已经保证request_queue已经被lock;
从request_queue中取得一个request(blk_peek_request &blk_start_request),并解除request_queue的lock,不要耽误了其他人提交bio。另外这里取出请求的时候其实已经为该请求初始化好了对应的scsi_cmd;
各种错误判断:底层设备是否准备好,没有的话,转步骤5,退出;
向底层设备发送scsi_cmd(scsi_dispatch_cmd);
判断3返回值,如果出错,蓄流(blk_plug_device),转步骤6;如果未出错转步骤1继续处理,继续之前先对request_queue加锁;
进入这里,表明设备尚未准备好,我们暂时还不能向其发请求,那我们只能蓄流(blk_plug_device),注意这里先得对request_queue加锁;
准备清理环境,退出,先解锁queue->lock,原因可见代码注释),清理完成再加锁,之所以这样是为了语义清晰:该函数进来的时候是加锁的,出去的时候也是加锁的,那么调用者用起来自然也就清爽了。
仔细想想,这里的逻辑也不算难理解,但是这里可能会有以下问题是:1. 加锁解锁的地方太多,很容易影响性能,虽然用的是自旋锁。且整个地方只有一把大锁,你懂的;
2. 如果在泄流的过程中上层(文件系统)源源不断地发送请求的话,可能达不到蓄流的效果,上层提交的过快,而泄流线程可能没那么快,导致的结果就是来一个request就泄掉,再来一个还是泄掉,前后request无法做合并和排序,影响性能。
改进
好,那既然上面说到的蓄流泄流算法存在种种的弊端,那我们如何改进?让我们对症下药,针对上面的症结提出应对之策:1. 化整为零:细化锁粒度;
2. 批量提交:上层文件系统提交的时候不要一次一个来,太费事儿,一次给我来一打吧。
看,怎么做其实是哲学问题,很简单。那我们看看在新版内核(3.10)中是如何实现这两点。
细化锁粒度
新版内核中细化了锁的粒度,除了request_queue全局有一把大锁以外,每个进程增加了一个plug队列,这样,在极大程度上可以实现真正的并行了:当IO请求提交时,首先插入该队列,在队列漫时,再flush到设备的请求队列request_queue中,这样可避免频繁对设备的请求队列操作导致的锁竞争,提升效率。
void blk_queue_bio(struct request_queue *q, struct bio *bio) {
......
/** Check if we can merge with the plugged list before* grabbing any locks.*/
// 尝试向进程的plug list插入bio请求,甚至合并 if (attempt_plug_merge(q, bio, &request_count))
return;
// 如果无法合并至进程的plug_list,只能乖乖插入request_queue了 ......
}
static bool attempt_plug_merge(struct request_queue *q, struct bio *bio, unsigned int *request_count){
struct blk_plug *plug;
struct request *rq;
bool ret = false;
// 找到该进程的plug队列 plug = current->plug;
if (!plug)
goto out;
*request_count = 0;
// 遍历队列的每个request,检查bio是否可以合并至该request // 可合并条件: // 1. bio和request属于同一个设备(queue一致) // 2. io请求连续 // 3. 合并后的request内IO请求大小未超过硬件限制 list_for_each_entry_reverse(rq, &plug->list, queuelist) {
int el_ret;
if (rq->q == q)
(*request_count)++;
// 如果bio和当前req无法合并,继续遍历下一个req if (rq->q != q || !blk_rq_merge_ok(rq, bio))
continue;
// 尝试merge el_ret = blk_try_merge(rq, bio);
if (el_ret == ELEVATOR_BACK_MERGE) {
ret = bio_attempt_back_merge(q, rq, bio);
if (ret)
break;
} else if (el_ret == ELEVATOR_FRONT_MERGE) {
ret = bio_attempt_front_merge(q, rq, bio);
if (ret)
break;
}
}
out:
return ret;
}
通过上面的分析,我们确实看到了,每次提交bio的时候,都会将请求提交至当前进程的plug_list中。
批量提交
上面我们看到了,提交bio的时候将请求放在本地,等攒够了再憋大招放下去。我们这里就看看是如何做批量提交的。
上面判断中如果bio无法合并至当前进程的plug_list中,且亦无法合并至request_queue中,那会为该bio创建一个新的request,然后将其插入到当前进程的request_queue的尾部,当然在插入之前还得判断当前进程的plug_list中累积的request数量是否超过阈值,如果是,先将这些requests flush至request_queue中。
get_rq:
......
init_request_from_bio(req, bio);
if (test_bit(QUEUE_FLAG_SAME_COMP, &q->queue_flags))
req->cpu = raw_smp_processor_id();
plug = current->plug;
if (plug) {
if (list_empty(&plug->list))
trace_block_plug(q);
else {
// 如果请求数量已经攒够了,flush下去 // 第二个参数设置为false代表的是同步plug if (request_count >= BLK_MAX_REQUEST_COUNT) {
blk_flush_plug_list(plug, false);
trace_block_plug(q);
}
}
// 刷完之后将该request添加到plug_list链表的尾部 list_add_tail(&req->queuelist, &plug->list);
drive_stat_acct(req, 1);
} else {
spin_lock_irq(q->queue_lock);
add_acct_request(q, req, where);
__blk_run_queue(q);
}
这里判断出如果本进程的plug_list的request已经攒得足够多了,就调用blk_flush_plug_list()刷下去。注意调用该函数时将第二个参数设置为false,表示同步刷新,我们再后面将会看到它和一部刷新的区别。然后再将该request插入到plug_list的尾部。
我们接下来仔细研究下该是如何将plug_list的request flush到request_queue中的。
flush时机
同步flush所谓的同步flush,也即我们上面刚分析的:在plug_list满的时候,将该链表上的request一次性插入到request_queue中。这里使用同步是因为我们必须等着flush完成才能继续处理后面的request。
异步flush我们不能完全依赖同步flush,很简单,因为如果在上层提交请求不足时可能会导致该list上的请求迟迟无法被调度。于是我们必须在一定的时候将这些request刷到设备的request_queue中。异步flush发生在进程切换。
schedule->
sched_submit_work ->
blk_schedule_flush_plug()->
blk_flush_plug_list(plug, true)
同步flush和异步flush具体的区别在于调用blk_flush_plug_list()时设置的第二个参数区别:设置为false代表同步flush,否则代表异步flush。
flush流程
void blk_flush_plug_list(struct blk_plug *plug, bool from_schedule)
{
// 为了效率考虑:将进程的plug_list请求全部拷贝到list内 // 不影响新的请求插入 list_splice_init(&plug->list, &list);
// 对request进行排序: // 因为每个进程的plug_list可能包含多个设备的request // 排序规则: // 1. request_queue相同(发往同一个设备)的request放在一起 // 2. request_queue按照大小排序 // 排序结果: // 所有属于同一个设备的request按照IO顺序组织起来 list_sort(NULL, &list, plug_rq_cmp);
local_irq_save(flags);
// 对每一个request,判断它与当前queue是否相同 // 如果不同,将当前queue进行unplug // 如果相同,则将该request插入到调度队列 while (!list_empty(&list)) {
rq = list_entry_rq(list.next);
list_del_init(&rq->queuelist);
BUG_ON(!rq->q);
// 如果当前request_queue的request已经插入完了 // 那么就将当前queue进行unplug,并更新当前queue为新的 // request所属的queue if (rq->q != q) {
// 为什么这里会触发一次unplug? // 这里不触发的话没别的地方触发,这里触发提交的请求不一定够多 if (q)
queue_unplugged(q, depth, from_schedule);
q = rq->q;
spin_lock(q->queue_lock);
}
if (rq->cmd_flags & (REQ_FLUSH | REQ_FUA))
__elv_add_request(q, rq, ELEVATOR_INSERT_FLUSH);
else
__elv_add_request(q, rq, ELEVATOR_INSERT_SORT_MERGE);
}
if (q)
// 为什么这里会触发一次同步unplug? queue_unplugged(q, depth, from_schedule);
local_irq_restore(flags);
}
这个函数看起来也很简单,主要流程如下:取出plug_list上的单个request(当然是已经排序过的);
判断该request是否和上一个request的queue一样,即与上一个request是发往同一个设备。如果是,则继续将该request插入到设备的request_queue中;否则:现将上次处理的request所属的设备进行一次泄流(queue_unplugged(),且是同步泄流)
处理完所有的请求后,再将最后一次处理的request所属的request_queue进行一次泄流
个人感觉,在这里最不太好理解的就是为什么要在这里对设备进行泄流?
依我愚见:一般泄流的时机是request_queue中积攒了足够的request,或者request_queue已经有一定时间未泄流了,这也正是我们前面分析老的内核的实现逻辑(请仔细回顾下)。但在新内核中抛弃了这种做法,或者说换了种做法,让我们不妨考虑下:如果一个进程的plug_list开始flush request了,说明必然是积攒了足够多的request(虽然这些request不一定是属于相同磁盘设备),但由于每个request可能可能是由很多bio merge而成。那也就意味着上层发下来的bio会更多。
因此,我们在flush 进程的plug_list的时候一般情况向该设备发下的request已经足够多,很大可能性多到已经可以忽略性能提升的地步了。而且在这里直接进行设备的泄流,关键在于逻辑足够简单,无其他调用分支,代码可读性更强。
另外,如果上层发下来的bio无法合并,那么必然可能会产生很多的request,且这些request都足够小,对于这些足够小且不连续的IO,我们即使聚集再多进行一次性泄流,也没什么大用,无法实质性的提升IO性能。
综合上述几种原因,所以我觉得这就是为什么新版内核中去掉了老版本内核那种复杂的临界值控制,而是将控制下放给了每个进程自己。这样解放了request_queue的实现,使得代码可维护性更强。
接下来我们就来欣赏更为酸爽的泄流逻辑:
// 参数from_schedule决定是走同步泄流还是异步泄流static void queue_unplugged(struct request_queue *q, unsigned int depth, bool from_schedule) __releases(q->queue_lock)
{
trace_block_unplug(q, depth, !from_schedule);
if (from_schedule)
// 异步泄流 blk_run_queue_async(q);
else // 同步泄流 __blk_run_queue(q);
spin_unlock(q->queue_lock);
}
同步泄流
同步泄流,即等着泄流过程完成,调用函数blk_run_queue
void __blk_run_queue(struct request_queue *q)
{
if (unlikely(blk_queue_stopped(q)))
return;
__blk_run_queue_uncond(q);
}
inline void __blk_run_queue_uncond(struct request_queue *q)
{
if (unlikely(blk_queue_dead(q)))
return;
q->request_fn_active++;
q->request_fn(q);
q->request_fn_active--;
}
最终调用了q->request_fn(),也即scsi_request_fn():
static void scsi_request_fn(struct request_queue *q)
{
......
for (;;) {
req = blk_peek_request(q);
if (!req || !scsi_dev_queue_ready(q, sdev))
break;
if (!(blk_queue_tagged(q) &&
!blk_queue_start_tag(q, req)))
blk_start_request(req);
sdev->device_busy++;
spin_unlock(q->queue_lock);
......
spin_lock(shost->host_lock);
if (blk_queue_tagged(q) && !blk_rq_tagged(req)) {
if (list_empty(&sdev->starved_entry))
list_add_tail(&sdev->starved_entry, &shost->starved_list);
goto not_ready;
}
scsi_init_cmd_errh(cmd);
rtn = scsi_dispatch_cmd(cmd);
spin_lock_irq(q->queue_lock);
if (rtn)
goto out_delay;
}
goto out;
not_ready:
spin_unlock_irq(shost->host_lock);
spin_lock_irq(q->queue_lock);
blk_requeue_request(q, req);
sdev->device_busy--;
out_delay:
if (sdev->device_busy == 0)
blk_delay_queue(q, SCSI_QUEUE_DELAY);
out:
spin_unlock_irq(q->queue_lock);
put_device(&sdev->sdev_gendev);
spin_lock_irq(q->queue_lock);
}
对比一下发现,新版内核的泄流逻辑其实和老版本差不多,我们就不再仔细描述,只说他们之间的区别:* 无需判断是否需要在合适的时机进行泄流了,这是因为泄流目前已经变成每个进程自己的事情;所以request_queue不再管泄流的事情;
* 无法再泄流的时候(超过底层设备的处理能力了),调用blk_delay_queue,延迟泄流,现在下面接不住了,过会再泄。这里可以简单地把其理解为一个延时任务,在延时时间到达的时候该任务就会执行,开始下一次泄流。
相较于老版本复杂的逻辑,去掉了泄流时机的判断,整个逻辑是不是变得非常清晰,至少我这么觉得。
异步泄流
看完了同步泄流,我们接下来大概看看异步泄流,它应该比同步泄流更简单,按照我的理解,其无非就是安排一个定时任务,到时间去做就好了。
void blk_run_queue_async(struct request_queue *q)
{
if (likely(!blk_queue_stopped(q) && !blk_queue_dead(q)))
mod_delayed_work(kblockd_workqueue, &q->delay_work, 0);
}
至于Linux内核的定时任务,那不是我们这里研究的重点,有机会再分析吧。
总结
在这篇文章中,我们相当仔细地分析了块设备层的优化-蓄流和泄流。我们从老版本的内核分析起,仔细阐述了蓄流泄流原因以及内部原理。接下来,我们思考该版本实现的问题:性能
代码可读性
根据我们自己的思考对老版本内核的实现提出改进意见,结合新版本内核的实现分析,确实符合我们提出的症结,且在新版本的实现中通过巧妙的方式有效地解决了问题,实现了性能和可读性的统一,不甚妙哉。