IO调度器

 

IO旅行到调度器的时候,发现自己受到的待遇竟然很不一样,有些IO倚仗着特权很快就放行了;有些IO迟迟得不到处理,甚至在有些系统中居然饿死!面对这样的现状,IO显然是很不高兴的,凭什么别人就能被很快送到下一个旅程,自己需要在调度器上耗费青春年华?这里是拼爹的时代,人家出身好,人家是读请求,人家就可以很快得到资源。咱们是写请求,出生贫寒,只能等着,但也不会让你饿死。这就是我们常见的deadline策略。在更加糟糕的地方,deadline都没有,拼的是家族血脉关系,相邻的IO可以很快处理掉,其他的等着吧,那就会出现饿死的情况。这就是我们常说的noop策略,其实就是Linus电梯。在文明一点的社会,大家会比较公平,从应用的整体来看,大家会享有相同的IO带宽,但是,从IO的个体来看,公平性还是没有的。这个社会没有绝对的公平,只要保证所有家庭的公平性,那么社会就会比较和谐。当然,我们发现有些家庭(应用)不是特别合群,我们也可以对其进行惩罚,IO带宽的分配就会对其进行缩减。这就是我们常见的CFQ策略。在IO调度器层,可以有很多的策略,不同的系统可以定义不同的策略,目的都是在与更好的聚合IO,并且对不同的应用进行QOS控制。

Linux系统中,可以注册自己的调度算法,如果不注册自己的调度器,那么可以采用上述提到的三种调度器之一。其中,deadline是在Linus电梯的基础上发展起来的,其对读写请求进行了有区别的调度,还会考虑到IO饥饿的情况。最为传统的调度器不能规避IO饥饿问题。CFQ调度器考虑了应用的公平性,在很多情况下可以得到最佳性能,有关于这三种调度器的设计比较会在下面篇章中详细阐述。

IO请求通过generic_make_request进行转发时,如果被访问的设备是一个有queue的块设备,那么系统会调用blk_queue_bio函数进行bio的调度合并。blk_queue_bio函数说明如下:

void blk_queue_bio(struct request_queue *q, struct bio *bio)
{
const bool sync = !!(bio->bi_rw & REQ_SYNC);
struct blk_plug *plug;
int el_ret, rw_flags, where = ELEVATOR_INSERT_SORT;
struct request *req;
unsigned int request_count = 0;
/*
* low level driver can indicate that it wants pages above a
* certain limit bounced to low memory (ie for highmem, or even
* ISA dma in theory)
*/
blk_queue_bounce(q, &bio);
if (bio->bi_rw & (REQ_FLUSH | REQ_FUA)) {
spin_lock_irq(q->queue_lock);
where = ELEVATOR_INSERT_FLUSH;
goto get_rq;
}
/*
* Check if we can merge with the plugged list before grabbing
* any locks.
*/
/* 尝试将bio合并到当前plugged的请求队列中 */
if (attempt_plug_merge(q, bio, &request_count))
return;
spin_lock_irq(q->queue_lock);
/* elv_merge是核心函数,找到bio前向或者后向合并的请求 */
el_ret = elv_merge(q, &req, bio);
if (el_ret == ELEVATOR_BACK_MERGE) {
/* 进行后向合并操作 */
if (bio_attempt_back_merge(q, req, bio)) {
if (!attempt_back_merge(q, req))
elv_merged_request(q, req, el_ret);
goto out_unlock;
}
} else if (el_ret == ELEVATOR_FRONT_MERGE) {
/* 进行前向合并操作 */
if (bio_attempt_front_merge(q, req, bio)) {
if (!attempt_front_merge(q, req))
elv_merged_request(q, req, el_ret);
goto out_unlock;
}
}
/* 无法找到对应的请求实现合并 */
get_rq:
/*
* This sync check and mask will be re-done in init_request_from_bio(),
* but we need to set it earlier to expose the sync flag to the
* rq allocator and io schedulers.
*/
rw_flags = bio_data_dir(bio);
if (sync)
rw_flags |= REQ_SYNC;
/*
* Grab a free request. This is might sleep but can not fail.
* Returns with the queue unlocked.
*/
/* 获取一个empty request请求 */
req = get_request_wait(q, rw_flags, bio);
if (unlikely(!req)) {
bio_endio(bio, -ENODEV);    /* @q is dead */
goto out_unlock;
}
/*
* After dropping the lock and possibly sleeping here, our request
* may now be mergeable after it had proven unmergeable (above).
* We don't worry about that case for efficiency. It won't happen
* often, and the elevators are able to handle it.
*/
/* 采用bio对request请求进行初始化 */
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 this is the first request added after a plug, fire
* of a plug trace. If others have been added before, check
* if we have multiple devices in this plug. If so, make a
* note to sort the list before dispatch.
*/
if (list_empty(&plug->list))
trace_block_plug(q);
else {
if (!plug->should_sort) {
struct request *__rq;
__rq = list_entry_rq(plug->list.prev);
if (__rq->q != q)
plug->should_sort = 1;
}
if (request_count >= BLK_MAX_REQUEST_COUNT) {
/* 请求数量达到队列上限值,进行unplug操作 */
blk_flush_plug_list(plug, false);
trace_block_plug(q);
}
}
/* 将请求加入到队列 */
list_add_tail(&req->queuelist, &plug->list);
drive_stat_acct(req, 1);
} else {
/* 在新的内核中,如果用户没有调用start_unplug,那么,在IO scheduler中是没有合并的,一旦加入到request queue中,马上执行unplug操作,这个地方个人觉得有点不妥,不如以前的定时调度机制。对于ext3文件系统,在刷写page cache的时候,都需要首先执行start_unplug操作,因此都会进行request/bio的合并操作。 */
spin_lock_irq(q->queue_lock);
/* 将request加入到调度器中 */
add_acct_request(q, req, where);
/* 调用底层函数执行unplug操作 */
__blk_run_queue(q);
out_unlock:
spin_unlock_irq(q->queue_lock);
}
}

对于blk_queue_bio函数主要做了三件事情:

1)进行请求的后向合并操作

2)进行请求的前向合并操作

3)如果无法合并请求,那么为bio创建一个request,然后进行调度

bio合并过程中,最为关键的函数是elv_merge。该函数主要工作是判断bio是否可以进行后向合并或者前向合并。对于所有的调度器,后向合并的逻辑都是相同的。在系统中维护了一个request hash表,然后通过bio请求的起始地址进行hash寻址。Hash表的生成原理比较简单,就是将所有request的尾部地址进行分类,分成几大区间,然后通过hash函数可以寻址这几大区间。Hash函数是:

hash_long(ELV_HASH_BLOCK((sec)), elv_hash_shift)

一旦通过hash函数找到所有位于这个区间的request之后,通过遍历的方式匹配到所需要的request。具体该过程的实现函数如下:

static struct request *elv_rqhash_find(struct request_queue *q, sector_t offset)
{
struct elevator_queue *e = q->elevator;
/* 通过hash函数找到区间内的所有request */
struct hlist_head *hash_list = &e->hash[ELV_HASH_FN(offset)];
struct hlist_node *entry, *next;
struct request *rq;
/* 遍历地址区间内的所有request */
hlist_for_each_entry_safe(rq, entry, next, hash_list, hash) {
BUG_ON(!ELV_ON_HASH(rq));
if (unlikely(!rq_mergeable(rq))) {
__elv_rqhash_del(rq);
continue;
}
/* 如果地址匹配,那么找到所需的request */
if (rq_hash_key(rq) == offset)
return rq;
}
return NULL;
}

采用hash方式维护request,有一点需要注意:当一个request进行合并处理之后,需要对该requesthash表中进行重新定位。这主要是因为request的尾地址发生了变化,有可能会超过一个hash区间的范围。

如果后向合并失败,那么调度器会尝试前向合并。不是所有的调度器支持前向合并,如果调度器支持这种方式,那么需要注册elevator_merge_fn函数实现前向调度功能。例如deadline算法采用了红黑树的方式实现前向调度。如果前向调度无法完成合并。那么调度器认为该合并失败,需要产生一个新的request,并且采用现有bio对其进行初始化,然后加入到request queue中进行调度处理。

IO利用generic_make_request来到块设备层之后,对其进行处理的重要函数blk_queue_bio主要任务是合并IO。由于不同的调度器有不同的合并方法、IO分类方法,所以,具体调度器的算法会采用函数注册的方式实现。blk_queue_bio仅仅是一个上层函数,最主要完成后向合并、调用调度器方法进行前向合并以及初始化request准备调度。

<待续>