Linux 通用块层提供给上层的接口函数是submit_bio。上层在构造好bio请求之后,调用该接口提交给Linux通用块层处理。
上层向块io子系统提交请求
我们首先从submit_bio函数入手:
//common/block/blk-core.c:10
blk_qc_t submit_bio(struct bio *bio)
{
if (blkcg_punt_bio_submit(bio))
return BLK_QC_T_NONE;
/*
* If it's a regular read/write or a barrier with data attached,
* go through the normal accounting stuff before submission.
*/
if (bio_has_data(bio)) {
unsigned int count;
if (unlikely(bio_op(bio) == REQ_OP_WRITE_SAME))
count = queue_logical_block_size(
bio->bi_bdev->bd_disk->queue) >> 9;
else
count = bio_sectors(bio);
if (op_is_write(bio_op(bio))) {
count_vm_events(PGPGOUT, count);
} else {
task_io_account_read(bio->bi_iter.bi_size);
count_vm_events(PGPGIN, count);
}
}
/*
* If we're reading data that is part of the userspace workingset, count
* submission time as memory stall. When the device is congested, or
* the submitting cgroup IO-throttled, submission can be a significant
* part of overall IO time.
*/
if (unlikely(bio_op(bio) == REQ_OP_READ &&
bio_flagged(bio, BIO_WORKINGSET))) {
unsigned long pflags;
blk_qc_t ret;
psi_memstall_enter(&pflags);
ret = submit_bio_noacct(bio);
psi_memstall_leave(&pflags);
return ret;
}
return submit_bio_noacct(bio);
}
EXPORT_SYMBOL(submit_bio);
这个函数所做的其实是台账account工作,做的基本上都是统计工作,真正的实际活动是由submit_bio_noacct函数来做的:
//common/block/blk-core.c:1025
blk_qc_t submit_bio_noacct(struct bio *bio)
{
/*
* We only want one ->submit_bio to be active at a time, else stack
* usage with stacked devices could be a problem. Use current->bio_list
* to collect a list of requests submited by a ->submit_bio method while
* it is active, and then process them after it returned.
* 如果存在bio_list,则将bio放入bio_list就返回等待执行就好了
*/
if (current->bio_list) {
bio_list_add(¤t->bio_list[0], bio);
return BLK_QC_T_NONE;
}
// 如果不存在 bio_list,也不存在bd_disk所对应的submit_bio操作
if (!bio->bi_bdev->bd_disk->fops->submit_bio)
return __submit_bio_noacct_mq(bio);
// 如果不存在 bio_list,但是存储设备定义了submit_bio操作
return __submit_bio_noacct(bio);
}
EXPORT_SYMBOL(submit_bio_noacct);
从上代码中可以看出,当存储设备定义了submit_bio操作时,会通过调用__submit_bio_noacct,否则调用__submit_bio_noacct,它们的区别主要在于有没有对队列中bio的排序归并操作。
(图源csdn-geshifei,处理bio的路径)
由于“栈式块设备”(例如md和dm设备)的存在和堆栈大小限制,为防止在栈式块设备执行过程中可能出现的问题,在一个时刻只允许进程有一个submit_bio处于active状态,在实现上,使用了一个bio等待处理链表bio_list,当该进程bio_list处于active状态时,后续的bio直接被链入链表,在当前bio处理完成后顺序逐个地处理。
submit_bio_noacct与bio_list有关的执行过程是:
首先判断bio_list是否处于激活状态,如是,直接链入bio_list并返回,反之进入第二步;
无论最后由__submit_bio_noacct_mq或__submit_bio_noacct继续进行,都会新建一个bio_list表明当前的submit_bio正处于活动状态,让后来的bio有机会链入链表;
从链表中取出bio并处理;
没有bio需要处理后,置bio_list为NULL,使得该进程的submit_bio处于非活动状态。
例如以__submit_bio_noacct_mq函数为例:
//common/block/blk-core.c:1001
static blk_qc_t __submit_bio_noacct_mq(struct bio *bio)
{
struct bio_list bio_list[2] = { };
blk_qc_t ret;
current->bio_list = bio_list; // 设置了bio_list
do {
ret = __submit_bio(bio); // 处理bio
} while ((bio = bio_list_pop(&bio_list[0]))); // 处理下一个bio
current->bio_list = NULL; // 置bio_list为null
return ret;
}
无论走noacct_mq还是noacct,最终都会调用__submit_bio函数来处理bio。
//common/block/blk-core.c:915
static blk_qc_t __submit_bio(struct bio *bio)
{
struct gendisk *disk = bio->bi_bdev->bd_disk;
blk_qc_t ret = BLK_QC_T_NONE;
if (unlikely(bio_queue_enter(bio) != 0))
return BLK_QC_T_NONE;
if (!submit_bio_checks(bio) || !blk_crypto_bio_prep(&bio))
goto queue_exit;
if (disk->fops->submit_bio) { // 存储设备定义了submit_bio操作
ret = disk->fops->submit_bio(bio);
goto queue_exit;
}
return blk_mq_submit_bio(bio); // 存储设备没有定义submit_bio操作
queue_exit:
blk_queue_exit(disk->queue);
return ret;
}
可以发现,存储设备是否定义了submit_bio操作,它们的行为在这这个函数中发生了变化,如果定义了相关操作,则会直接调用,否则调用blk_mq所提供的通用处理函数blk_mq_submit_bio。
blk_mq_submit_bio函数是一个比较复杂的函数,它将根据bio创建和发送一个request给块设备。但是在分析这个函数之前,我们需要首先了解一下block层的multi-queue机制,即blk-mq。
blk-mq
Linux传统上的块设备层和IO调度器主要是针对HDD(hard disk drivers)设计的,而HDD的随机读写性能很差,吞吐量只有几百IO per second,延迟在毫秒级,所以当时IO性能的瓶颈在硬件,而不是内核。但是随着科技的发展,高速SDD(Solid State Disk)的出现并展现出越来越高的性能,百万级甚至千万级的数据访问已经成为趋势,传统块设备层已经无法满足如此高的IOPS需求,逐渐成为系统IO的性能瓶颈。
于是为了适配现代设备高IOPS、低延迟的特征,新的块设备层框架blk-mq应运而生。
在单队列架构下,向块设备层提交bio后,仅有一个层次的一个请求队列:
在多核体系下,单队列的缺点尤为突出:
请求队列锁竞争:单队列机制下使用spinlock来同步IO请求队列的访问,每次想往请求队列里插入或删除IO请求,必须先获取此锁,要操作请求队列、对IO排序和调度时,也需要获取此锁,这一系列的操作继续之前,必须先请求锁,在高IOPS场景,多个线程同时提交IO请求的情况下,锁的竞争非常剧烈,带来不可忽视的软件开销。
从上图可以看到,在单队列机制高IOPS场景下,约80%的cpu时间耗费在锁获取上。
硬件中断:高的IOPS意味着高的中断数量,在多数情况下,完成一次IO需要两次中断,一次是存储器件触发的硬件中断,另一次是cpu核间中断。
远端内存访问:如果提交IO请求的cpu不是接收硬件中断的cpu且两个cpu中间没有共享缓存,那么获取请求队列锁的过程中,还存在远端访问的问题。
综上,实验证明,核数越多,单队列性能越差。
就像单行线拥挤了可以修更宽敞的路一样,针对单队列框架下存在的问题,多队列框架被提出:
多队列框架采用了两层队列,将单个请求队列锁的竞争分散到多个队列中,极大地提高了Block层并发处理IO的能力。
两层队列设计分工明确:
软件暂存队列(Software Staging Queue):
blk-mq为每一个cpu分配一个软件队列,bio的提交/完成处理、IO请求的合并排序标记调度记账等block层应该完成的功能都在这个队列上进行。由于每个cpu有单独的队列,所以每个cpu的io操作可以同时进行且不存在锁竞争的问题。
硬件派发队列(Hardware Dispatch Queue):
blk-mq为存储器件的每个硬件队列(多数存储器件只有一个)分配一个硬件派发队列,负责存放软件队列往这个硬件队列派发的io请求。在存储设备驱动初始化时,blk-mq会通过固定的映射关系将一个或多个软件队列映射(map)到一个硬件派发队列(合理的设计以保证每个硬件队列的软件队列数量基本一致),之后这些软件队列上的io请求会往映射的硬件队列上派发。
解决了锁的问题的同时,远端访问问题也同时解决,极大提高了block层的IOPS吞吐量,从下图可看出,锁获取所消耗的cpu时间比例大大减小:
性能上,mq也更加接近raw设备的性能:
mq当下已经成为内核默认选项。我们之前了解过的null_blk空设备驱动也是基于blk-mq实现。想要了解更多mq知识,可以参考资料2,如有必要之后还会继续研究。
在回到我们的主线之前,还需要补充关于blk_plug的知识。
blk_plug
blk_plug构建了一个缓存碎片IO的请求队列,用于将顺序请求合并成一个更大的请求。合并后请求批量从各自的任务链表移动到设备的request_queue,减少了设备请求队列锁竞争,从而提高效率。
如果一个线程开启了请求合并模式 blk_start_plug,就会启用blk_plug,反之,如果设置了blk_finish_plug,则会关闭线程的请求合并。
根据参考资料3中的测试情况:
测试环境:
SATA控制器:intel 82801JI
OS: linux3.6, redhat
raid5: 4个ST31000524NS盘
没有blk_plug:
Total (8,16):
Reads Queued: 309811, 1239MiB Writes Queued: 0, 0KiB
Read Dispatches: 283583, 1189MiB Write Dispatches: 0, 0KiB
Reads Requeued: 0 Writes Requeued: 0
Reads Completed: 273351, 1149MiB Writes Completed: 0, 0KiB
Read Merges: 23533, 94132KiB Write Merges: 0, 0KiB
IO unplugs: 0 Timer unplugs: 0
添加了 blk_plug:
Total (8,16):
Reads Queued: 428697, 1714MiB Writes Queued: 0, 0KiB
Read Dispatches: 3954, 1714MiB Write Dispatches: 0, 0KiB
Reads Requeued: 0 Writes Requeued: 0
Reads Completed: 3956, 1715MiB Writes Completed: 0, 0KiB
Read Merges: 424743, 1698MiB Write Merges: 0, 0KiB
IO unplugs: 0 Timer unplugs: 3384
可以看出,读请求被大量合并了,提高了效率。
blk_mq_submit_bio
回到这个函数中来,只要存储设备没有定义submit_bio,在mq已经成为默认选项的情况下,极大一部分的submit_bio操作都会走到这个函数。
在这个函数中,将bio转化为request后,会根据不同的情况(if-else结构),进行不同的操作:
如果本次io是flush、fua类型,执行此路径,此类bio需要尽快分发器件处理,不能缓存在上层队列中,直接插入队列后立即启动。
如果启用了请求合并plug,且满足以下之一:硬件队列长度为1或存储设备定义了自己的commit_rqs函数或存储设备是旋转的机械磁盘(QUEUE_FLAG_NONROT标记位置)或队列为sbitmap_shared,这个场景针对慢速设备,会将request暂存进入自己的plug list,然后再进行调度,对于慢速设备而言,会较大地提高性能。
如果存在IO调度器,交由IO调度器去处理。
如果启用了请求合并plug,且存储设备设置了不允许合并io请求标志位,执行此路径,这个场景下,仅作有限合并,若本次request与plug list中的request可以合并则合并,否则就添加到plug中,接着将plug list中的request发送到下层队列中。从代码上看,由于每一次都下发,plug中属于一个存储设备的request有且只有一个。
如果硬件队列数量大于1且请求是同步的,或者硬件队列不忙,为了充分利用存储器件处理能力,直接下发。
默认执行路径。如果是emmc、ufs这些单队列设备,或者nvme这类多队列设备但异步请求,或者nvme这类多队列设备且同步请求但是硬件队列忙,执行此路径。
由于采用if-else结构,当前面的路径满足后,后面的路径就不会再判断。
代码分析:
blk_qc_t blk_mq_submit_bio(struct bio *bio)
{
struct request_queue *q = bio->bi_bdev->bd_disk->queue;
const int is_sync = op_is_sync(bio->bi_opf);
const int is_flush_fua = op_is_flush(bio->bi_opf);
struct blk_mq_alloc_data data = {
.q = q,
};
struct request *rq;
struct blk_plug *plug;
struct request *same_queue_rq = NULL;
unsigned int nr_segs;
blk_qc_t cookie;
blk_status_t ret;
bool hipri;
blk_queue_bounce(q, &bio); // 创建一个反弹缓冲区
__blk_queue_split(&bio, &nr_segs); // 将bio拆分
// 拆分与合并能提高吞吐效率,详见资料一
if (!bio_integrity_prep(bio))
goto queue_exit;
// plug list merge:尝试将入参bio合并到plug list管理的rq中。
if (!is_flush_fua && !blk_queue_nomerges(q) &&
blk_attempt_plug_merge(q, bio, nr_segs, &same_queue_rq))
goto queue_exit;
// 调度器 merge:尝试将入参bio合并到调度器队列或者软件队列ctx管理的rq中。
if (blk_mq_sched_bio_merge(q, bio, nr_segs))
goto queue_exit;
rq_qos_throttle(q, bio);
hipri = bio->bi_opf & REQ_HIPRI;
data.cmd_flags = bio->bi_opf;
rq = __blk_mq_alloc_request(&data);
if (unlikely(!rq)) {
rq_qos_cleanup(q, bio);
if (bio->bi_opf & REQ_NOWAIT)
bio_wouldblock_error(bio);
goto queue_exit;
}
trace_block_getrq(bio);
rq_qos_track(q, rq, bio);
// 回包
cookie = request_to_qc_t(data.hctx, rq);
// bio -> request
blk_mq_bio_to_request(rq, bio, nr_segs);
// 加解密相关
ret = blk_crypto_init_request(rq);
if (ret != BLK_STS_OK) {
bio->bi_status = ret;
bio_endio(bio);
blk_mq_free_request(rq);
return BLK_QC_T_NONE;
}
// 六条路径分流,if-else结构
plug = blk_mq_plug(q, bio);
if (unlikely(is_flush_fua)) {
/* Bypass scheduler for flush requests */
blk_insert_flush(rq);
blk_mq_run_hw_queue(data.hctx, true);
} else if (plug && (q->nr_hw_queues == 1 ||
blk_mq_is_sbitmap_shared(rq->mq_hctx->flags) ||
q->mq_ops->commit_rqs || !blk_queue_nonrot(q))) {
/*
* Use plugging if we have a ->commit_rqs() hook as well, as
* we know the driver uses bd->last in a smart fashion.
*
* Use normal plugging if this disk is slow HDD, as sequential
* IO may benefit a lot from plug merging.
*/
unsigned int request_count = plug->rq_count;
struct request *last = NULL;
if (!request_count)
trace_block_plug(q);
else
last = list_entry_rq(plug->mq_list.prev);
if (request_count >= blk_plug_max_rq_count(plug) || (last &&
blk_rq_bytes(last) >= BLK_PLUG_FLUSH_SIZE)) {
blk_flush_plug_list(plug, false);
trace_block_plug(q);
}
blk_add_rq_to_plug(plug, rq);
} else if (q->elevator) {
/* Insert the request at the IO scheduler queue */
blk_mq_sched_insert_request(rq, false, true, true);
} else if (plug && !blk_queue_nomerges(q)) {
/*
* We do limited plugging. If the bio can be merged, do that.
* Otherwise the existing request in the plug list will be
* issued. So the plug list will have one request at most
* The plug list might get flushed before this. If that happens,
* the plug list is empty, and same_queue_rq is invalid.
*/
if (list_empty(&plug->mq_list))
same_queue_rq = NULL;
if (same_queue_rq) {
list_del_init(&same_queue_rq->queuelist);
plug->rq_count--;
}
blk_add_rq_to_plug(plug, rq);
trace_block_plug(q);
if (same_queue_rq) {
data.hctx = same_queue_rq->mq_hctx;
trace_block_unplug(q, 1, true);
blk_mq_try_issue_directly(data.hctx, same_queue_rq,
&cookie);
}
} else if ((q->nr_hw_queues > 1 && is_sync) ||
!data.hctx->dispatch_busy) {
/*
* There is no scheduler and we can try to send directly
* to the hardware.
*/
blk_mq_try_issue_directly(data.hctx, rq, &cookie);
} else {
/* Default case. */
blk_mq_sched_insert_request(rq, false, true, true);
}
if (!hipri)
return BLK_QC_T_NONE;
return cookie;
queue_exit:
blk_queue_exit(q);
return BLK_QC_T_NONE;
}
请求的切分与合并是请求处理过程中的重要内容,但是考虑篇幅限制和当前工作的重点不在这里,之后可能另起一文学习。
参考资料
https://blog.csdn.net/geshifei/article/details/120590183
https://www.cnblogs.com/Linux-tech/p/12961279.html
https://blog.csdn.net/Faded0104/article/details/103224969