Bootstrap

linux内核block层源码优化之缓解容器间(多进程)IO抢占造成的IO延迟

我想从事服务器线上问题排查的同学,都应该遇到过多进程读写文件时,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()增加如下代码

  1. static bool tg_may_dispatch(struct throtl_grp *tg, struct bio *bio,
  2.                             unsigned long *wait)
  3. {
  4.         ........
  5.         /*如果当前进程设置了IO高优先级传输属性bio标记为高优先级*/
  6.         if(tg->io_priority_control != 0){
  7.             bio->bi_flags |= (1 << BIO_HIGHPRIO);
  8.         }
  9.         ........
  10. }

当进程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函数最后增加如下代码:

  1. bool bio_attempt_back_merge(struct request_queue *q, struct request *req,
  2.                             struct bio *bio)
  3. {
  4.     ........
  5.     //如果合并的bio有高优先级传输属性则设置req高优先级,还要清理掉bio的高优先级传输属性
  6.     if(bio->bi_flags & (1 << BIO_HIGHPRIO)){
  7.         req->cmd_flags |= REQ_HIGHPRIO;
  8.         bio->bi_flags &= ~(1 << BIO_HIGHPRIO);
  9.     }
  10. }
  11.     ........
  12. }
  13. bool bio_attempt_front_merge(struct request_queue *q, struct request *req,
  14.                              struct bio *bio)
  15. {
  16.     ........
  17.     //如果合并的bio有高优先级传输属性则设置req高优先级,还要清理掉bio的高优先级传输属性
  18.     if(bio->bi_flags & (1 << BIO_HIGHPRIO)){
  19.         req->cmd_flags |= REQ_HIGHPRIO;
  20.         bio->bi_flags &= ~(1 << BIO_HIGHPRIO);
  21.     }
  22.     ........
  23. }

       这样进程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属性,代码如下:

  1. void blk_queue_bio(struct request_queue *q, struct bio *bio)
  2. {
  3.     ........
  4.     req = get_request(q, rw_flags, bio, 0); //清空一个新的req
  5.     if (IS_ERR(req)) {
  6.             blk_queue_exit(q);
  7.             bio_endio(bio, PTR_ERR(req));   /* @q is dead */
  8.             goto out_unlock;
  9.     }
  10.     if(bio->bi_flags & (1 << BIO_HIGHPRIO)){
  11.         //清空bio的高优先级传输属性,隐藏的关键点
  12.         bio->bi_flags &= ~(1 << BIO_HIGHPRIO);
  13.         //如果bio有高优先级传输属性则设置对应的req高优先级传输
  14.         req->cmd_flags |= REQ_HIGHPRIO;
  15.     }
  16.     ........
  17. }

      接着,如果一个新分配的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函数源码如下:

  1. static void  deadline_add_request(struct request_queue *q, struct request *rq)
  2. {           
  3.         struct deadline_data *dd = q->elevator->elevator_data;
  4.         const int data_dir = rq_data_dir(rq);
  5.         //把req添加到红黑树队列
  6.         deadline_add_rq_rb(dd, rq);
  7.         //deadline算法把req添加到fifo队列,添加到红黑树队列在上边的deadline_add_rq_rb()
  8.         if(rq->cmd_flags & REQ_HIGHPRIO){
  9.             //如果req有高优先级传输属性,则req放入fifo链表头,超时时间0,保证最快被调度派发给驱动
  10.             rq->fifo_time = jiffies;
  11.             list_add(&rq->queuelist, &dd->fifo_list[data_dir]);
  12.         }else{
  13.             rq->fifo_time = jiffies + dd->fifo_expire[data_dir];
  14.             list_add_tail(&rq->queuelist, &dd->fifo_list[data_dir]);
  15.         }
  16. }

   红色部分代码是新增的,作用是:如果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合并后的收尾工作,也是一个优化的重点,如下:

  1. static void   deadline_merged_requests(struct request_queue *q, struct request *req,
  2.                          struct request *next)
  3. {
  4.     if (!list_empty(&req->queuelist) && !list_empty(&next->queuelist)) {
  5.             if (time_before(next->fifo_time, req->fifo_time)) {
  6.                     list_move(&req->queuelist, &next->queuelist);
  7.                     req->fifo_time = next->fifo_time;
  8.             }
  9.     }
  10.     deadline_remove_request(q, next);
  11.     //将next合并到req,next的高优先级传递到req.并且req要放到fifo队列头,会得到优先派发的机会
  12.     if(next->cmd_flags & REQ_HIGHPRIO){
  13.         struct deadline_data *dd = q->elevator->elevator_data;
  14.         const int data_dir = rq_data_dir(req);
  15.         req->fifo_time = jiffies;//req放入fifo链表头,超时时间0,保证最快被调度派发给磁盘驱动
  16.         req->cmd_flags |= REQ_HIGHPRIO;//设置req高优先级
  17.         list_move(&req->queuelist, &dd->fifo_list[data_dir]);
  18.         //如果next这个req有高优先级传输属性,清理掉,它不会参与IO传输,这是唯一清理高优先级属性的机会
  19.         next->cmd_flags &= ~REQ_HIGHPRIO;
  20.     }
  21. }

     红色是新增的代码,因为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 调度算法的核心,修改如下:

  1. static int deadline_dispatch_requests(struct request_queue *q, int force)
  2. {
  3. ...........
  4.     struct request *rq;
  5.     int data_dir;
  6. #define IS_REQ_HIGHPRIO(req) ((req->cmd_flags & REQ_IO_STAT) && (req->cmd_type == REQ_TYPE_FS) && (req->cmd_flags&REQ_HIGHPRIO))
  7. /*取出fifo队列头的read/write req*/
  8.     struct request *r_req = rq_entry_fifo(dd->fifo_list[READ].next);
  9.     struct request *w_req = rq_entry_fifo(dd->fifo_list[WRITE].next);
  10.     if (dd->next_rq[WRITE])
  11.         rq = dd->next_rq[WRITE];
  12.     else
  13.         rq = dd->next_rq[READ];
  14.             
  15.     if(rq && ((w_req && IS_REQ_HIGHPRIO(w_req)) || (r_req && IS_REQ_HIGHPRIO(r_req)))){
  16.         if(w_req && (rq != w_req) && IS_REQ_HIGHPRIO(w_req) && dd->next_rq[WRITE])
  17.             list_move(&rq->queuelist, &w_req->queuelist);
  18.         else if(r_req && (rq != r_req) && IS_REQ_HIGHPRIO(r_req) && dd->next_rq[READ])
  19.             list_move(&rq->queuelist, &r_req->queuelist);
  20.             
  21.         if(w_req && IS_REQ_HIGHPRIO(w_req))
  22.             data_dir = WRITE;
  23.         else
  24.             data_dir = READ;
  25.         goto  dispatch_find_request;
  26.     }
  27.     else if (rq && dd->batching < dd->fifo_batch)
  28.         /* we have a next request are still entitled to batch */
  29.         goto dispatch_request;
  30.     ............
  31.     deadline_move_request(dd, rq);
  32.     return 1;
  33. }

红色部分依然是新增的代码,它的目的是检测出fifo队列头的req有REQ_HIGHPRIO高优先级传输属性,则立即派发。

还有一处优化是deadline_dispatch_requests->deadline_move_to_dispatch函数

  1. void elv_dispatch_add_head(struct request_queue *q, struct request *rq)
  2. {       
  3.     if (q->last_merge == rq)
  4.             q->last_merge = NULL;
  5.     elv_rqhash_del(q, rq);
  6.     q->nr_sorted--;
  7.     q->end_sector = rq_end_sector(rq);
  8.     q->boundary_rq = rq;
  9.     list_add(&rq->queuelist, &q->queue_head);                                                      
  10. }
  11. static inline void
  12. deadline_move_to_dispatch(struct deadline_data *dd, struct request *rq)
  13. {
  14.         struct request_queue *q = rq->q;
  15.         deadline_remove_request(q, rq);
  16.         //如果req有高优先级传输属性,则要把req加入q->queue_head链表头,这样该req会得到优先派发
  17.         if(rq->cmd_flags & REQ_HIGHPRIO)
  18.             elv_dispatch_add_head(q, rq);
  19.         else
  20.             elv_dispatch_add_tail(q, rq);
  21. } 

   看红色新增的代码,它是把从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高优先级传输属性则清理掉。

  1. void blk_account_io_done(struct request *req)
  2. {
  3.     ..........
  4.     //如果req有高优先级传输属性则清除掉,这个隐藏点点很重要
  5.     if(req->cmd_flags & REQ_HIGHPRIO)
  6.         req->cmd_flags &= ~REQ_HIGHPRIO;
  7.     ..........
  8. }
  9. void blk_account_io_completion(struct request *req, unsigned int bytes)
  10. {
  11.     ..........
  12.     //如果req有高优先级传输属性则清除掉,这个隐藏点点很重要
  13.     if(req->cmd_flags & REQ_HIGHPRIO)
  14.         req->cmd_flags &= ~REQ_HIGHPRIO;
  15.     ..........
  16. }

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

常见修改的几个参数

  1. direct:绕过cache层,直接读写磁盘IO
  2. numjobs:线程数
  3. bs:单次IO读写数据块大小
  4. size:读写文件的总大小
  5. iodepth:IO深度,一次提交的IO个数
  6. 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的速度超级快,运行如下命令制作频繁的脏页回写场景:

  1. echo 5000 > /proc/sys/vm/dirty_expire_centisecs
  2. while true ;do dd if=/dev/zero of=/mnt/ext4/fio_test bs=1M count=4096;done
  3. //执行 cat  /sys/block/sdb/inflight 看下inflight req个数

  4. [root@localhost test]# cat /sys/block/sdb/inflight
  5.        0      139      107
  6. [root@localhost test]# cat /sys/block/sdb/inflight
  7.        0      151      119
  8. [root@localhost test]# cat /sys/block/sdb/inflight
  9.        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

  1. cat test.c
  2. #define FILE_SIZE (1024*1024*1)
  3. int main(int argc,char *argv[ ])
  4. {
  5.     int fd,ret;
  6.     unsigned char *p;
  7.     char buf[100];
  8.     struct timeval start;
  9.     struct timeval end;
  10.     long  dx;
  11.     int one_size = 1024*1024*1;
  12.     int write_count = 0;
  13.     snprintf(buf,100,"test_file_1");
  14.     fd = open(buf,O_RDWR|O_SYNC|O_CREAT);
  15.     p = (unsigned char*)malloc(FILE_SIZE);
  16.     while(1)
  17.     {
  18.         ret = lseek(fd,0,SEEK_SET);
  19.         gettimeofday(&start,NULL);
  20.         do{
  21.             ret = write(fd,p,one_size);
  22.             write_count ++;
  23.         }while(one_size * write_count <= FILE_SIZE);
  24.         gettimeofday(&end,NULL);
  25.         dx = end.tv_sec *1000000 + end.tv_usec - (start.tv_sec *1000000 + start.tv_usec);
  26.         if(dx/1000 >= 100)
  27.             printf("io time %dms\n",dx/1000);
  28.         sleep(1);
  29.     }
  30.     free(p);
  31.     close(fd);
  32.     return 0;
  33. }
  34. 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高优先级”进程,

  1. [root@localhost ~]# cd /sys/fs/cgroup/blkio
  2. [root@localhost ~]# mkdir test
  3. [root@localhost ~]# cd test
  4. [root@localhost blkio]# lsblk
  5. NAME   MAJ:MIN RM  SIZE RO TYPE MOUNTPOINT
  6. sdb      8:16   0   50G  0 disk /mnt/ext4
  7. [root@localhost blkio]# echo  “8:16  1” > blkio.throttle.io_priority_control
  8. [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函数源码:

  1. static void scsi_request_fn(struct request_queue *q)
  2. {
  3.     for (;;) {
  4.         //把IO算法队列req先添加到q->queue_head链表头(默认是链表尾,IO高优先级进程是链表头),然后从q->queue_head链表头取出待派发的req,针对req的信息分配SCSI命令结构体cmd并赋值
  5.         req = blk_peek_request(q);
  6.         //如果向磁盘驱动派发的req太多,满了,停止派发,break
  7.         if (!scsi_dev_queue_ready(q, sdev)){
  8.                 break;
  9.         }
  10.         //把req从q->queue_head链表剔除掉
  11.         blk_start_request(req);
  12.         //发送SCSI命令,真正开始传输req对应的磁盘数据
  13.         rtn = scsi_dispatch_cmd(cmd);
  14.     }
  15. }

     重点就是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数据传输、磁盘数据传输完成都见到。下边将这些代码全部列下:

  1. //submit_bio->generic_make_request->blk_queue_bio发送IO请求
  2. void blk_queue_bio(struct request_queue *q, struct bio *bio)
  3. {
  4.     spin_lock_irq(q->queue_lock);///上锁
  5.     el_ret = elv_merge(q, &req, bio);
  6.     if (el_ret == ELEVATOR_BACK_MERGE) {
  7.             if (bio_attempt_back_merge(q, req, bio)) {
  8.                     elv_bio_merged(q, req, bio);
  9.                     free = attempt_back_merge(q, req);
  10.                     if (!free)
  11.                             elv_merged_request(q, req, el_ret);
  12.                     else
  13.                             __blk_put_request(q, free);
  14.                     goto out_unlock;
  15.             }
  16.     }
  17.     ......
  18.     req = get_request(..bio..)->__get_request->spin_unlock_irq(q->queue_lock)///解锁
  19.     ......
  20.     plug = current->plug;
  21.     if (plug) {
  22.         list_add_tail(&req->queuelist, &plug->list);
  23.         blk_account_io_start(req, true);
  24.     }else {
  25.             spin_lock_irq(q->queue_lock);///上锁
  26.               //把req添加到IO算法队列
  27.             add_acct_request(q, req, where); ->__elv_add_request(q, rq, where);
  28.             __blk_run_queue(q);
  29. out_unlock:
  30.             spin_unlock_irq(q->queue_lock);///解锁
  31.     }
  32. }
  33. //进程plug模式派发req
  34. void blk_flush_plug_list(struct blk_plug *plug, bool from_schedule)
  35. {
  36.     struct request_queue *q;
  37.     q = NULL;
  38.     local_irq_save(flags);//关闭本地中断
  39.     while (!list_empty(&list)) {
  40.         rq = list_entry_rq(list.next);
  41.         if (rq->q != q) {//第一次循环成立
  42.             if (q)
  43.                 queue_unplugged(q, depth, from_schedule);
  44.             q = rq->q;
  45.             depth = 0;
  46.             spin_lock(q->queue_lock);///上锁
  47.         }
  48. //把进程plug->list链表上的req发送到IO算法队列
  49.         __elv_add_request(q, rq, ELEVATOR_INSERT_SORT_MERGE);
  50.         depth++;
  51.     }
  52.     if (q)
  53.         queue_unplugged(q, depth, from_schedule);
  54.     local_irq_restore(flags);//开本地中断
  55. }
  56. //进程plug模式派发req,真正启动磁盘数据传输
  57. static void queue_unplugged(struct request_queue *q, unsigned int depth,
  58.                             bool from_schedule)
  59. {
  60.     if (from_schedule)
  61.         blk_run_queue_async(q);
  62.     else
  63.         __blk_run_queue(q); //启动磁盘数据传输
  64.     spin_unlock(q->queue_lock);///解锁
  65. }
  • //__blk_run_queue执行流程
  • __blk_run_queue->__blk_run_queue_uncond->scsi_request_fn
  • //从IO算法队列依次取出req并启动该req对应的磁盘数据
  1. static void scsi_request_fn(struct request_queue *q)
  2. {
  3.     for (;;) {
  4.         //把IO算法队列req先添加到q->queue_head链表头(默认是链表尾,IO高优先级进程是链表头),然后从q->queue_head链表头取出待派发的req,针对req的信息分配SCSI命令结构体cmd并赋值
  5.         req = blk_peek_request(q);
  6.         //如果向磁盘驱动驱动派发的req太多,break
  7.         if (!scsi_dev_queue_ready(q, sdev)){
  8.                 break;
  9.         }
  10.         //把req从q->queue_head链表剔除掉
  11.         blk_start_request(req);
  12.         spin_unlock_irq(q->queue_lock);///解锁
  13.         cmd = req->special;
  14. ………
  15.         //发送SCSI命令,真正开始传输数据
  16.         rtn = scsi_dispatch_cmd(cmd);
  17.         spin_lock_irq(q->queue_lock);///上锁
  18.     }
  19. }
  20. //req对应磁盘数据传输产生中断执行,最终执行blk_account_io_done进行IO使用率等统计
  21. static bool scsi_end_request(struct request *req, int error,unsigned int bytes, unsigned int bidi_bytes)
  22. {
  23.     spin_lock_irqsave(q->queue_lock, flags);///上锁
  24.     blk_finish_request(req, error);  ->blk_account_io_done
  25.     spin_unlock_irqrestore(q->queue_lock, flags);///解锁
  26. }

      可以发现加锁的地方真的很多,一般什么场景需要加锁呢?如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开发的主要原因吧。

;