我想从事服务器线上问题排查的同学,都应该遇到过多进程读写文件时,IO抢占导致的IO延迟明显问题。比如这样一个场景,磁盘sata,进程A不定时读取100KB的日志文件,正常情况几百us就读取完成。但是正碰巧遇到B进程也在读写一个200MB的数据文件,进程A大概率读取这100KB的配置文件耗时几十ms,甚至上百ms。
这种情况很常见,因为sata盘随机读写性能也就几十MB,IOPS也就几百吧(好点的上千)。进程B读写那200MB的数据文件基本把sata盘带宽占完了,进程A只能等待进程B暂时让出IO资源才能断断续续读取完100KB的日志文件。再加上内核block层单队列框架设计缘故,在进程IO请求分配、IO请求的合并、IO请求加入IO队列、IO请求的派发等等,需要持有q->queue_lock锁。多核多进程高并发场景下,在q->queue_lock锁的竞争上将有较大的性能开销。
云场景大量使用容器(一个容器可以看成一个业务),一台服务器跑几十个容器很常见,这些容器基本都有读写文件。如果某个容器在一个时间点IO流量很大,长时间占着磁盘IO,其他进程读写文件必然受到影响,IO延迟是肯定的。如果这些业务是数据库等对IO很敏感的业务,是无法忍受IO延迟的。必然发生IO超时,问题挺严重。当然把sata盘换成ssd,不行再上nvme,会一定程度缓解多进程读写文件造成的IO延迟,根治不现实。除了堆硬件资源,有没有软件上的优化手段?
使用blkio cgroup限流功能也可以缓解该问题,但是该IOPS上限该设置多少?设置大了不起作用,设置小了又太影响性能。比如A业务每周日0点定时保存几个GB的日志,为了保证保存日志时不影响其他业务,对A业务的进程该怎么限流呢?每秒最大20M?IOPS该怎么限制?似乎只要稍微大点就可能会因为占着磁盘IO资源而影响IO敏感的业务 (毕竟sata盘随机读写性能真的差,还无法提前预估是随机/顺序读写),看来使用cgroup 限流并不是个好的解决方法。
其实有很多对IO敏感的业务IO流量并不大,比如读写的文件也就几十KB或者几MB,IO延迟只能保持的us级别,最差的几个ms。如果有个IO大流量的业务占着磁盘IO资源,这些对IO敏感的业务读写文件时,IO延迟估计就是几百ms甚至秒级别,业务当然无法忍受。是否可以把这些IO敏感的业务标记为“IO高优先级”,如果这些业务进程在IO传输时遇到某些IO流量大的进程占着磁盘IO资源,让这些IO敏感的“IO高优先级”业务进程优先派发IO请求给磁盘驱动,优先传输。如此IO敏感的业务进程即便遇上IO流量大的进程占着磁盘IO资源,因为优先IO传输,IO延迟就可以一定程度降低?这个方案现在已经取得了进展,确实有效果。
本文将围绕如何实现“IO高优先级”进程优先派发IO请求展开讨论,基于 block层deadline 调度算法,介绍如何修改IO请求的合并、IO请求的分配、IO请求加入IO算法队列、IO请求的派发、IO请求传输完成等内核block源码,实现IO优先派发功能。最后也涉及到q->queue_lock锁竞争的实战分析。本文内核版本centos 7.6 3.10.0.957.27,实现该功能的内核源码见https://github.com/dongzhiyan-stack/kernel_brainstorming。
1内核block层IO请求的合并、加入IO算法队列、IO派发概述
这一节先对IO请求在block的工作过程做一个简单总结,建议先看下我之前写过的两篇文章,iostat IO统计原理linux内核源码分析----基于单通道SATA盘和block层IO调度器 (deadline调度算法) linux内核源码详解,介绍的比较详细。
还是从经典的发送IO请求submit_bio->generic_make_request->blk_queue_bio函数开始,该函数首先尝试将bio(即struct bio)合并IO队列已有的req(req即struct request,代表IO请求)。如果合并失败则为该bio分配一个新的req,最后再把这个新的req添加到IO算法队列(elv hash队列、deadline的红黑树和fifo队列),总结一下总的函数流程:
1.1 blk_queue_bio函数的处理
1 首先,尝试将bio合并到当前进程plug->list链表上的req。两个函数流程如下:
- blk_queue_bio->blk_attempt_plug_merge->bio_attempt_back_merge//bio后项合并到req
- blk_queue_bio->blk_attempt_plug_merge->bio_attempt_front_merge//bio前项合并到req
2 接着,尝试将bio合并到IO算法队列(elv的hash队列和deadline算法的红黑树队列)的req,可能还会触发req二次合并()。两个函数流程如下:
- blk_queue_bio->elv_merge//判断出bio可以后项合并到elv hash队列的req
- ->bio_attempt_back_merge//bio后项合并到req
- //req扇区结束地址增大,取出它在红黑树队列后边的req1,再尝试把req前项合并到req1
- ->attempt_back_merge->attempt_merge//这就是req的2次合并
- blk_queue_bio->elv_merge//判断出bio可以前项合并到deadline 算法红黑树队列的req
- ->bio_attempt_front_merge//bio前项合并到req
- //req扇区起始地址增大,取出它在红黑树队列前边的req2,再尝试把req后项合并到req2
- ->attempt_front_merge->attempt_merge//这就是req的2次合并
前项/后项合并的意思是,如果bio代表的扇区结束地址等于req的扇区起始地址,则bio前项合并到req。如果bio代表的扇区起始地址等于req的扇区结束地址,则bio后项合并到req。同时说明一下,红黑树队列是deadline算法专有的队列。
3 如果经历了前两步bio没能合并到任何的req,只能为该bio分配新的req
- blk_queue_bio->get_request//为bio分配新的req
- //把req添加到elv hash队列
- ->add_acct_request->__elv_add_request->elv_rqhash_add
- //把req添加到deadline算法的红黑树和fifo队列
- ->deadline_add_request
- ->__blk_run_queue//把req派发给磁盘驱动
1.2 req的派发
当进程plug模式发送IO请求,执行blk_queue_bio函数只是把req添加到进程plug->list链表,然后再执行blk_finish_plug->blk_flush_plug_list把进程plug->list链表上的req添加到IO算法队列,最后把req派发给磁盘驱动,看下这个流程:
不管是blk_queue_bio或者blk_flush_plug_list函数,最后都会执行__blk_run_queue->__blk_run_queue_uncond->scsi_request_fn派发IO算法队列的req给磁盘驱动,看下scsi_request_fn函数的整体流程。
- //从deadline的红黑树或者fifo队列取出req到q->queue_head
- //链表,然后从q->queue_head链表取出这个req,派发这个req
- scsi_request_fn->blk_peek_request->__elv_next_request->deadline_dispatch_requests
- ->scsi_dispatch_cmd//最终把req派发给磁盘驱动
1.3 req合并 的attempt_merge函数
前文多处看到attempt_merge这个函数,
- static int attempt_merge(struct request_queue *q, struct request *req,struct request *next)
这是把next这个req后项合并到另一个req,看下它的流程
- attempt_merge->deadline_merged_requests//deadline算法把next合并到req
- //req吞并了next这个req,扇区结束如果变大需要重新再hash队列排序
- ->elv_rqhash_reposition
- ->elv_rqhash_del//把next这个req从hash队列剔除掉
好的,终于把bio或者req的合并、req的分配、req添加到IO算法队列、req的派发涉及的函数介绍清楚了。“IO高优先级”进程优先派发IO的优化方法也是围绕这些函数进行修改的,重点修改的是标蓝色的那几个函数。优化的核心思想是:当“IO高优先级”派发IO请求时,把该IO请求插入deadline算法fifo队列头,并设置超时时间0,这样该IO请求就会以最快的速度派发给磁盘驱动。即便此时有个大IO流量的进程也在进行读写文件,“IO高优先级”进程也不会有大的IO延迟,当然这个优化方法也有局限性,都在下文讲解。
2 “IO高优先级”进程优先派发IO 之内核源码的修改
首先得解决一个问题,怎么把一个进程标记为“IO高优先级”,用到了cgroup blkio限流功能。这个功能涉及到了cgroup blkio内核源码的修改,细节可以看下https://github.com/dongzhiyan-stack/kernel_brainstorming。这里只介绍怎么使用,首先增加了cgroup blkio控制文件” throttle.io_priority_control”,ls /sys/fs/cgroup/blkio即可看到” blkio.throttle.io_priority_control”。执行如下命令即可将进程1标记为“IO高优先级”
- [root@localhost ~]# cd /sys/fs/cgroup/blkio
- [root@localhost blkio]# lsblk
- NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
- sda 8:0 0 46G 0 disk
- ├─sda1 8:1 0 300M 0 part /boot
- ├─sda2 8:2 0 2G 0 part [SWAP]
- sdb 8:16 0 50G 0 disk /mnt/ext4
- sr0 11:0 1 1024M 0 rom
- [root@localhost blkio]# echo “8:16 1” > blkio.throttle.io_priority_control
- [root@localhost blkio]# echo 进程 1ID >/sys/fs/cgroup/blkio/tasks
好的,进程1现在已经被标记为“IO高优先级”进程,该进程读写/mnt/ext4目录下的文件,在block层传输IO请求时即可优先传输,下一节接着介绍怎么修改内核源码。
2.1 req的合并、req的分配、req添加到IO算法队列的优化
在include/linux/blk_types.h增加如下宏定义,用来标记bio和req为高优先级传输属性
- #define BIO_HIGHPRIO 16 /* 高优先级传输bio */
- /*高优先传输的req*/
- #define REQ_HIGHPRIO (1ULL << __REQ_HIGHPRIO)
在tg_may_dispatch()增加如下代码
- static bool tg_may_dispatch(struct throtl_grp *tg, struct bio *bio,
- unsigned long *wait)
- {
- ........
- /*如果当前进程设置了“IO高优先级”传输属性,则bio标记为高优先级*/
- if(tg->io_priority_control != 0){
- bio->bi_flags |= (1 << BIO_HIGHPRIO);
- }
- ........
- }
当进程1发送IO请求,执行submit_bio->generic_make_request-> generic_make_request_checks-> blk_throtl_bio-> tg_may_dispatch ,便可以将bio->bi_flags标记为 BIO_HIGHPRIO高优先级。
接着在bio_attempt_front_merge /bio_attempt_back_merge函数最后增加如下代码:
- bool bio_attempt_back_merge(struct request_queue *q, struct request *req,
- struct bio *bio)
- {
- ........
- //如果合并的bio有高优先级传输属性则设置req高优先级,还要清理掉bio的高优先级传输属性
- if(bio->bi_flags & (1 << BIO_HIGHPRIO)){
- req->cmd_flags |= REQ_HIGHPRIO;
- bio->bi_flags &= ~(1 << BIO_HIGHPRIO);
- }
- }
- ........
- }
- bool bio_attempt_front_merge(struct request_queue *q, struct request *req,
- struct bio *bio)
- {
- ........
- //如果合并的bio有高优先级传输属性则设置req高优先级,还要清理掉bio的高优先级传输属性
- if(bio->bi_flags & (1 << BIO_HIGHPRIO)){
- req->cmd_flags |= REQ_HIGHPRIO;
- bio->bi_flags &= ~(1 << BIO_HIGHPRIO);
- }
- ........
- }
这样进程1发送IO请求执行submit_bio->generic_make_request->blk_queue_bio,将bio合并到进程plug->list链表或者IO算法队列的req时,执行到bio_attempt_front_merge /bio_attempt_back_merge函数的 if(bio->bi_flags & (1 << BIO_HIGHPRIO)) ,检测到bio有BIO_HIGHPRIO高优先级传输属性,则执行req->cmd_flags |= REQ_HIGHPRIO,将bio合并到的req设置REQ_HIGHPRIO高优先级传输属性。
但是,如果bio没有合并到plug->list链表或者IO算法队列的req,则需要分配一个新的req,再设置它的REQ_HIGHPRIO属性,代码如下:
- void blk_queue_bio(struct request_queue *q, struct bio *bio)
- {
- ........
- req = get_request(q, rw_flags, bio, 0); //清空一个新的req
- if (IS_ERR(req)) {
- blk_queue_exit(q);
- bio_endio(bio, PTR_ERR(req)); /* @q is dead */
- goto out_unlock;
- }
- if(bio->bi_flags & (1 << BIO_HIGHPRIO)){
- //清空bio的高优先级传输属性,隐藏的关键点
- bio->bi_flags &= ~(1 << BIO_HIGHPRIO);
- //如果bio有高优先级传输属性则设置对应的req高优先级传输
- req->cmd_flags |= REQ_HIGHPRIO;
- }
- ........
- }
接着,如果一个新分配的req有REQ_HIGHPRIO高优先级传输属性,添加到deadline算法红黑树和fifo队列该怎么修改内核源码呢?主要有这两个分支blk_queue_bio->add_acct_request->__elv_add_request->deadline_add_request和blk_flush_plug_list-> __elv_add_request-> deadline_add_request。最后都是执行deadline算法的deadline_add_request函数,直接把req添加到deadline算法红黑树和fifo队列。修改后的deadline_add_request函数源码如下:
- static void deadline_add_request(struct request_queue *q, struct request *rq)
- {
- struct deadline_data *dd = q->elevator->elevator_data;
- const int data_dir = rq_data_dir(rq);
- //把req添加到红黑树队列
- deadline_add_rq_rb(dd, rq);
- //deadline算法把req添加到fifo队列,添加到红黑树队列在上边的deadline_add_rq_rb()
- if(rq->cmd_flags & REQ_HIGHPRIO){
- //如果req有高优先级传输属性,则req放入fifo链表头,超时时间0,保证最快被调度派发给驱动
- rq->fifo_time = jiffies;
- list_add(&rq->queuelist, &dd->fifo_list[data_dir]);
- }else{
- rq->fifo_time = jiffies + dd->fifo_expire[data_dir];
- list_add_tail(&rq->queuelist, &dd->fifo_list[data_dir]);
- }
- }
红色部分代码是新增的,作用是:如果req有REQ_HIGHPRIO高优先级传输属性,则把req添加到fifo队列头(list_add(&rq->queuelist, &dd->fifo_list[data_dir])),并且设置req在fifo队列的超时时间是0(rq->fifo_time = jiffies),一般req添加到fifo队列时在fifo队列的超时时间是dd->fifo_expire[data_dir]。这样做的目的是,该req将以最快的速度得到派发。
还有一种情况req有REQ_HIGHPRIO高优先级传输属性,但是它合并到了另外的req,则需要把REQ_HIGHPRIO高优先级传输属性传递到合并后的req。主要有两种情况,函数流程在前文已经列过,这里再贴一下:
- blk_queue_bio->elv_merge//判断出bio可以后项合并到elv hash队列的req
- ->bio_attempt_back_merge//bio后项合并到req
- //req扇区结束地址增大,取出它在红黑树队列后边的req1,再尝试把req前项合并到req1
- ->attempt_back_merge->attempt_merge//这就是req的2次合并
- blk_queue_bio->elv_merge//判断出bio可以前项合并到deadline 算法红黑树队列的req
- ->bio_attempt_front_merge//bio前项合并到req
- //req扇区起始地址增大,取出它在红黑树队列前边的req2,再尝试把req后项合并到req2
- ->attempt_front_merge->attempt_merge//这就是req的2次合并
这个流程执行blk_queue_bio函数发送IO请求时,执行bio_attempt_back_merge/ bio_attempt_front_merge函数把有BIO_HIGHPRIO属性的bio合并到req1/req2,req1/req2就有了REQ_HIGHPRIO高优先级传输属性。然后执行attempt_back_merge/ attempt_front_merge继续尝试把req1/req2合并到deadline算法红黑树队列其他的req。因为req1/req2此时吞并了bio,req1/req2的扇区起始地址或者扇区结束地址增大,就可以将req1/req2尝试2次合并到deadline算法红黑树队列前后挨着的req。
这个流程是派发进程plug->list链表上的req,先尝试执行blk_flush_plug_list->__elv_add_request->elv_attempt_insert_merge->blk_attempt_req_merge->attempt_merge把req合并到deadline 算法红黑树队列的req。
可以发现,最终执行的都是attempt_merge()函数,把一个req合并到另一个req。这个函数里会执行到elv_merge_requests-> deadline_merged_requests 。deadline_merged_requests是deadline 算法elevator_merged_fn接口函数,做一些deadline算法req合并后的收尾工作,也是一个优化的重点,如下:
- static void deadline_merged_requests(struct request_queue *q, struct request *req,
- struct request *next)
- {
- if (!list_empty(&req->queuelist) && !list_empty(&next->queuelist)) {
- if (time_before(next->fifo_time, req->fifo_time)) {
- list_move(&req->queuelist, &next->queuelist);
- req->fifo_time = next->fifo_time;
- }
- }
- deadline_remove_request(q, next);
- //将next合并到req,next的高优先级传递到req.并且req要放到fifo队列头,会得到优先派发的机会
- if(next->cmd_flags & REQ_HIGHPRIO){
- struct deadline_data *dd = q->elevator->elevator_data;
- const int data_dir = rq_data_dir(req);
- req->fifo_time = jiffies;//req放入fifo链表头,超时时间0,保证最快被调度派发给磁盘驱动
- req->cmd_flags |= REQ_HIGHPRIO;//设置req高优先级
- list_move(&req->queuelist, &dd->fifo_list[data_dir]);
- //如果next这个req有高优先级传输属性,清理掉,它不会参与IO传输,这是唯一清理高优先级属性的机会
- next->cmd_flags &= ~REQ_HIGHPRIO;
- }
- }
红色是新增的代码,因为next合并到了req,之后next就无效了,将被从fifo队列和红黑树队列清理掉。但是next如果有REQ_HIGHPRIO属性,req要继承next这个req的属性(req->cmd_flags |= REQ_HIGHPRIO),并且设置req的在fifo队列的超时时间是0(req->fifo_time = jiffies),还要把req移动到fifo队列头(list_move(&req->queuelist, &dd->fifo_list[data_dir])),这样req将得到优先派发给磁盘驱动的机会。
好的,现在req的合并、req的分配、req添加到IO算法队列涉及的内核代码该怎么优化已经介绍过了,下一步开始讲解req的派发过程该怎么优化。
2.2 req派发的优化
从IO算法队列派发req的过程是:__blk_run_queue->scsi_request_fn->blk_peek_request->__elv_next_request->deadline_dispatch_requests,在deadline_dispatch_requests函数将deadline算法红黑树或者fifo队列的req添加到q->queue_head链表。然后回到scsi_request_fn函数,将这个req派发该磁盘驱动。
deadline_dispatch_requests函数是deadline 调度算法的核心,修改如下:
- static int deadline_dispatch_requests(struct request_queue *q, int force)
- {
- ...........
- struct request *rq;
- int data_dir;
- #define IS_REQ_HIGHPRIO(req) ((req->cmd_flags & REQ_IO_STAT) && (req->cmd_type == REQ_TYPE_FS) && (req->cmd_flags&REQ_HIGHPRIO))
- /*取出fifo队列头的read/write req*/
- struct request *r_req = rq_entry_fifo(dd->fifo_list[READ].next);
- struct request *w_req = rq_entry_fifo(dd->fifo_list[WRITE].next);
- if (dd->next_rq[WRITE])
- rq = dd->next_rq[WRITE];
- else
- rq = dd->next_rq[READ];
- if(rq && ((w_req && IS_REQ_HIGHPRIO(w_req)) || (r_req && IS_REQ_HIGHPRIO(r_req)))){
- if(w_req && (rq != w_req) && IS_REQ_HIGHPRIO(w_req) && dd->next_rq[WRITE])
- list_move(&rq->queuelist, &w_req->queuelist);
- else if(r_req && (rq != r_req) && IS_REQ_HIGHPRIO(r_req) && dd->next_rq[READ])
- list_move(&rq->queuelist, &r_req->queuelist);
- if(w_req && IS_REQ_HIGHPRIO(w_req))
- data_dir = WRITE;
- else
- data_dir = READ;
- goto dispatch_find_request;
- }
- else if (rq && dd->batching < dd->fifo_batch)
- /* we have a next request are still entitled to batch */
- goto dispatch_request;
- ............
- deadline_move_request(dd, rq);
- return 1;
- }
红色部分依然是新增的代码,它的目的是检测出fifo队列头的req有REQ_HIGHPRIO高优先级传输属性,则立即派发。
还有一处优化是deadline_dispatch_requests->deadline_move_to_dispatch函数
- void elv_dispatch_add_head(struct request_queue *q, struct request *rq)
- {
- if (q->last_merge == rq)
- q->last_merge = NULL;
- elv_rqhash_del(q, rq);
- q->nr_sorted--;
- q->end_sector = rq_end_sector(rq);
- q->boundary_rq = rq;
- list_add(&rq->queuelist, &q->queue_head);
- }
- static inline void
- deadline_move_to_dispatch(struct deadline_data *dd, struct request *rq)
- {
- struct request_queue *q = rq->q;
- deadline_remove_request(q, rq);
- //如果req有高优先级传输属性,则要把req加入q->queue_head链表头,这样该req会得到优先派发
- if(rq->cmd_flags & REQ_HIGHPRIO)
- elv_dispatch_add_head(q, rq);
- else
- elv_dispatch_add_tail(q, rq);
- }
看红色新增的代码,它是把从fifo队列头找出的有REQ_HIGHPRIO高优先级传输属性添加到q->queue_head链表头,默认是执行elv_dispatch_add_tail把它req添加到q->queue_head链表尾。我们当然需要把该req添加到q->queue_head链表头,将来scsi_request_fn函数就会从q->queue_head链表头取出这个req,派发给磁盘驱动。
2.3 req派发完成的优化
这个比较简单,在blk_account_io_completion函数(req对应的磁盘数据传输完成中途执行)或者blk_account_io_done函数(req对应的磁盘数据传输完成最后执行),如果磁盘数据传输完成的req有REQ_HIGHPRIO高优先级传输属性则清理掉。
- void blk_account_io_done(struct request *req)
- {
- ..........
- //如果req有高优先级传输属性则清除掉,这个隐藏点点很重要
- if(req->cmd_flags & REQ_HIGHPRIO)
- req->cmd_flags &= ~REQ_HIGHPRIO;
- ..........
- }
- void blk_account_io_completion(struct request *req, unsigned int bytes)
- {
- ..........
- //如果req有高优先级传输属性则清除掉,这个隐藏点点很重要
- if(req->cmd_flags & REQ_HIGHPRIO)
- req->cmd_flags &= ~REQ_HIGHPRIO;
- ..........
- }
3 “IO高优先级”进程优先派发IO 之测试
3.1 有效的测试方法分析
多进程IO抢占造成IO延迟的内核优化与解决方法,已经介绍过了,但该怎么验证该方法有效呢?该优化只有在IO算法队列(elv hash队列、deadline算法的红黑树和fifo队列)有很多req等待派发时才能发挥效果。比如deadline算法fifo队列有60个req等待派发,此时“IO高优先级”进程发送IO请求,因为它有” 高优先级传输”属性,则把它的IO请求req会被放到fifo队列头,并且在fifo队列超时时间是0。这样下次就选择该req派发给磁盘驱动,很快的。否则只能等fifo队列原有60个req派发给磁盘驱动才能轮到派发,这将会造成IO延迟。
所以要验证出效果,需要营造一个IO算法队列有很多req等待派发的场景,该怎么模拟?使用fio是否可行?比如如下命令
fio -filename=/mnt/ext4/fio_test -direct=1 -iodepth 1 -thread -rw=randrw -rwmixread=50 -ioengine=psync -bs=4k -size=5G -numjobs=10 -runtime=180
常见修改的几个参数
- direct:绕过cache层,直接读写磁盘IO
- numjobs:线程数
- bs:单次IO读写数据块大小
- size:读写文件的总大小
- iodepth:IO深度,一次提交的IO个数
- ioengine:IO引擎,如libaio、paync、sync。如果是libaio引擎,iodepth设置为10,则测试时会保持IO算法队列的req个数+派发给磁盘驱动但未传输完成的req个数保持为10。
如下是fio实际测试时监控到的数据
显然libaio引擎模式才能是inflight的req保持在10,但这10个req=在IO算法队列的req+派发给磁盘驱动但未传输完成的req。并没有实现我们预期的目标:使IO算法队列的req个数保持在10。psync和sync引擎模式iodepth=10似乎并不起作用。
在内核里跟踪libaio引擎fio测试过程,发现是直接执行blk_queue_bio派发req,函数流程是blk_queue_bio-> __blk_run_queue-> __blk_run_queue_uncond-> scsi_request_fn(一次只向IO算法队列添加一个req,然后就从IO算法队列取出req派发给磁盘驱动),并不是req plug模式。那到底有什么好方法能快速模拟出“IO算法队列的req个数保持在10甚至更多”的场景呢?优先考虑 req plug派发模式,一次向进程plug->list添加N多个req,然后执行blk_flush_plug_list函数将plug->list链表上的N多个req先添加到IO算法队列(elv hash队列与deadline的红黑树和fifo队列),最后执行scsi_request_fn函数将IO算法队列上的req依次派发给磁盘驱动。这种情况正好可以出现“IO算法队列的req个数保持在10甚至更多”,但是不太好模拟,如下是实际测试内核相关函数抓的打印:
in_flight_real是我新增的一个类似in_flight的统计变量,它只表示在IO算法队列的req个数,不包含已经派发给磁盘驱动但还没传输完成的req个数,截图显示它最大13。但这种req plug模式不好模拟,要想快速测试效果该怎么办?想到了脏页回写这种重IO场景,脏页回写不是req plug模式,实际测试也是blk_queue_bio-> __blk_run_queue-> __blk_run_queue_uncond-> scsi_request_fn这样,一次只向IO算法队列添加一个req,然后就从IO算法队列取出req派发给磁盘驱动。但是派发req的速度超级快,运行如下命令制作频繁的脏页回写场景:
- echo 5000 > /proc/sys/vm/dirty_expire_centisecs
- while true ;do dd if=/dev/zero of=/mnt/ext4/fio_test bs=1M count=4096;done
-
//执行 cat /sys/block/sdb/inflight 看下inflight 的req个数
- [root@localhost test]# cat /sys/block/sdb/inflight
- 0 139 107
- [root@localhost test]# cat /sys/block/sdb/inflight
- 0 151 119
- [root@localhost test]# cat /sys/block/sdb/inflight
- 0 153 121
可以看到,第2列表示的write req可以达到139、151、153,第3列的是什么?是sdb块设备“IO算法队列的req”个数(不包含已经派发给磁盘驱动但还没传输完成的req),就是in_flight_real这个变量,相关源码还是可以看https://github.com/dongzhiyan-stack/kernel_brainstorming。总之,脏页回写很容易模拟“IO算法队列的req”个数很多的场景,我们的“解决多进程IO抢占造成的IO延迟”内核block层优化算法终于可以轻松验证了。
然后跑我写测IO读写文件测试demo
- cat test.c
- #define FILE_SIZE (1024*1024*1)
- int main(int argc,char *argv[ ])
- {
- int fd,ret;
- unsigned char *p;
- char buf[100];
- struct timeval start;
- struct timeval end;
- long dx;
- int one_size = 1024*1024*1;
- int write_count = 0;
- snprintf(buf,100,"test_file_1");
- fd = open(buf,O_RDWR|O_SYNC|O_CREAT);
- p = (unsigned char*)malloc(FILE_SIZE);
- while(1)
- {
- ret = lseek(fd,0,SEEK_SET);
- gettimeofday(&start,NULL);
- do{
- ret = write(fd,p,one_size);
- write_count ++;
- }while(one_size * write_count <= FILE_SIZE);
- gettimeofday(&end,NULL);
- dx = end.tv_sec *1000000 + end.tv_usec - (start.tv_sec *1000000 + start.tv_usec);
- if(dx/1000 >= 100)
- printf("io time %dms\n",dx/1000);
- sleep(1);
- }
- free(p);
- close(fd);
- return 0;
- }
- gcc –o test test.c
其实就是频繁调用write系统调用写文件,如果耗时超过100ms则打印出来(正常情况write耗时只有几十ms),省略了异常判断。./test运行这个测试程序,在另一个终端运行上边的while true ;do dd if=/dev/zero of=/mnt/ext4/fio_test bs=1M count=4096;done命令,可以发现write耗时竟然突增在10s左右,IO抢占很严重呀。但是执行如下命令把test进程设置为“IO高优先级”进程,
- [root@localhost ~]# cd /sys/fs/cgroup/blkio
- [root@localhost ~]# mkdir test
- [root@localhost ~]# cd test
- [root@localhost blkio]# lsblk
- NAME MAJ:MIN RM SIZE RO TYPE MOUNTPOINT
- sdb 8:16 0 50G 0 disk /mnt/ext4
- [root@localhost blkio]# echo “8:16 1” > blkio.throttle.io_priority_control
- [root@localhost blkio]# echo test进程ID >/sys/fs/cgroup/blkio/tasks
test进程write耗时保持在200ms,偶尔会有耗时3s左右。这个测试终于说明“多进程IO抢占造成IO延迟的内核block优化方法”是有效果的。有同学可能想问,为什么while true ;do dd if=/dev/zero of=/mnt/ext4/fio_test bs=1M count=4096;done命令可以轻松模拟出IO算法队列的req 有很多个场景呢?看下scsi_request_fn函数源码:
- static void scsi_request_fn(struct request_queue *q)
- {
- for (;;) {
- //把IO算法队列req先添加到q->queue_head链表头(默认是链表尾,IO高优先级进程是链表头),然后从q->queue_head链表头取出待派发的req,针对req的信息分配SCSI命令结构体cmd并赋值
- req = blk_peek_request(q);
- //如果向磁盘驱动派发的req太多,满了,停止派发,break
- if (!scsi_dev_queue_ready(q, sdev)){
- break;
- }
- //把req从q->queue_head链表剔除掉
- blk_start_request(req);
- //发送SCSI命令,真正开始传输req对应的磁盘数据
- rtn = scsi_dispatch_cmd(cmd);
- }
- }
重点就是scsi_dev_queue_ready()函数,我的判断是如果向磁盘驱动派发的req太多,该函数就会返回false,if (!scsi_dev_queue_ready(q, sdev))成立,则break跳出,就不能再执行scsi_dispatch_cmd()继续向磁盘驱动派发req了。磁盘驱动传输req对应的磁盘数据是要花一定时间的,如果短时间内疯狂执行scsi_request_fn()->scsi_dispatch_cmd()函数向磁盘驱动派发req,很快会把磁盘驱动req队列打满,就不能再向磁盘驱动派发req了。直接从scsi_request_fn()函数for循环break跳出,停止派发。
此时脏页回写进程还会前赴后继执行blk_queue_bio->add_acct_request->__elv_add_request向IO算法队列添加req,然后执行__blk_run_queue-> __blk_run_queue_uncond-> scsi_request_fn,因为已经向磁盘驱动派发太多req而if (!scsi_dev_queue_ready(q, sdev))成立,只能还是break跳出,不能scsi_request_fn()->scsi_dispatch_cmd()函数向磁盘驱动派发req。IO算法队列的req(不包含已经派发给磁盘驱动但还没传输完成的req)就会一直增加,测试时最多可以达到120多个。
此时,被标记为“IO高优先级”的进程读写文件进行IO传输。执行blk_queue_bio->add_acct_request->__elv_add_request或者blk_flush_plug_list->__elv_add_request,把它的req添加到IO算法队列时,因为“高优先级传输”属性,就会被添加到deadline算法fifo队列头,超时时间是0。之后执行scsi_request_fn->blk_peek_request,把该req从IO算法队列(elv的hash队列、deadline算法红黑树和fifo队列)添加到q->queue_head链表时,同样因为“高优先级传输”属性,则会把该req添加到q->queue_head链表头,而不是链表尾。再接着,就从 q->queue_head链表头取出该req,从blk_peek_request()返回这个req。最后执行scsi_request_fn()->scsi_dispatch_cmd()派发这个req给磁盘驱动。总之,被标记为“IO高优先级”的进程的IO请求会以最快的速度被派发给磁盘驱动。
思考,正如前文所说,我已经分析到磁盘驱动也有一个队列,req派发给磁盘驱动也是先添加到“磁盘驱动的队列”(实际是以SCSI的cmd形式)。这里可以优化:当脏页回写向磁盘驱动队列派发太多req时,即便if (!scsi_dev_queue_ready(q, sdev))成立,test进程依然可以向磁盘驱动派发req,并且把“高优先级属性传输”的req移动到磁盘驱动的队列头,真正实现最快速度派发“高优先级属性传输”的req给磁盘驱动,并且完成IO数据传输,这样基本不会有IO延迟了吧?理论上是可以实现的!
3.2 q->queue_lock锁分析
实际调试发现q->queue_lock锁真是无处不在,IO请求的发送、进程plug 模式派发req、真正启动磁盘IO数据传输、磁盘数据传输完成都见到。下边将这些代码全部列下:
- //submit_bio->generic_make_request->blk_queue_bio发送IO请求
- void blk_queue_bio(struct request_queue *q, struct bio *bio)
- {
- spin_lock_irq(q->queue_lock);///上锁
- el_ret = elv_merge(q, &req, bio);
- if (el_ret == ELEVATOR_BACK_MERGE) {
- if (bio_attempt_back_merge(q, req, bio)) {
- elv_bio_merged(q, req, bio);
- free = attempt_back_merge(q, req);
- if (!free)
- elv_merged_request(q, req, el_ret);
- else
- __blk_put_request(q, free);
- goto out_unlock;
- }
- }
- ......
- req = get_request(..bio..)->__get_request->spin_unlock_irq(q->queue_lock)///解锁
- ......
- plug = current->plug;
- if (plug) {
- list_add_tail(&req->queuelist, &plug->list);
- blk_account_io_start(req, true);
- }else {
- spin_lock_irq(q->queue_lock);///上锁
- //把req添加到IO算法队列
- add_acct_request(q, req, where); ->__elv_add_request(q, rq, where);
- __blk_run_queue(q);
- out_unlock:
- spin_unlock_irq(q->queue_lock);///解锁
- }
- }
- //进程plug模式派发req
- void blk_flush_plug_list(struct blk_plug *plug, bool from_schedule)
- {
- struct request_queue *q;
- q = NULL;
- local_irq_save(flags);//关闭本地中断
- while (!list_empty(&list)) {
- rq = list_entry_rq(list.next);
- if (rq->q != q) {//第一次循环成立
- if (q)
- queue_unplugged(q, depth, from_schedule);
- q = rq->q;
- depth = 0;
- spin_lock(q->queue_lock);///上锁
- }
- //把进程plug->list链表上的req发送到IO算法队列
- __elv_add_request(q, rq, ELEVATOR_INSERT_SORT_MERGE);
- depth++;
- }
- if (q)
- queue_unplugged(q, depth, from_schedule);
- local_irq_restore(flags);//开本地中断
- }
- //进程plug模式派发req,真正启动磁盘数据传输
- static void queue_unplugged(struct request_queue *q, unsigned int depth,
- bool from_schedule)
- {
- if (from_schedule)
- blk_run_queue_async(q);
- else
- __blk_run_queue(q); //启动磁盘数据传输
- spin_unlock(q->queue_lock);///解锁
- }
- //__blk_run_queue执行流程
- __blk_run_queue->__blk_run_queue_uncond->scsi_request_fn
- //从IO算法队列依次取出req并启动该req对应的磁盘数据
- static void scsi_request_fn(struct request_queue *q)
- {
- for (;;) {
- //把IO算法队列req先添加到q->queue_head链表头(默认是链表尾,IO高优先级进程是链表头),然后从q->queue_head链表头取出待派发的req,针对req的信息分配SCSI命令结构体cmd并赋值
- req = blk_peek_request(q);
- //如果向磁盘驱动驱动派发的req太多,break
- if (!scsi_dev_queue_ready(q, sdev)){
- break;
- }
- //把req从q->queue_head链表剔除掉
- blk_start_request(req);
- spin_unlock_irq(q->queue_lock);///解锁
- cmd = req->special;
- ………
- //发送SCSI命令,真正开始传输数据
- rtn = scsi_dispatch_cmd(cmd);
- spin_lock_irq(q->queue_lock);///上锁
- }
- }
- //req对应磁盘数据传输产生中断执行,最终执行blk_account_io_done进行IO使用率等统计
- static bool scsi_end_request(struct request *req, int error,unsigned int bytes, unsigned int bidi_bytes)
- {
- spin_lock_irqsave(q->queue_lock, flags);///上锁
- blk_finish_request(req, error); ->blk_account_io_done
- spin_unlock_irqrestore(q->queue_lock, flags);///解锁
- }
可以发现加锁的地方真的很多,一般什么场景需要加锁呢?如blk_queue_bio()函数中bio或者req合并到IO算法队列(elv hash队列、deadline算法红黑树队列和fifo队列)的req;进程执行blk_flush_plug_list函数发送plug->list链表的req到IO算法队列;scsi_request_fn函数从IO算法队列取出req到q->queue_head链表,然后再从q->queue_head链表取出req派发给磁盘驱动等等。基本上,就是对IO算法队列有操作(req合并、req插入、取出req)时,就要执行spin_lock_irq(q->queue_lock)或者spin_lock(q->queue_lock)加锁,操作完再解锁。
scsi_request_fn函数需要特别说明一下。这个函数这样循环操作:从IO算法队列派发req到q->queue_head链表头/尾,然后从q->queue_head链表头取出待派发的req,针对req的信息分配SCSI命令结构体cmd并赋值。然后执行scsi_dispatch_cmd()函数将cmd信息传递给磁盘驱动就完成了req派发给磁盘驱动,循环……..( 当然等req对应磁盘数据传输完成会执行软中断)。正常情况,这个循环会一直执行,直到IO算法队列没有req,scsi_request_fn->blk_peek_request返回的req是NULL,则break结束循环;或者向磁盘驱动连续派发req太多导致if (!scsi_dev_queue_ready(q, sdev))成立,也会break结束循环。两种情况都会停止向磁盘驱动派发req,从scsi_request_fn函数返回。接下来就看哪个进程执行到scsi_request_fn函数了,谁执行谁接着执行本段开头的循环。反正IO算法队列或者q->queue_head链表就那些req,哪个进程取出派发给磁盘驱动都一样。
像进程plug模式派发req,一次性就会向IO算法队列派发N多个req。这样scsi_request_fn函数就会循环派发req,这个过程大部分时间是spin_lock_irq(q->queue_lock)加锁状态。另外还要提一下脏页回写,它一次只向IO算法队列添加一个req,但是短时间内向磁盘驱动派发太多req,导致if (!scsi_dev_queue_ready(q, sdev))成立,短时间内就无法再向磁盘派发req。这样后续脏页回写进程只会将req添加到IO算法队列,IO算法队列的req越来越多,这个过程大部分时间也是spin_lock_irq(q->queue_lock)持有锁。因为这个过程频繁向IO算法队列添加req,为了保证队列不受其他进程篡改影响,肯定要加锁。
可以想象,如果一个进程在scsi_request_fn函数循环取出req派发给磁盘驱动,spin_unlock_irq(q->queue_lock)释放锁的时间很短,大部分时间是spin_lock_irq(q->queue_lock)加锁状态。此时其他进程submit_bio-> blk_queue_bio发送IO请求,估计竞争q->queue_lock锁要花费不少时间。实际测试也是这种情况。如下是实际测试的截图,fio和test_delay两个进程抢占派发req,竞争q->queue_lock锁。
看来,多进程派发req时,对q->queue_lock锁的竞争是很明显的,这也许是block多队列mq开发的主要原因吧。