Linux kernel | 块IO子系统请求处理过程、multi-queue框架、请求合并处理blk_plug

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(&current->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的排序归并操作。

outside_default.png

(图源csdn-geshifei,处理bio的路径)

由于“栈式块设备”(例如md和dm设备)的存在和堆栈大小限制,为防止在栈式块设备执行过程中可能出现的问题,在一个时刻只允许进程有一个submit_bio处于active状态,在实现上,使用了一个bio等待处理链表bio_list,当该进程bio_list处于active状态时,后续的bio直接被链入链表,在当前bio处理完成后顺序逐个地处理。

submit_bio_noacct与bio_list有关的执行过程是:

  1. 首先判断bio_list是否处于激活状态,如是,直接链入bio_list并返回,反之进入第二步;

  2. 无论最后由__submit_bio_noacct_mq或__submit_bio_noacct继续进行,都会新建一个bio_list表明当前的submit_bio正处于活动状态,让后来的bio有机会链入链表;

  3. 从链表中取出bio并处理;

  4. 没有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后,仅有一个层次的一个请求队列:

outside_default.png

多核体系下,单队列的缺点尤为突出:

  1.  请求队列锁竞争:单队列机制下使用spinlock来同步IO请求队列的访问,每次想往请求队列里插入或删除IO请求,必须先获取此锁,要操作请求队列、对IO排序和调度时,也需要获取此锁,这一系列的操作继续之前,必须先请求锁,在高IOPS场景,多个线程同时提交IO请求的情况下,锁的竞争非常剧烈,带来不可忽视的软件开销。

    outside_default.png

    从上图可以看到,在单队列机制高IOPS场景下,约80%的cpu时间耗费在锁获取上。

  2. 硬件中断:高的IOPS意味着高的中断数量,在多数情况下,完成一次IO需要两次中断,一次是存储器件触发的硬件中断,另一次是cpu核间中断。

  3. 远端内存访问:如果提交IO请求的cpu不是接收硬件中断的cpu且两个cpu中间没有共享缓存,那么获取请求队列锁的过程中,还存在远端访问的问题。

综上,实验证明,核数越多,单队列性能越差。

outside_default.png

就像单行线拥挤了可以修更宽敞的路一样,针对单队列框架下存在的问题,多队列框架被提出:

outside_default.png

多队列框架采用了两层队列,将单个请求队列锁的竞争分散到多个队列中,极大地提高了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时间比例大大减小:

outside_default.png

性能上,mq也更加接近raw设备的性能:

outside_default.png

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操作都会走到这个函数。

outside_default.png

在这个函数中,将bio转化为request后,会根据不同的情况(if-else结构),进行不同的操作:

  1. 如果本次io是flush、fua类型,执行此路径,此类bio需要尽快分发器件处理,不能缓存在上层队列中,直接插入队列后立即启动。

    outside_default.png

  2. 如果启用了请求合并plug,且满足以下之一:硬件队列长度为1或存储设备定义了自己的commit_rqs函数或存储设备是旋转的机械磁盘(QUEUE_FLAG_NONROT标记位置)或队列为sbitmap_shared,这个场景针对慢速设备,会将request暂存进入自己的plug list,然后再进行调度,对于慢速设备而言,会较大地提高性能。

    outside_default.png

  3. 如果存在IO调度器,交由IO调度器去处理。

    outside_default.png

  4. 如果启用了请求合并plug,且存储设备设置了不允许合并io请求标志位,执行此路径,这个场景下,仅作有限合并,若本次request与plug list中的request可以合并则合并,否则就添加到plug中,接着将plug list中的request发送到下层队列中。从代码上看,由于每一次都下发,plug中属于一个存储设备的request有且只有一个。

    outside_default.png

  5. 如果硬件队列数量大于1且请求是同步的,或者硬件队列不忙,为了充分利用存储器件处理能力,直接下发。

    outside_default.png

  6. 默认执行路径。如果是emmc、ufs这些单队列设备,或者nvme这类多队列设备但异步请求,或者nvme这类多队列设备且同步请求但是硬件队列忙,执行此路径。

由于采用if-else结构,当前面的路径满足后,后面的路径就不会再判断。

outside_default.png

代码分析:

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

  • 2
    点赞
  • 3
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
Linux block IO(块输入输出)是Linux操作系统的IO子系统,用于管理块设备(例如硬盘和SSD)的访问。在多核系统上引入多队列SSD访问是一种优化措施。 传统上,Linux操作系统在处理块设备访问时,使用单个队列(queue)来处理所有IO请求。这种单队列设计对于单核系统来说是合适的,因为只有一个CPU核心可以处理IO请求。然而,在多核系统中,这种设计却成为了性能瓶颈,因为所有的IO请求都必须经过单个队列,即使有多个CPU核心是可用的。 为了解决这个问题,Linux引入了多队列SSD访问功能。这意味着在多核系统上,每个CPU核心都有一个独立的队列来处理IO请求。每个队列可以独立处理IO请求,而不会受到其他队列的干扰。这种设计可以提高系统的并发性和吞吐量。 多队列SSD访问还可以充分利用SSD设备的性能特点。SSD设备通常具有多个通道(channel)和多个闪存芯片(chip),每个通道和芯片都可以同时处理IO请求。通过将IO请求分配给多个队列,可以同时利用多个通道和芯片,从而提高SSD的性能。 在Linux中实现多队列SSD访问需要对内核进行相应的修改和配置。用户可以通过命令和配置文件来设置每个队列的属性和参数,以满足特定场景下的需求。 总之,通过引入多队列SSD访问,Linux在多核系统上可以更好地利用硬件资源,提高系统的性能和吞吐量。这是一个重要的优化措施,可以提高块设备访问的效率和响应速度。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值