iostat IO统计原理linux内核源码分析----基于单通道SATA盘

先上一个IO发送submit_bio流程图,本文基本就是围绕该流程讲解。
在这里插入图片描述

内核版本 3.10.96
详细的源码注释:https://github.com/dongzhiyan-stack/kernel-code-comment

1 iostat基本知识与涉及的内核数据结构

在排查IO问题时,iostat 命令还是挺常用的。

[root@localhost ~]# iostat -dmx 1
Device:           rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
sda               0.00     0.01    0.28    0.14     0.01     0.00    53.54     0.01   20.43   15.73   29.62   3.98   0.17
sdb               0.00     0.00    0.00    0.00     0.00     0.00    32.20     0.00    5.54    6.04    4.42   4.62   0.00

像util代表的IO使用率、await代表的IO wait时间、r/s和w/s代表的IOPS、rrqm/s和wrqm/s代表的读写IO合并数、rMB/s和wMB/s代表的读写速率等等,在linux内核是怎么统计的?本文主要讲解IO发送、IO传输、IO传输完成这几个过程中涉及的IO使用率等参数,在内核里是怎么统计计算的。本文讲解的是基于单通道SATA盘,多通道队列mq下篇讲解。

iostat命令实际是读取/proc/diskstats 获取的IO数据,然后计算IO使用率等数据,对应的内核函数是diskstats_show()。

首先需要介绍一下基本数据结构和函数

struct hd_struct 表示一个块设备分区,也表示整个块设备。比如我们把磁盘sda分成sda1,sda2,sda3三个分区,则sda、sda1、sda2、sda3都对应一个struct hd_struct结构。
struct hd_struct结构体中与IO使用率等有关的成员

struct hd_struct {
     int  partno;//块设备主分区时不为0,块设备分区时为0
     unsigned long stamp;//记录当前系统时间jiffies
     atomic_t in_flight[2];// in_flight [rw]表示IO队列中读写请求个数
	struct disk_stats __percpu *dkstats;//记录IO使用率等原始数据
}

struct disk_stats结构体的成员都是IO使用率等有关的数据

struct disk_stats {
	unsigned long  sectors[2];//读写扇区总数,blk_account_io_completion()中更新
	unsigned long  ios[2];//blk_account_io_done()中更新,传输完成的读写IO个数,IOPS
	unsigned long  merges[2];//合并bio的个数,drive_stat_acct(blk_account_io_start)更新合并计数
	unsigned long  ticks[2];//blk_account_io_done()中更新,req传输耗时
	unsigned long  io_ticks;//IO使用率用的这个参数,文章最后详解
	unsigned long  time_in_queue;// IO在队列中的时间,受IO队列中IO个数的影响
};

submit_bio(int rw, struct bio *bio)是我们熟悉IO请求发送函数,它传入的是struct bio结构,表示本次IO读写的磁盘起始地址和大小、IO是读还是写、要读写的数据在内存中的地址等等。接着流程是submit_bio-> generic_make_request->blk_queue_bio,在blk_queue_bio()函数中,引入一个新的结构struct request,感觉之所以再费事引入这个结构,主要目的是为了IO请求合并。我想大家多多少少都应该听说过,在内核block层有个电梯调度算法(elv),可以把多个IO请求合并为一个(IO读写属性要一致),这些IO读写的磁盘扇区地址范围首尾相邻,这样只用进行一次IO磁盘数据传输。比如有三个IO请求IO1、IO2、IO3,读写的磁盘扇区地址范围分别是0–10,10–15,15–25,如果把这3个IO合并成一个新的IO,对应的磁盘扇区地址范围0–25,之后只用进行一次实际的磁盘数据传输就行,否则就得针对IO0、IO1、IO3分别进行3次实际的磁盘数据传输,传输效率就会变低,SATA盘这种机械硬盘受影响更大。这些操作大部分在blk_queue_bio()完成,先来该函数流程图。struct request这里简称为req。

2 submit_bio源码分析

void  blk_queue_bio(struct request_queue *q, struct bio *bio)
{
   /*依次取出进程plug->list链表上已有的rq,判断rq代表的磁盘范围是否挨着本次的bio的范围,
   是则把bio合并到rq合并成功返回真,直接return返回*/
	if (attempt_plug_merge(q, bio, &request_count))
		return;
    
    spin_lock_irq(q->queue_lock);

	/*在elv调度器里查找是否有可以合并的req,这是合并bio的req,是调用具体的IO调度算法函数寻找可以合并的
req。这些req所在的IO调度算法队列是多进程共享的,就像上边所述,进程访问这些队列要加锁,其他进程要等待。
函数返回值 ELEVATOR_BACK_MERGE(前项合并的req)、ELEVATOR_FRONT_MERGE(前项合并)、
ELEVATOR_NO_MERGE(没有找到可以合并的req)*/
	el_ret = elv_merge(q, &req, bio);
	if (el_ret == ELEVATOR_BACK_MERGE) 
	{
	    //后项合并,bio合并到req对应的磁盘空间范围后边
	    bio_attempt_back_merge(q, req, bio);
  /*二次合并,看能否把req合并到IO调度队列里的next req. 传说中的更高阶的合并吧,比如原IO调度算法队
列挨着的req1和req2,代表的磁盘空间范围分别是req1:0--5,req2:11--16,bio的磁盘空间是6--10,则先执行
bio_attempt_back_merge()把bio后项合并到req1,此时req1:0--10,显然此时req1和req2可以进行二次合并,
attempt_back_merge()函数就是这个作用吧,合并成功返回1,否则0*/
	    attempt_back_merge(q, req);
	}
	else if (el_ret == ELEVATOR_FRONT_MERGE) 
	{
	    //back merge合并
	    bio_attempt_front_merge(q, req, bio);
	    //二次合并,看能否把req合并到IO调度队列里的前一个prev req
	    attempt_front_merge(q, req);
	}

//走到这里,应该没有找到可以合并bio的rq,那就针对bio新分配一个rq,里边有可能休眠
	req = get_request(q, rw_flags, bio, GFP_NOIO);
//根据bio的信息对新分配的req初始化
	init_request_from_bio(req, bio);
 
	plug = current->plug;
	 if (plug) {//当前进程的plug链表非NULL
	      //如果plug链表上的req过多,实际测试一般不成立
	      if (request_count >= BLK_MAX_REQUEST_COUNT) {
	      //把plug链表上的req刷入IO调度算法队列,强制启动硬件IO传输 
				blk_flush_plug_list(plug, false);
			}
	     //新分配的req添加到plug->list链表
			list_add_tail(&req->queuelist, &plug->list);
	     //更新主块设备和块设备分区的time_in_queue和io_ticks数据,增加队列flight中req计数
			drive_stat_acct(req, 1);//就是blk_account_io_start
	}else{
	     spin_lock_irq(q->queue_lock);
    //新分配的rq添加到rq队列。更新主块设备和块设备分区的time_in_queue和io_ticks数据,
    //增加队列flight中req计数
			add_acct_request(q, req, where);
	     //启动rq队列,通知底层驱动,新的IO要传输
			__blk_run_queue(q);
	     spin_unlock_irq(q->queue_lock);
	}
}

1执行attempt_plug_merge()函数。在该函数中,判断当前进程current->plug如果非NULL,则循环取出current->plug链表上已经存在的每一个req,执行blk_try_merge()判断本次的bio能否合并到该req。判断标准是二者的磁盘扇区地址范围是否挨着。有两种情况:情况1,假设bio代表的磁盘范围是0–5,req代表的磁盘范围是5–10,bio的磁盘范围在req的前边,这叫front merge。bio合并到req(合并过程见bio_attempt_ front _merge函数),req的代表的磁盘范围空间0–10;情况2,bio代表的磁盘范围是10–15,req代表的磁盘范围是5–10,bio的磁盘范围在req的后边,这叫back merge。bio合并到req(合并过程见bio_attempt_back_merge函数),req的代表的磁盘空间范围是5–15。如果合并成功,blk_queue_bio()函数直接返回,不再向下执行。

attempt_plug_merge函数的示意代码如下:


```c
static bool attempt_plug_merge(struct request_queue *q, struct bio *bio,
			       unsigned int *request_count)
{
 	struct blk_plug *plug;
	struct request *rq;  
	plug = current->plug;
    //依次取出进程plug->list链表上已有的struct request req,然后判断req代表的磁盘范围是否挨着本次
    //的bio的范围,是则把bio合并到req
	list_for_each_entry_reverse(rq, &plug->list, queuelist) {
          //检查bio和rq代表的磁盘范围是否挨着,挨着则可以合并
		el_ret = blk_try_merge(rq, bio);
		if (el_ret == ELEVATOR_BACK_MERGE) {//二者挨着,req向后合并本次的bio
			ret = bio_attempt_back_merge(q, rq, bio);
			if (ret)
				break;
		} else if (el_ret == ELEVATOR_FRONT_MERGE) {//二者挨着,req向前合并本次的bio
			ret = bio_attempt_front_merge(q, rq, bio);
			if (ret)
				break;
		}
     }
}

2 到这里说明bio没有合并到current->plug链表上的req,并且该块设备设置了IO调度算法(本案例以常用的deadline调度算法为例),则执行elv_merge()判断本次的bio能否合并到IO调度算法队列中的req。注意,这里是bio能否合并到IO调度算法队列中的req,合并源与第1步不一样!判断标准也是二者的磁盘扇区地址范围是否挨着,也是分back merge和front merge两种合并。大体过程是:先遍历fifo队列中的req,看bio能否合并到back merge到req。接着遍历rb tree队列中的req,看bio能否合并到front merge合并到req。fifo队列和rb tree队列是deadline调度算法用的两种数据结构,都是保存req的队列,fifo队列是以req代表的磁盘扇区起始地址为key,而构成的一个链表。从该链表中取出的req,如果req代表的起始扇区地址加上磁盘扇区大小等于bio的磁盘扇区起始地址,req start sector+req sectors=bio start sector,则bio合并到req的后边。rb tree队列是以req代表的磁盘扇区起始地址进行排序,从该队列中取出的req,如果req的起始扇区地址等于bio的磁盘扇区起始地址加上磁盘扇区大小,即bio start sector+bio sectors= req start sector,则bio合并到req的前边。这里,注释代码里提到了二次合并,大体意思是bio合并到req后,req对应的磁盘空间范围扩大了,又与IO算法队列里的其他req代表的磁盘空间范围挨着了,这样就可以进一步合并,源码里有较为详细的举例,这里就不再介绍了。

3 如果执行elv_merge()后判断新的bio能不能合并到IO调度算法队列中已经存在的req,则执行get_request()分配一个新的struct req结构,接着执行init_request_from_bio()用bio的数据对req初始化。

4 接着分两种情况,当前进程是否使用plug队列,即blk_queue_bio()函数判断current->plug是否NULL。如果非NULL,则执行先执行list_add_tail(&req->queuelist, &plug->list)把当前req放入plug链表,然后执行drive_stat_acct(高版本内核换成blk_account_io_start)函数,进行一些IO使用率数据的统计。在该函数里,重点执行part_round_stats()更新struct disk_stats结构的time_in_queue和io_ticks两个变量、struct hd_struct的stamp变量。然后执行part_inc_in_flight(),使struct hd_struct的in_flight [rw]加1,表示IO队列中新增了一个IO请求。time_in_queue、io_ticks、stamp、in_flight [rw]这几个变量前文有解释。

下方是drive_stat_acct()、part_round_stats()、part_inc_in_flight()三个函数的源码简单解释。

static void drive_stat_acct(struct request *rq, int new_io) //高版本函数名字是 blk_account_io_start
{
	// new_io为0,表示发生了bio合并,说明新的bio合并到了req,只用增加IO合并计数merges则返回
	if (!new_io) { 
	part = rq->part;
	//增加IO合并数到struct disk_stats结构的merges变量
		   part_stat_inc(cpu, part, merges[rw]); 
	}else {
	     //更新主块设备和块设备分区的time_in_queue和io_ticks数据
			part_round_stats(cpu, part);
	      //有一个新的req加入队列了,增加flight计数
			part_inc_in_flight(part, rw);
			rq->part = part;
	}
}
//有一个新的req加入队列了,增加req个数flight
static  inline void part_inc_in_flight(struct hd_struct *part, int rw)
{
    //增加当前块设备分区的req个数flight
	atomic_inc(&part->in_flight[rw]);
	if (part->partno) //增加当前块设备主分区的req个数flight,不用管
		atomic_inc(&part_to_disk(part)->part0.in_flight[rw]);
}

//更新主块设备和块设备分区的time_in_queue和io_ticks计数,并更新part->stamp 为当前系统时间jiffies
void part_round_stats(int cpu, struct hd_struct *part)
{
	unsigned long now = jiffies;

	if (part->partno)//更新块设备主分区io_ticks计数,并更新part->stamp 为当前时间jiffies,不用管
		part_round_stats_single(cpu, &part_to_disk(part)->part0, now);
    
     //更新当前要读写的块设备分区的io_ticks,并更新part->stamp 为当前时间jiffies
	part_round_stats_single(cpu, part, now);
}

//更新主块设备和块设备分区的time_in_queue和io_ticks计数,并更新part->stamp 为当前系统时间jiffies
static void part_round_stats_single(int cpu, struct hd_struct *part,
				    unsigned long now)
{
    //时间差必须大于一个jiffies,否则不更新time_in_queue、io_ticks、part->stamp。就是说每两次执行
    //part_round_stats_single函数时间间隔不能太短,避免统计的太频繁。
	if (now == part->stamp)
		return;

    //in_flight 不为0,说明IO队列有req,则更新time_in_queue和io_ticks两个IO计数
	if (part_in_flight(part)) {
		__part_stat_add(cpu, part, time_in_queue,
				part_in_flight(part) * (now - part->stamp));
		__part_stat_add(cpu, part, io_ticks, (now - part->stamp));
	}
    //更新part->stamp 为当前时间
	part->stamp = now;
}

5 如果current->plug为NULL,则执行add_acct_request(),该函数中执行drive_stat_acct(高版本内核换成blk_account_io_start)函数,更新time_in_queue、io_ticks、stamp 、in_flight这几个IO使用率有关的使用计数。接着add_acct_request()中执行__elv_add_request()把新的req加入IO算法队列。回到blk_queue_bio函数,接着执行__blk_run_queue()强制启动磁盘控制器,进行磁盘硬件IO数据传输,之后就能磁盘控制器传输完数据,执行硬中断和软中断函数,唤醒等待IO数据传输完成而休眠的进程。__elv_add_request()涉及到把req加入IO调度算法队列的很多细节,有机会另外写文章再讲解。但是__blk_run_queue()函数还有必要介绍的,它负责调用磁盘控制器的启动IO数据传输函数。

3 __blk_run_queue() 启动IO数据传输和struct bio_vec结构分析

__blk_run_queue()函数的函数调用流程是,__blk_run_queue ->__blk_run_queue_uncond->scsi_request_fn。

static void scsi_request_fn(struct request_queue *q)
{
	struct scsi_device *sdev = q->queuedata;
	struct Scsi_Host *shost;
	struct scsi_cmnd *cmd;
	struct request *req;

for (;;) {
  /*1 循环执行__elv_next_request(),从q->queue_head队列取出待进行IO数据传输的req
  2 分配一个struct scsi_cmnd *cmd,使用req对cmd进行部分初始化cmd->request=req,req->special = cmd,
    还有cmd->transfersize传输字节数、cmd->sc_data_direction DMA传输方向。
 3 先遍历req上的每一个bio,再得到每个bio的bio_vec,把bio对应的文件数据在内存中的首地址
    bvec->bv_pag+bvec->bv_offset写入scatterlist。catterlist是磁盘数据DMA传输有关的数据结构,
    scatterlist保存到bidi_sdb->table.sgl,bidi_sdb是req的struct scsi_data_buffer成员。*/
		req = blk_peek_request(q);
		if (!req || !scsi_dev_queue_ready(q, sdev))
			break;
           …………
		//blk_queue_start_tag()中调用blk_start_request(),表示开始传输IO数据了。
		if (!(blk_queue_tagged(q) && !blk_queue_start_tag(q, req)))
			blk_start_request(req);
		sdev->device_busy++;
         …………
      //cmd和req已经相互赋值过了,包含了本次的SCSI命令
		cmd = req->special;
        …………
    	//发送SCSI  IO传输命令
		rtn = scsi_dispatch_cmd(cmd);
}
}
void blk_start_request(struct request *req)//表示要启动底层硬件传输了
{
    //把req从队列剔除
	blk_dequeue_request(req);
	req->resid_len = blk_rq_bytes(req);
	if (unlikely(blk_bidi_rq(req)))
		req->next_rq->resid_len = blk_rq_bytes(req->next_rq);
     //req->timeout和req->deadline赋值,把req插入q->timeout_list链表,启动request_queue->timeout
     //定时器
	blk_add_timer(req);
}

scsi_request_fn()的实际调用流程还是很复杂的,重点应该是blk_peek_request()函数。这个函数大体分析了一下,有一些地方没有搞清楚,分析的可能有问题。大体意思应该是,分配一个struct scsi_cmnd
*cmd结构,该结构负责与磁盘控制器驱动打交道,包含本次要传输IO数据在内存中的地址,传输数据量等等。由于磁盘控制器实际传输数据使用的是DMA,DMA负责把从IO数据在内存中的地址取出数据,搬运到磁盘控制器有关的寄存器(这个没看到代码,推测),不用CPU参与。这里以以IO写为例,IO读则应是DMA把本次要读取的数据从磁盘控制有关的寄存器搬运到bio指定的内存地址。DMA传输数据又有个要求,必须是从一片连续的物理内存中搬运到指定的内存或者寄存器,但是req对应的多个bio的磁盘数据在内存中的地址,一般都不会是连续的。这里又要引入一个结构,struct bio_vec,表示bio对应的磁盘数据在内存中的一片地址,一个struct bio_vec表示一个page。

比如要进程在内存 1K–2K 和5K–8K保存了本次要写入的文件数据,要写入的磁盘空间是2K–6K,生成一个bio请求bio1。则要生成两个bio_vec结构:bio_vec1和bio_vec2,bio_vec1代表内存1k–2k这片空间,bio_vec2代表5K–8K这片内存空间,bio1对应bio_vec1和bio_vec2。并且进程还在内存空间8K–12k保存了文件数据,要写入磁盘空间的是6k–10k,则又生成了一个bio请求bio2,还生成一个bio_vec结构bio_vec3,bio2对应bio_vec3。由于bio1传输的IO磁盘空间(2K–6K)与bio2传输的IO磁盘空间(6k–10k)临近,二者就合并到了一个req。

最后在磁盘控制器在进行IO数据传输时,并不管bio1或者bio2,而是要从通过bio1和bio2找到bio_vec1、bio_vec2、bio_vec3找到实际要传输的IO数据在内存中的地址,要传输的数据量。而每次DMA传输IO数据又要求要传输数据的内存空间是连续的,所以一次肯定传输不完bio_vec1、bio_vec2、bio_vec3这3片内存数据,这里又引入了一个新的结构struct scatterlist,简单来说它负责组织这种不连续的物理内存的DMA数据传输,blk_peek_request()函数除了分配struct scsi_cmnd结构表示本次传输的SCSI命令,还调用建立bio_vec1、bio_vec2、bio_vec3这3片不连续的内存的内存映射。struct scatterlist、struct scsi_cmnd和req都是通过成员相互连续的。

通过以上分析,有种被骗的感觉,之前觉得req可以保证IO数据一次性传输完,传输完产生一次中断,然后唤醒等待IO数据传输的进程就一切搞定。但是现在看来,由于bio的对应的要传输的磁盘数据在内存中的地址不连续,得传输很多次才能完成呀,也就是要产生多次中断,才能最终传输完req对应的bio磁盘数据呀。我感觉,就看req所有的bio对应的磁盘数据保存的内存空间,分成几片,就要DMA传输几次,然后产生对应次数的中断,最终才能传输完所有的数据。这点是猜测的,涉及的源码后续也得再研究一下。

4 进程current->plug链表与req的发送过程讲解

关于进程current->plug链表,涉及到了req的两种发送方式,其中还是有东西要讲解的。
第一种情况:

submit_bio(rw, &bio)
wait_for_completion(&complete)

进程执行submit_bio后,因为current->plug链表为NULL,所以blk_queue_bio()函数最后,执行add_acct_request-> __elv_add_request 把新的req添加到IO算法队列,然后直接执行__blk_run_queue启动磁盘硬件IO数据传输了。这里我有个疑问,如果block没有设置IO调度算法,那req该怎么添加到IO算法队列?????要研究一下。

第二种情况:

blk_start_plug(&plug)
journal_submit_data_buffers(journal, commit_transaction,write_op)
blk_finish_plug(&plug)

blk_start_plug函数会设置current->plug链表,然后调用journal_submit_data_buffers()函数。该函数会大量调用submit_bio-> blk_queue_bio最后,因为current->plug非NULL,则执行list_add_tail(&req->queuelist, &plug->list)把req添加到plug->list链表。接着执行blk_finish_plug函数。

void blk_finish_plug(struct blk_plug *plug)
{
	blk_flush_plug_list(plug, false);
	if (plug == current->plug)
		current->plug = NULL;
}
void blk_flush_plug_list(struct blk_plug *plug, bool from_schedule)
{
LIST_HEAD(list);

	list_splice_init(&plug->list, &list);
//对plug链表上的req排序,应该是按照每个req的起始扇区地址排序,起始扇区小的排在前
	list_sort(NULL, &list, plug_rq_cmp);

local_irq_save(flags);//整个发送过程关中断

    //依次取出进程plug链表上的req依次插入IO调度算法队列上
	while (!list_empty(&list)) {
          //取出req
		rq = list_entry_rq(list.next);
         //从plug链表删除req
		list_del_init(&rq->queuelist);

		//如果req有REQ_FLUSH | REQ_FUA属性
		if (rq->cmd_flags & (REQ_FLUSH | REQ_FUA))
			__elv_add_request(q, rq, ELEVATOR_INSERT_FLUSH);
		else//否则,在这里把每一个req插入到IO调度算法队列里,尝试与IO算法队列上的req合并
			__elv_add_request(q, rq, ELEVATOR_INSERT_SORT_MERGE);
      }
// queue_unplugged-> __blk_run_queue 中启动磁盘硬件IO数据传输
     queue_unplugged(q, depth, from_schedule);

     local_irq_restore(flags);
}

__elv_add_request传递的ELEVATOR_INSERT_FLUSH和ELEVATOR_INSERT_SORT_MERGE参数,二者实质的区别是啥?????需要研究一下

好的,到这里blk_queue_bio()函数整理流程讲解完成,主要是一个新分配的req是怎么影响IO使用率等有关的数据,涉及到drive_stat_acct()、part_round_stats()、part_inc_in_flight()几个函数。其实还有一个重要环节,影响IO使用率等数据的统计,就是IO数据传输完成,磁盘控制器先产生硬中断,然后出发软中断,然后执行blk_account_io_done()和blk_account_io_completion()。

5 IO数据传输完成过程详解

blk_account_io_done()和blk_account_io_completion()函数调用流程如下:

0xffffffffb8f45380 : part_round_stats+0x0/0x100 [kernel]
 0xffffffffb8f48430 : blk_account_io_done+0x110/0x170 [kernel]
 0xffffffffb8f484fc : blk_finish_request+0x6c/0x130 [kernel]
 0xffffffffb90dd546 : scsi_end_request+0x116/0x1e0 [kernel]
 0xffffffffb90dd7d8 : scsi_io_completion+0x168/0x6a0 [kernel]
 0xffffffffb90d2c8c : scsi_finish_command+0xdc/0x140 [kernel]
 0xffffffffb90dcd22 : scsi_softirq_done+0x132/0x160 [kernel]
 0xffffffffb8f4f8f6 : blk_done_softirq+0x96/0xc0 [kernel]
 0xffffffffb8ca2155 : __do_softirq+0xf5/0x280 [kernel]


0xffffffffb8f47e90 : blk_account_io_completion+0x0/0xb0 [kernel]
 0xffffffffb8f47f89 : blk_update_request+0x49/0x360 [kernel]
 0xffffffffb90dd464 : scsi_end_request+0x34/0x1e0 [kernel]
 0xffffffffb90dd7d8 : scsi_io_completion+0x168/0x6a0 [kernel]
 0xffffffffb90d2c8c : scsi_finish_command+0xdc/0x140 [kernel]
 0xffffffffb90dcd22 : scsi_softirq_done+0x132/0x160 [kernel]
 0xffffffffb8f4f8f6 : blk_done_softirq+0x96/0xc0 [kernel]
 0xffffffffb8ca2155 : __do_softirq+0xf5/0x280 [kernel]

再梳理一下两个函数的调用流程,栈回溯信息有些函数没打印出来

scsi_end_request –> blk_end_request-> blk_end_bidi_request-> blk_update_bidi_request-> blk_update_request-> blk_account_io_completion
                                                          -> blk_finish_request-> blk_account_io_done

前文说过,一个req可能合并了多个bio,发送一次IO数据传输SCSI命令,可能并不会把req上所有的bio对应的磁盘数据传输完。比如req上合并了5个bio,而这5个bio可能又有更多的bio_vec,就是说要传输磁盘IO数据在物理内存的数据肯能分了很多片,而传输磁盘IO数据又得使用DMA,DMA每次只能传输一片连续的物理内存中的数据。所以假设第一次发送IO数据传输命令,只传输了3个bio对应的磁盘数据。数据传输完成后,产生中断执行scsi_end_request –> blk_end_request-> blk_end_bidi_request-> blk_update_bidi_request-> blk_update_request-> blk_account_io_completion 函数,在blk_account_io_completion函数中,统计一下刚才传输完成IO数据量,把传输完成的bio相关休眠的进程唤醒,设置下一次传输的bio等等。接着就返回了,不再执行blk_finish_request-> blk_account_io_done,继续进行下一次IO传输,应该是执行scsi_end_request->scsi_next_command->scsi_run_queue->__blk_run_queue进行下一次IO传输,这点不太确定。还有就是这个流程是怎么设置本次传输的bio有关的IO数据到磁盘控制器的,也不太清楚???需要专门研究一下req、bio、SCSI命令的转化关系,尤其是一个req的bio分多次传输的情况!

好的,假设新设置完下一次传输的bio后,也传输完成了,此时又产生中断。但是因为req上的bio都传输完成了,所以函数执行流程是,执行scsi_end_request –> blk_end_request-> blk_end_bidi_request-> blk_update_bidi_request-> blk_update_request-> blk_account_io_completion ,统计一下本次传输完成IO数据量,把传输完成的bio相关休眠的进程唤醒等等。因为 现在req上的bio全部传输完成了,返回到blk_update_bidi_request函数后,就会执行blk_end_bidi_request-> blk_finish_request-> blk_account_io_done,在blk_account_io_completion函数中,统计ios、ticks、time_in_queue、io_ticks、in_flight等IO使用计数。

直接上blk_account_io_completion和blk_account_io_done源码

static void blk_account_io_completion(struct request *req, unsigned int bytes)
{
	if (blk_do_io_stat(req)) {
		const int rw = rq_data_dir(req);
		struct hd_struct *part;
		int cpu;
		cpu = part_stat_lock();
		part = req->part;
         //增加struct disk_stats结构的sectors IO使用计数,即传输的扇区数
		part_stat_add(cpu, part, sectors[rw], bytes >> 9);
		part_stat_unlock();
	}
}
//req对应的bio磁盘数据全部传输完成了,增加ios、ticks、time_in_queue、io_ticks、flight等使用计数
static void blk_account_io_done(struct request *req)
{
	if (blk_do_io_stat(req) && !(req->cmd_flags & REQ_FLUSH_SEQ)) {
		unsigned long duration = jiffies - req->start_time;
		const int rw = rq_data_dir(req);
		struct hd_struct *part;
		int cpu;

		cpu = part_stat_lock();
		part = req->part;
        //增加struct disk_stats结构的ios计数,即IOPS
	   part_stat_inc(cpu, part, ios[rw]);
        //增加struct disk_stats结构的ticks计数
		part_stat_add(cpu, part, ticks[rw], duration);
        //更新主块设备和块设备分区的time_in_queue和io_ticks数据
		part_round_stats(cpu, part);
        //req传输完成,IO队列中的req数见1,即struct hd_struct结构的 flight减1
		part_dec_in_flight(part, rw);

		hd_struct_put(part);
		part_stat_unlock();
	}
}

6 最后用流程图总结一下

跟IO计数有关的流程全介绍完成了,下边再详细展示下整个过程。
在这里插入图片描述

这是submit_bio主函数流程,黄色的方框表示一个主函数的开始,棕色的方框表示该函数代码里执行流程,青色的表示对应函数代码里的主要函数。跟IO使用率统计有关的两个函数是part_round_stats()、drive_stat_acct(同blk_account_io_start),part_round_stats()被调用的频繁很高,在IO传输完成、cat /proc/diskstat都会调用到。如下是流程图

在这里插入图片描述

part_round_stats()、drive_stat_acct(同blk_account_io_start)两个函数的主流程是:

在这里插入图片描述

下边总结一下,块设备IO使用率有关的几个统计项数据:time_in_queue、io_ticks、in_flight、merges 、sectors、ios、ticks、part->stamp。

  • 1 time_in_queue表示当前块设备的IO队列中的总IO数在IO队列中的时间,是一个累加值,具体看源码。更新流程是:part_round_stats->part_round_stats_single->__part_stat_add(cpu,part,time_in_queue,part_in_flight(part)*(now-part->stamp))

  • 2 io_ticks最常用,跟iostat看到IO使用率有关,更新流程是
    part_round_stats->part_round_stats_single->__part_stat_add(cpu, part,io_ticks, (now -part->stamp))。它表示,单位时间内已经提交IO到队列但是还没有传输完成的IO花费的时间(实际以jiffies为单位表示,是个累加值),。什么意思?它与in_flight紧密相关,in_flight表示已经提交IO到队列但是还没有传输完成的IO个数。如果1s内,有800ms in_flight都大于0,说明”单位时间内已经提交IO到队列但是还没有传输完成的IO花费的时间”是800ms,则该段时间的IO使用率就是80%。

  • 3 in_flight表示已经提交IO到队列但是还没有传输完成的IO个数,准确点说是在IO队列中的IO个数+已经从IO队列中取出发送给磁盘控制器驱动传输但还未传输完成的IO个数。需要强调一点,以前我一直以为,in_flight仅仅表示IO队列中的IO个数,大错特错。当从IO队列中取出一个req发送给磁盘控制器驱动,in_flight并没有减1,而是等req对应的磁盘数据全部传输完成执行blk_account_io_done函数in_flight才会减1。另外的场景是,当一个新的req(即IO)加入队列,in_flight加1。当发生IO二次合并,被合并的req就要消失,则in_flight减1。如下是in_flight加减流程:

新分配的req加入IO队列,in_flight加1。 使用进程plug链表:blk_queue_bio->drive_stat_acct(req, 1)(blk_account_io_start)->> part_inc_in_flight()->atomic_inc(&part->in_flight[rw])

不使用进程plug链表:blk_queue_bio->add_acct_request->drive_stat_acct(req,1)(blk_account_io_start)->part_inc_in_flight()->atomic_inc(&part->in_flight[rw])

当一个req传输完成,in_flight减1:blk_account_io_done->part_dec_in_flight->atomic_dec(&part->in_flight[rw])

当发生IO二次合并,被合并的req就要出队列,in_flight减1:blk_queue_bio>attempt_front_merge()/attempt_back_merge() ->attempt_merge()->blk_account_io_merge()->part_dec_in_flight()->atomic_dec(&part->in_flight[rw])

这里有个疑问,发生IO二次合并,只是in_flight减1,为什么不merges那个IO参数也减1呢?merges参数针对bio合并到req的情况,只有bio合并到进程plug链表的req或者IO算法队列的req,merges才加1,不会减少。

  • 4 merges表示当前块设备bio合并到进程plug链表的req或者IO算法队列的req的个数,累加值。前边已经解释过,不再啰嗦。它的更新流程是1

bio合并到IO算法队列中的req,merges加1:blk_queue_bio>bio_attempt_front_merge/bio_attempt_back_merge->drive_stat_acct(req, 0)(同blk_account_io_start)->part_stat_inc(cpu, part, merges[rw])

bio合并到进程plug链表的req,merges加1:blk_queue_bio->attempt_plug_merge->blk_try_merge->bio_attempt_front_merge->bio_attempt_back_merge->drive_stat_acct(req, 0)(同blk_account_io_start)->part_stat_inc(cpu, part, merges[rw])

  • 5 sectors 表示当前块设备的req传输完成的扇区数,累加值。更新流程是blk_account_io_completion->part_stat_add(cpu, part, sectors[rw],
    bytes >> 9)注意,实际观察发现,一个req代表的总扇区数一次传输并不能传输完,会产生好几次中断,每次中断后的软中断都会执行到blk_account_io_completion(),然后累计一下当前完成传输的扇区数。

  • 6 ios表示当前块设备传输完成的req个数,累加值。只有req代表的扇区数全部传输完,执行blk_account_io_done->part_stat_inc(cpu,
    part, ios[rw]) 令ios累加1,这应该是就是iostat看到的IOPS。

  • 7 ticks应该表示req的传输耗时,是一个累计值,即累计该块设备的所有的req传输耗时,更新流程是blk_account_io_done->part_stat_add(cpu, part, ticks[rw],duration)。在req分配时会记录当前系统时间jiffies,流程是blk_queue_bio->get_request->__get_request->blk_rq_init,即req->start_time=jiffies。然后等req对应的磁盘数据全部传输完,执行blk_account_io_done,先执行duration= jiffies - req->start_time,duration则是该req传输耗时,然后把这个req传输耗时累加到ticks。

  • 8 part->stamp其实跟块设备的IO使用率统计项没有多大关系。它用来限制part_round_stats()函数的执行,在part_round_stats->part_round_stats_single函数最后,用part->stamp记录jiffies。然后下次执行part_round_stats->part_round_stats_single,如果part->stamp如果与当前系统时间jiffies相等,直接返回,不会再更新time_in_queue、io_ticks这两项数据。

static void part_round_stats_single(int cpu, struct hd_struct *part,unsigned long now)
{
    //时间差必须大于一个jiffies,否则执行返回
	if (now == part->stamp)
		return;
     //in_flight 不为0,说明in flight队列有req,此时才会更新time_in_queue、io_ticks
	if (part_in_flight(part)) {
		__part_stat_add(cpu, part, time_in_queue,
				part_in_flight(part) * (now - part->stamp));
		__part_stat_add(cpu, part, io_ticks, (now - part->stamp));
	}
     //更新part->stamp 为当前时间
	part->stamp = now;
}

最后还是要提一下执行最频繁的part_round_stats()函数,只要前后两次执行该函数时间差大于一个jiffes,并且IO队列的req个数不为0,即in_flight不为0,就会更新time_in_queue和io_ticks两个数据。该函数的执行实际时机有。

1 新的IO加入IO队列,流程是blk_queue_bio->drive_stat_acct(req, 1)(blk_account_io_start)->part_round_stats() 和blk_queue_bio->add_acct_request->drive_stat_acct(req, 1)(blk_account_io_start)->part_round_stats()。

2 req对应的磁盘扇区数据全部传输完,执行blk_account_io_done-> part_round_stats()

3 cat /proc/diskstat后内核执行vfs_read-> proc_reg_read-> seq_read-> diskstats_show-> part_round_stats()

4 发生IO二次合并执行blk_queue_bio->attempt_front_merge()/attempt_back_merge()->attempt_merge()->blk_account_io_merge()->part_round_stats()

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐