Linux通用块层及iostat学习笔记

块设备基础知识

块设备操作过程

从图中可以看出,对于磁盘的一次读请求,首先经过虚拟文件系统(VFS),其次是页高速缓存(page cache),接下来是映射层(mapping layer,是具体的文件系统如ext4)、通用块层(generic block layer)、IO 调度程序(I/O scheduler,也叫elevator)、块设备驱动(block device driver),最后是物理块设备(block device)。

下面简要概括一个块设备I/O操作的执行过程:

  1. 系统调用(如read())的服务例程调用适当的VFS函数。
  2. VFS函数确定所请求的数据是否已经存在,若数据存在于内存的页高速缓存中,则直接读内存;否则进入下一步。
  3. 内核需要从块设备读数据,通过映射层确定数据的物理位置。
  4. 现在内核可以向通用块层提交请求,启动I/O操作来传送所请求的数据。一般而言,每个I/O操作只针对磁盘上一组连续的块。由于请求的数据不必位于相邻的块中,所以通用块层可能启动几次I/O操作。每次I/O操作由一个“块I/O”(简称“bio”)结构描述,它收集底层组件需要的所有信息以满足所发出的请求。
  5. 通用块层下面的I/O调度程序根据预先定义的内核策略,将待处理的I/O操作(bio)映射成一个I/O请求(request)并插入到块设备的请求队列(request queue)中,或将一个I/O操作合并到队列中已有的、且物理介质上相邻的请求中。
  6. 激活块设备驱动程序调用策略例程(strategy routine)选择一个待处理的请求,并向磁盘控制器的硬件接口发送适当的命令,来进行实际的数据传送。
  7. 当I/O操作终止时,磁盘控制器就产生一个中断,如果需要,相应的中断程序再调用策略例程去处理队列中的另一个请求。

可以看到块设备的操作涉及许多内核组件, 每个组件采用不同长度的块来管理磁盘数据:

  • 硬件块设备控制器采用称为“扇区”的固定长度的块来传送数据,因此I/O调度程序和块设备驱动程序必须管理数据扇区。
  • 虚拟文件系统、映射层和文件系统将磁盘数据存放在逻辑单元“块”中,一个块对应文件系统中一个最小的磁盘存储单元。
  • 页高速缓存作用于磁盘数据的“页”上,每页正好装在一个页框中。
  • 块设备驱动程序能够处理数据的“段”:一个段就是一个内存页或内存页的一部分,它们包含磁盘上物理相邻的数据块。
  • 通用块层将所有上层和下层的组件联合在一起,因此它需要了解数据的扇区、块、段以及页。

要特别注意的是,虽然块设备驱动程序可以每次传送一个单独的扇区,但是这样会导致磁盘性能的下降(因为确定磁盘表面上扇区的物理位置相当费时)。因此当内核创建了一个块设备I/O请求时,内核并不是立即执行它,I/O请求仅仅被调度,执行会向后推迟。

这种人为的延迟是提高块设备性能的关键机制:当请求传送一个新的数据块时,内核会检查能否将新请求与之前一直处于等待的某个请求合并,以减少寻道次数。I/O调度程序正是负责接收并调度通用块层创建的I/O请求。

为了实现这种延迟,通用块层引入了一个“插入/拔出”(plug/unplug)的概念。
一开始,队列是空的并且是已插入(可理解为关闭)的。此时向空的队列提交请求,以及今后的一段时间内,不会有请求被底层的设备驱动程序处理,这样通用块层中的bio才可以有充足的时间进行合并。
而当通用块层上层提交了足够的bio以后,它会显式地进行拔出(可理解为激活)操作,或者在一个很短的超时时间后被自动拔出,拔出后队列中的I/O请求才会开始下发到设备驱动程序。
通用块层就是通过这样的plug/unplug方式来保证I/O请求能够延迟下发,以达到最终的性能提升。

通用块层

bio结构体

通用块层的核心数据结构是一个称为bio的结构体,它描述了当前正在执行的(活动的)、以片段(segment)链表的形式组织的块设备I/O操作,所以该结构体中的主要成员变量都是用来管理相关信息的。
其中一个片段是一小块连续的内存缓冲区。这样的好处就是不需要保证单个缓冲区一定要连续。

每个bio实例都包含一个磁盘存储区标识符(存储区中的起始扇区号和扇区数目),及一个或多个描述与I/O操作相关的内存区的片段:

/* blk_types.h */
/* 内核版本均为v2.6.39 */
struct bio_vec {
  struct page    * bv_page; /* 指向这个内存缓冲区所在的物理页面 */ 
  unsigned int    bv_len; /* 内存缓冲区大小(字节) */
  unsigned int    bv_offset;  /* 缓冲区在该物理页面上的偏移量(字节) */
};

struct bio {
  sector_t            bi_sector;    /* 需要传输的第一个扇区号(磁盘中的位置) */
  struct bio          *bi_next;    /* 请求队列中的下一个bio */
  struct block_device    *bi_bdev;   /* 相关的块设备 */
  unsigned long        bi_flags;    /* bio标志位 */
  unsigned long        bi_rw;        /* I/O操作类型及优先级 */

  unsigned short  bi_vcnt;    /* bio_vec数组中段的数目 */
  unsigned short  bi_idx;        /* bio_vec数组中段的当前索引值 */

  /* Number of segments in this BIO after
   * physical address coalescing is performed.
   */
  unsigned int        bi_phys_segments;   /* 结合后的片段数 */

  unsigned int        bi_size;    /* 剩余的I/O数量 */

  unsigned int        bi_seg_front_size;  /* bio_vec数组中第一个可合并段的大小 */
  unsigned int        bi_seg_back_size;   /* bio_vec数组中最后一个可合并段的大小 */

  unsigned int        bi_max_vecs;    /* bio_vec数组中允许的最大段数 */

  unsigned int        bi_comp_cpu;    /* completion CPU */

  atomic_t            bi_cnt;        /* bio的引用计数器 */

  struct bio_vec  *bi_io_vec;    /* 指向bio的bio_vec数组中的段的指针(内存中的位置) */

  bio_end_io_t        *bi_end_io;   /* bio的I/O操作结束时调用的方法 */

  void                  *bi_private;    /* 供该bio结构体的创建者(通用块层和块设备驱动程序的I/O完成方法)使用 */
#if defined(CONFIG_BLK_DEV_INTEGRITY)
  struct bio_integrity_payload *bi_integrity;  /* data integrity */
#endif

  bio_destructor_t    *bi_destructor;    /* 释放bio时调用的析构方法(通常是bio_destructor()方法) */

  /*
   * We can inline a number of vecs at the end of the bio, to avoid
   * double allocations for a small number of bio_vecs. This member
   * MUST obviously be kept at the very end of the bio.
   */
  struct bio_vec    bi_inline_vecs[0];  /* 内嵌在结构体末尾的 bio 向量,用于防止出现二次申请少量的 bio_vecs */
};

bio中的每个段是由一个bio_vec数据结构描述的,其中最重要的几个成员变量是bi_io_vecs、 bi_vcnt和bi_idx。
bi_io_vecs指向一个bio_vec结构体数组,该结构体链表包含了一个特定I/O操作所需要使用到的所有段(segment)。
每个bio_vec结构都是一个形式为的向量,它描述的是一个特定的段:段所在的物理页、块在物理页中的偏移量、从给定偏移量开始的块长度。
整个bio_io_vec结构体数组表示了一个完整的缓冲区。
因此即使一个缓冲区分散在内存的多个位置上,bio结构体也能对内核保证I/O操作的执行,这样的就叫做向量I/O(vectored I/O,或scatter/gather I/O)。

在每个给定的块设备I/O操作中,bi_vcnt域用来描述bi_io_vec所指向的bio_vec数组中的向量数目。当通用块层开始执行请求,需要使用各个片段时,bi_idx就会不断更新,总是指向当前片段。通用块层通过它可以跟踪块设备I/O操作的完成进度。

bio作为通用块层的主要数据结构,既描述了操作磁盘的位置(bi_sector),又描述了内存的位置(bi_io_vec),是上层内核vfs与下层驱动的连接纽带。

磁盘和磁盘分区: gendisk

磁盘是一个由通用块层处理的逻辑设备,由gendisk结构体描述:

struct disk_part_tbl {
  struct rcu_head rcu_head;
  int len;
  struct hd_struct __rcu *last_lookup;
  struct hd_struct __rcu *part[];   /* 描述分区表的hd_struct数组 */
};
struct gendisk {
  int major;            /* 磁盘主设备号 */
  int first_minor;  /* 与磁盘关联的第一个次设备号 */
  int minors;     /* 与磁盘关联的次设备号范围 */

  char disk_name[DISK_NAME_LEN];    /* 磁盘的标准名称 */
  char *(*devnode)(struct gendisk *gd, mode_t *mode);

  unsigned int events;        /* supported events */
  unsigned int async_events;    /* async events, subset of all */

  /* Array of pointers to partitions indexed by partno.
   * Protected with matching bdev lock but stat and other
   * non-critical accesses use RCU.  Always access through
   * helpers.
   */
  struct disk_part_tbl __rcu *part_tbl;   /* 分区表指针 */
  struct hd_struct part0;

  const struct block_device_operations *fops; /* 块设备操作表的指针 */
  struct request_queue *queue;  /* 指向磁盘请求队列的指针 */
  void *private_data; /* 块设备驱动程序的基本数据 */

  int flags;  /* 磁盘类型的标志 */
  struct device *driverfs_dev;  // FIXME: remove
  struct kobject *slave_dir;

  struct timer_rand_state *random;  
  atomic_t sync_io;        /* 写入磁盘的扇区数计数器, 仅为RAID使用 */
  struct disk_events *ev;
#ifdef  CONFIG_BLK_DEV_INTEGRITY
  struct blk_integrity *integrity;
#endif
  int node_id;
};

需要关注的是每个gendisk对象包含一个request_queue类型的字段,表示每个磁盘(而非分区)单独维护一个I/O请求队列。

磁盘的分区表使用结构体disk_part_tbl表示,其地址保存在每个磁盘gendisk对象的part_tbl成员中。disk_part_tbl内部用一个hd_struct结构体的数组保存分区表的信息,每个hd_struct结构体表示磁盘的一个分区。

描述磁盘分区的hd_struct结构体定义:

struct hd_struct {
  sector_t start_sect;  /* 起始扇区 */
  sector_t nr_sects;  /* 分区的长度(扇区数) */
  sector_t alignment_offset;
  unsigned int discard_alignment;
  struct device __dev;    /* 从设备驱动模型基类结构device继承 */
  struct kobject *holder_dir;
  int policy;     /* 如果分区是只读的则为1, 否则为0 */
  int partno;     /* 磁盘中分区的相对索引 */
  struct partition_meta_info *info; /* 分区元数据 */
#ifdef CONFIG_FAIL_MAKE_REQUEST
  int make_it_fail;
#endif
  unsigned long stamp;      /* 统计请求队列使用情况的时间戳 */
  atomic_t in_flight[2];    /* 当前请求队列中与该分区相关联的I/O数 */
#ifdef    CONFIG_SMP
  struct disk_stats __percpu *dkstats;
#else
  struct disk_stats dkstats;  /* 硬盘性能统计信息 */
#endif
  atomic_t ref;
  struct rcu_head rcu_head;
};

其中需要关注的是disk_stats类型的字段dkstats,该字段主要用于保存分区的性能统计信息,同时内核会将其中的数据同步到/proc/diskstats文件中,iostat正是依赖这个文件来获取和计算磁盘分区的各个实时性能参数,后文会详细展开说明。

I/O请求队列: request_queue

请求队列由一个大的结构体request_queue表示:

struct request_queue
{
  /*
   * Together with queue_head for cacheline sharing
   */
  struct list_head    queue_head;
  struct request        *last_merge;
  struct elevator_queue    *elevator;

  /*
   * the queue request freelist, one for reads and one for writes
   */
  struct request_list    rq;   /* 请求结构体的freelist */

  /* 不同情况下执行的回调方法: */
  request_fn_proc        *request_fn;
  make_request_fn        *make_request_fn;
  prep_rq_fn        *prep_rq_fn;
  unprep_rq_fn        *unprep_rq_fn;
  merge_bvec_fn        *merge_bvec_fn;
  softirq_done_fn        *softirq_done_fn;
  rq_timed_out_fn        *rq_timed_out_fn;
  dma_drain_needed_fn    *dma_drain_needed;
  lld_busy_fn        *lld_busy_fn;

  // ...
};

请求队列(request_queue)实质上是一个双向链表,每个元素是一个请求(request结构体).
request_queue结构体中的queue_head成员存放链表的头。
结构体成员make_request_fn为I/O请求创建一个request结构体,然后把交给I/O调度程序。
I/O调度程序提供了几种预先定义好的元素排序方式(调度算法),后续会详细展开说明。
而成员request_fn存放来自设备驱动程序的请求处理函数。

每个请求队列都有一个允许处理的最大请求数,request_queue的nr_requests字段存放了每个数据传送方向所允许的最大请求数(默认读写队列的最大请求数都是128个)。
如果待处理的读/写请求数超过了允许的最大值,那么队列会被标志已满,需要把请求加入到某个传送方向的可阻塞进程被放置到request_list结构所对应的等待队列中睡眠。

I/O请求: request

每个块设备的待处理请求都是用一个结构体request表示的:

struct request {
  struct list_head queuelist; /* 用于挂在请求队列链表的节点,只能使用函数blkdev_dequeue_request访问,不能直接访问 */
  struct list_head donelist;  /* 用于挂在已完成请求链表的节点 */  
  struct request_queue *q;    /* 指向请求队列的指针 */  
  unsigned int cmd_flags;     /* 命令标识 */  
  enum rq_cmd_type_bits cmd_type;  /* 命令类型 */  

  /* 各种各样的扇区计数: */  
  /* 为提交I/O维护bio横断面的状态信息,其中hard_*成员是通用块层内部更新的,驱动程序不应该修改 */  
  sector_t sector;     /* 要传送的下一个扇区号 */  
  sector_t hard_sector;      /* 要传送的下一个扇区号 */  
  unsigned long nr_sectors;  /* 整个请求还需要传送的扇区数 */  
  unsigned long hard_nr_sectors; /* 整个请求中还需要传送的扇区数 */  
  unsigned int current_nr_sectors;  /* 当前bio的当前段中还需要传送的扇区数 */  
  unsigned int hard_cur_sectors;    /* 当前bio的当前段中还需要传送的扇区数 */
  struct bio *bio;     /* 请求中第一个未完成传送操作的bio */
  struct bio *biotail; /* 请求链表中末尾的bio */
  struct hlist_node hash;   /* 链入一个哈希表(用来查找与新bio相邻的请求) */

  /* rb_node仅用在I/O调度器中把请求放到一棵红黑树上,当请求被移到分发队列中时,请求将被删除。 */
  /* 因此,completion_data(用于给底层驱动保存一些额外的信息)可与rb_node分享空间 */
  union {
      struct rb_node rb_node;   /* 排序/查找 */
      void *completion_data;  
  };

  struct gendisk *rq_disk;    /* 请求所引用的磁盘 */
  struct hd_struct *part;     /* 请求所引用的分区 */
  unsigned long start_time;   /* 请求进入队列的时间(用jiffies表示) */

  unsigned int timeout;     /* 请求的超时 */
  int retries;

  rq_end_io_fn *end_io;   /* 请求完成时的回调函数 */
  void *end_io_data;

  // ...
};

每个请求(request)包含一个或多个bio结构.
最初, 通用块层创建一个仅包含一个bio结构的请求.
然后I/O调度程序可能向初始的bio中增加一个新段, 也可能将另一个bio结构链接到请求中(当新数据与请求中已存在的数据物理相邻), 从而扩展该请求.
request结构体中的bio字段指向请求中的第一个bio结构, 而biotail字段指向最后一个bio结构.

一个request实例中的几个成员字段值可能是动态变化的.
例如一旦bio中引用的数据块全部传送完毕,
bio字段立即更新从而指向请求链表中的下一个bio.
在此期间新的bio可能被加入到请求链表的尾部, 所以biotail的指向也可能改变.

各个结构体类型的关系

图片来源:Linux设备驱动—块设备(二)之相关结构体

向通用块层提交请求的过程:general_make_request

当内核向通用块层提交一个I/O请求操作时,先执行bio_alloc()函数分配一个新的bio描述符并初始化。
初始化bio后,内核调用generic_make_request()函数,该函数是通用块层的主要入口点,它主要执行以下操作:

  1. 检查bio->bi_sector没有超过块设备的扇区数
  2. 获取与块设备(bio->bdev)相关的请求队列q
  3. 检查该bio处理的扇区是否超过请求队列单个请求的最大扇区数
  4. 调用blk_partition_remap()函数检查块设备是否指的是一个磁盘分区。
    如果是,则从bio->bi_bdev获取分区的hd_struct描述符,并把相对于分区的起始扇区号转化为相对于整个磁盘的扇区号。此后,通用块层、I/O调度程序以及设备驱动程序将忽略分区,直接作用于整个磁盘
  5. 再次检查是否超过块设备的范围
  6. 调用回调函数q->make_request_fn将bio请求插入请求队列q中(2.6内核版本默认为__make_request()
  7. 返回

向I/O调度程序发出请求的过程:make_request_fn

如前所述,generic_make_request()函数调用请求队列描述符的make_request_fn方法向I/O调度程序发送一个请求。
在内核版本2.6中,该方法默认由__make_request()函数实现。
该函数接收一个request_queue类型的描述符q和一个bio结构的描述符bio作为其参数,然后执行如下操作:

  1. 如果需要,调用blk_queue_bounce()函数建立一个回弹缓冲区。如果回弹缓冲区被建立,__make_request()函数将对该缓冲区而不是原先的bio结构进行操作
  2. 调用I/O调度程序的elv_queue_empty()函数检查请求队列中是否存在待处理请求。注意:调度队列可能是空的,但是I/O调度程序的其他队列可能包含待处理请求。如果没有待处理请求,那么调用blk_plug_device()函数,以防止请求被设备驱动程序处理,然后执行第4步。
  3. 如果插入的请求队列包含待处理请求,调用I/O调度程序的elv_merge()函数检查新的bio结构是否可以并入已存在的请求中。若可以,则试图将该请求与可以队首或队尾的请求合并,合并后跳转到第7步终止函数;否则继续下一步。
  4. bio必须被插入到一个新的请求中,调用get_request_wait()分配一个新的request结构体。
  5. 初始化request,工作流程包括:
    1. 根据bio描述符的内容初始化各个字段,包括扇区数、当前bio及当前段
    2. 设置flags中的REQ_CMD标志(表明是读或写操作)
    3. 如果第一个bio段的页框放在低端内存,则将buffer字段设置为缓冲区的线性地址
    4. 将rq_disk字段设置为bio->bi_bdev->bd_disk的地址
    5. 将bio插入请求队列
    6. 将start_time字段设置为jiffies的值
  6. 所有操作全部完成。在终止前,检查如果设置了bio->bi_rw中的BIO_RW_SYNC标志,则对请求队列调用generic_unplug_device()函数,立即激活设备驱动程序处理队列的请求。

iostat

iostat是I/O statistics(输入/输出统计)的缩写,iostat工具将对系统的磁盘操作活动进行监视。它的特点是汇报磁盘活动统计情况,同时也会汇报出CPU使用情况。
同vmstat一样,iostat也有一个弱点,就是它不能对某个进程进行深入分析,仅对系统的整体情况进行分析。

基本实现原理

Linux 内核提供了一种通过 /proc 文件系统(伪文件系统),在运行时访问内核内部数据结构、改变内核设置的机制。

最初开发 /proc 文件系统是为了提供有关系统中进程的信息。
但是由于这个文件系统非常有用,因此内核中的很多元素也开始使用它来报告信息,或启用动态运行时配置。

/proc 文件系统包含了一些目录(用作组织信息的方式)和虚拟文件。
虚拟文件可以向用户呈现内核中的一些信息,也可以用作一种从用户空间向内核发送信息的手段。
iostat 就是通过读取这些虚拟文件来获取CPU、块设备的使用情况的。

iostat主要依赖于两个虚拟文件:/prop/stat(CPU实时状态监控)以及/proc/diskstats(块设备实时状态监控)。
本文着重分析diskstats文件在内核处理I/O请求过程中的更新。

/proc/stat 文件分析

# cat /proc/stat
cpu  110861 181 555675 78075105 6363396 0 9505 0 0 0
cpu0 57981 92 282580 39097145 3123930 0 4709 0 0 0
cpu1 52879 88 273095 38977960 3239466 0 4796 0 0 0
intr 158740214 20 10 0 0 57 0 3 0 0 6 0 35 15 0 0 0 0 0 0 0 0 0 0 0 0 18176 1 0 20 0 188460 3 0 167762 0 7991724 0 5084866 0 6035296 0 6177406 0 76604 0 72744 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
ctxt 231648757
btime 1517643700
processes 28629
procs_running 1
procs_blocked 0
softirq 106725500 2 53615919 0 500475 0 0 32 24172209 0 28436863

根据man proc可以看到:
第一行显示的是汇总的CPU统计信息,第二第三行的 cpu0、cpu1 分别表示两个逻辑CPU的相关数据。
第四行intr表示自系统启动到现在发生的系统中断(硬件)次数。
第五行ctxt表示进程切换的次数。
第六行btime表示系统启动的时间(以unix时间戳表示)。
第七行processes表示自系统启动到现在 fork 的进程总数。
第八行procs_running表示处于 runnable 状态的进程个数。
第九行procs_blocked表示处于 blocked 状态的进程个数。
第十行softirq表示各种软件中断发生的次数。

/proc/diskstats 文件分析

# cat /proc/diskstats
253       0 vda 37360 17 1784146 86663 163791 105909 9388280 6893851 0 143952 6980407
253       1 vda1 37242 17 1782338 86490 160625 105909 9388280 6892606 0 142781 6978996
253      16 vdb 5555701 0 44482846 18940368 10847398 494735595 7194283112 951346938 0 50716405 970285279
253      17 vdb1 4832454 0 38662041 17571294 392392 194185 267009408 473999941 0 7036133 491570582
253      18 vdb2 131940 0 1057273 212544 5659449 90069 2487535888 1381317020 0 16226827 1381529281
253      32 vdc 2921144 0 23382908 16891258 9582117 364228627 6018812992 4047444938 0 40063302 4064335281
253      33 vdc1 2592512 0 20741753 15840688 266563 35281 181828504 103572044 0 3350938 119412476
253      34 vdc2 125098 0 1002441 57908 5593445 76553 2462180128 1364974045 0 15723424 1365029277
253      48 vdd 3888656 0 31123004 16844034 9496985 364077866 6016379512 4038748329 0 40606067 4055597784
253      49 vdd1 3601897 0 28816833 15793736 266558 35272 181828392 103544601 0 3882066 119337738
253      50 vdd2 120312 0 964153 58429 5553776 76293 2461307784 1363220100 0 15745725 1363277875
253      64 vde 5112942 0 40915653 3418211 5346013 363765070 4415766216 3180011507 0 29932692 3183430274
253      65 vde1 4751347 0 38011993 2329368 251027 35195 171340648 98125820 0 3179339 100454539
253      66 vde2 452 0 4954 117 1218193 51179 831873968 474804824 0 5378425 474804474
253      80 vdf 38196 0 307210 766787 458562 51453 436149560 292950758 0 3374209 293717487
253      96 vdg 33490 0 269562 733330 458691 49240 436149008 295449025 0 3338442 296182433

每个设备及其分区都单独占一行,每行包含了该设备/设备分区的设备号、设备名及其他一些实时更新的统计数据。
每行前三个字段分别为主设备号、次设备号、设备名。
根据内核iostats文档描述,
之后从左到右的各个字段及意义如下:

字段编号 示例值 内核变量名 解释
F1 37360 rd_ios 完成的读请求次数
F2 17 rd_merges 被合并的读请求个数
F3 1784146 rd_sectors 读请求扇区个数的总和
F4 86663 rd_ticks 读请求花费的时间总和(毫秒)
F5 163791 wr_ios 完成的写请求次数
F6 105909 wr_merges 被合并的写请求个数
F7 9388280 wr_sectors 写请求扇区个数的总和
F8 6893851 wr_ticks 写请求花费的时间总和(毫秒)
F9 0 in_flight 块设备请求队列中的I/O请求数
F10 143952 io_ticks 块设备队列非空时间总和
F11 6980407 time_in_queue 块设备队列非空时间加权总和

上述各个字段全部都是累加值

实际上这些字段大多来自于内核头文件genhd.h中定义的disk_statshd_struct结构体:

/* include/linux/genhd.h */
struct disk_stats {
  unsigned long sectors[2];       /* [2]: for READs and WRITEs */
  unsigned long ios[2];
  unsigned long merges[2];
  unsigned long ticks[2];
  unsigned long io_ticks;
  unsigned long time_in_queue;
};

这几个字段均在内核处理I/O请求过程中实时更新,更新代码主要涉及以下几个函数:

part_round_stats

part_round_stats主要用于更新diskstat中的io_ticks和time_in_queue,
每当队列中的请求发生变化(开始或完成)时都会被调用。

/* blk-core.c */
/* 
 * part_round_stats 获取CPU时间(jiffies),更新diskstat的time_in_queue和io_ticks
 * @param cpu       当前活跃的CPU编号
 * @param part      目标磁盘
 */
void part_round_stats(int cpu, struct hd_struct *part) {
  unsigned long now = jiffies;

  /* 实际通过调用part_round_stats_single来处理 */
  if (part->partno)
    part_round_stats_single(cpu, &part_to_disk(part)->part0, now);
  part_round_stats_single(cpu, part, now);
}

static void part_round_stats_single(int cpu, struct hd_struct *part,
            unsigned long now) {
  if (now == part->stamp)
    return;

  /* 如果磁盘分区队列非空,则更新time_in_queue和io_ticks */
  if (part_in_flight(part)) {
    /* 计算time_in_queue += io_ticks * 队列中等待的I/O请求个数 */
    __part_stat_add(cpu, part, time_in_queue,
        part_in_flight(part) * (now - part->stamp));

    /* 计算io_ticks += (now - part->stamp) */
    __part_stat_add(cpu, part, io_ticks, (now - part->stamp));
  }

  /* 更新磁盘分区的时间戳 */
  part->stamp = now;
}

drive_stat_acct

drive_stat_acct()函数主要用于更新磁盘的diskstat统计信息。
这个函数中负责更新merge,io_ticks,time_in_queue字段。

调用链:

__make_request  ->  drive_stat_acct(req, 1)
                ->  add_acct_request    ->  drive_stat_acct(req, 1)
                ->  attempt_plug_merge  ->  bio_attempt_back_merge  ->  drive_stat_acct(req, 0)
                                        ->  bio_attempt_front_merge ->  drive_stat_acct(req, 0)

其中__make_request就是之前提到的向I/O调度程序发出请求主要的实现函数。

/* blk-core.c */
/* 
 * drive_stat_acct  更新diskstat,在__make_request函数中,以及合并request后会被调用
 * @param rq        I/O请求体
 * @param new_io    是否为新的I/O request
 */
static void drive_stat_acct(struct request *rq, int new_io){
  struct hd_struct *part;
  /* 从request类型的变量req中获取请求类型:R/W */
  int rw = rq_data_dir(rq);
  int cpu;

  if (!blk_do_io_stat(rq))
    return;

  cpu = part_stat_lock();

  if (!new_io) {
    /* 如果不是新的IO请求,而是merge产生的 */
    part = rq->part;

    /* 则 merges + 1 */
    part_stat_inc(cpu, part, merges[rw]);
  } else {
    /* 如果是新的IO,先获取访问的扇区 */
    part = disk_map_sector_rcu(rq->rq_disk, blk_rq_pos(rq));
    if (!hd_struct_try_get(part)) {
      part = &rq->rq_disk->part0;
      hd_struct_get(part);
    }

    /* 更新io_ticks和time_in_queue */
    part_round_stats(cpu, part);

    /* 读/写对应的 in_flight+1 */
    part_inc_in_flight(part, rw);
    rq->part = part;
  }

  part_stat_unlock();
}

blk_account_io_completion,blk_account_io_done

这两个函数也都是用于更新diskstat统计信息的,但是
blk_account_io_completion()是在一个请求部分完成时调用的,主要更新diskstat中的扇区数(sectors)字段;
blk_account_io_done()是在一个请求被块设备驱动程序全部完成时调用,主要更新ios, ticks、io_ticks和time_in_queue。

调用链:

blk_end_request     ->
blk_end_request_all -> blk_end_bidi_request -> blk_update_bidi_request -> blk_update_request -> blk_account_io_completion
                                           \-----(if no more data)------> blk_finish_request -> blk_account_io_done

其中blk_end_request是用于在块设备驱动程序(如scsi驱动程序)处理请求的过程中,请求被部分完成时调用的函数。
blk_end_request_all是在一个请求被全部完成后,块设备驱动程序报告调用的函数。

/* blk-core.c */
/*
 * blk_account_io_completion  一个req被部分完成调用,更新diskstat中的sectors
 * @param req                 I/O请求体
 * @param bytes               完成了多少字节
 */
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;

    /* 右移9位,相当于除以512字节,即一个扇区的字节数 */
    part_stat_add(cpu, part, sectors[rw], bytes >> 9);
    part_stat_unlock();
  }
}

/*
 * blk_account_io_done  一个req被全部完成调用,更新diskstat中的ios,ticks,io_ticks,time_in_queue
 * @param req           I/O请求体
 */
static void blk_account_io_done(struct request *req) {
  if (blk_do_io_stat(req) && !(req->cmd_flags & REQ_FLUSH_SEQ)) {
    /* 获取Δt */
    unsigned long duration = jiffies - req->start_time;

    /* 从request类型的变量req中获取请求类型:R/W */
    const int rw = rq_data_dir(req);
    struct hd_struct *part;
    int cpu;

    cpu = part_stat_lock();
    part = req->part;

    /* 完成一次io, 递增rd_ios/wr_ios */
    part_stat_inc(cpu, part, ios[rw]);

    /* 将io的持续时间(duration),加到rd_ticks/wr_ticks上 */
    part_stat_add(cpu, part, ticks[rw], duration);

    /* 更新io_ticks和time_in_queue */
    part_round_stats(cpu, part);

    /* 对应的infight-1 */
    part_dec_in_flight(part, rw);

    hd_struct_put(part);
    part_stat_unlock();
  }
}

输出参数及计算方式

# iostat -mx 2
avg-cpu:  %user   %nice %system %iowait  %steal   %idle
           0.13    0.00    0.69    7.81    0.00   91.36

Device:   rrqm/s   wrqm/s     r/s     w/s    rMB/s    wMB/s avgrq-sz avgqu-sz   await r_await w_await  svctm  %util
vda         0.00     0.26    0.09    0.39     0.00     0.01    56.37     0.02   35.29    2.32   42.97   0.73   0.03
vdb         0.00  1203.92   13.52   26.40     0.05     8.55   441.30     2.36   59.15    3.41   87.70   3.09  12.34
vdc         0.00   886.34    7.11   23.32     0.03     7.15   483.25     9.89  325.06    5.78  422.40   3.20   9.75
vdd         0.00   885.97    9.46   23.11     0.04     7.15   451.79     9.87  302.98    4.33  425.27   3.03   9.88
vde         0.00   885.21   12.44   13.01     0.05     5.25   426.11     7.75  304.37    0.67  594.84   2.86   7.28
vdf         0.00     0.13    0.09    1.12     0.00     0.52   878.61     0.71  591.27   20.08  638.85   6.79   0.82
vdg         0.00     0.12    0.08    1.12     0.00     0.52   886.70     0.72  601.78   21.90  644.11   6.78   0.81

在这个命令下输出的信息主要分为两部分:CPU利用率(CPU Utilization)和设备利用率(Device Utilization)。

CPU利用率

在Linux/Unix下,CPU利用率分为用户态,内核态和空闲态三种模式,
分别表示CPU处于用户态执行的时间,系统内核执行的时间,和系统空闲进程执行的时间的百分比。

在输出标签avg-cpu后显示的信息就是CPU处在各个模式的平均利用率。
在多处理器的系统中这些信息是通过分别计算各个处理器的相应数据后,取平均值输出。

由于各个CPU在各个模式下的利用率内核都会实时输出在iostat 可直接从/proc/stat文件中读取解析,不需要额外计算。

%user

%user 表示CPU处在用户态下(即执行用户态进程)的时间百分比。

%nice

Nice值是类UNIX操作系统中表示静态优先级的数值。
静态优先级是进程启动时分配的优先级,可以用nice()和sched_setscheduler()系统调用重新设定,否则在进程运行过程中一直保持恒定。
Nice值的范围是-20~+19,默认值为0。拥有Nice值越大的进程的实际优先级越小。

%nice 表示CPU在执行优先级较低的(参考man proc),即Nice值大于0的用户态进程的时间百分比。

%system

%system 表示CPU处在内核态下(即执行内核进程)的时间百分比。

%iowait

%iowait 表示CPU在等待I/O完成的时间百分比。

实际上这个数据并不可靠,原因是:

  1. CPU不会等待I/O完成,iowait只是某个进程在等待I/O完成的时间。当进程发出一个I/O请求使CPU进入空闲等待时,CPU会调度另一个进程来继续执行。
  2. 对于多核CPU,正在等待I/O完成的进程不会在任何CPU上执行,因此CPU等待I/O完成的时间难以计算。
  3. 这个字段的值会在某些情况下减少

%steal

%steal 表示CPU在执行其他操作系统(如hypervisor需要执行虚拟化环境下的进程)的时间百分比。

%idle

%idle 表示CPU在执行空闲进程的时间百分比。

块设备利用率

iostat 按每个物理设备或分区给出了使用率的统计报告。
如前所述,以下各个指标都是通过diskstat文件计算得的,因此公式中的变量名直接引用diskstat官方文档:

字段编号 示例值 内核变量名 解释
F1 37360 rd_ios 完成的读请求次数
F2 17 rd_merges 被合并的读请求个数
F3 1784146 rd_sectors 读请求扇区个数的总和
F4 86663 rd_ticks 读请求花费的时间总和(毫秒)
F5 163791 wr_ios 完成的写请求次数
F6 105909 wr_merges 被合并的写请求个数
F7 9388280 wr_sectors 写请求扇区个数的总和
F8 6893851 wr_ticks 写请求花费的时间总和(毫秒)
F9 0 in_flight 块设备请求队列中的I/O请求数
F10 143952 io_ticks 块设备队列非空时间总和
F11 6980407 time_in_queue 块设备队列非空时间加权总和

tps: Transfers per second

该设备上每秒提交的I/O请求(Transfer)个数。
由于多个I/O请求可能会被合并成一个请求提交给设备,因此每个Transfer的大小不是固定的。

tps = [(Δrd_ios+Δwr_ios)/Δt]

rrqm/s: Read requests merged per second

设备的请求队列中,每秒被合并的读请求个数。

rrqm/s = [Δrd_merges/Δt]

wrqm/s:Write requests merged per second

设备的请求队列中,每秒被合并的读请求个数。

wrqm/s = [Δwr_merges/Δt]

r/s: Reads per second

该设备每秒完成的读操作次数。

r/s = [Δrd_ios/Δt]

w/s: Writes per second

该设备每秒完成的写操作次数。

w/s = [Δwr_ios/Δt]

avgrq-sz: Average requests size

每个I/O请求的平均大小(单位:扇区个数)。

avgrq-sz = [Δrd_sectors+Δwr_sectors]/[Δrd_ios+Δwr_ios]

avgqu-sz; Average queue length size

I/O请求的平均队列长度,即平均未完成的I/O请求数量。

avgqu-sz=[Δtime_in_queue/Δt]

await: Await time

每个I/O请求被处理完成所需的平均时间(毫秒)。
这个时间包括I/O请求在请求队列中等待的时间,以及设备实际处理该请求的时间。

await = [Δrd_ticks+Δwr_ticks]/[Δrd_ios+Δwr_ios]

svctm: Service time

设备实际处理每个I/O操作所需的平均时间(毫秒),不包括请求在队列中等待的时间。

需要注意的是这个指标并不可靠,也已经被弃用了,因为设备可能在同时处理多个I/O操作。

svctm = [util/tput(transfer put)],无意义。

%util: Utilization

该设备的繁忙(非空闲)时间比率。

%util = [Δio_ticks/Δt]

注意:这个值有时可能超过100,这是因为读操作不是原子操作,实际读到的分母Δt可能会比理论值要小。

实测分析

测试说明

用fio和dd测试不同场景下iostat输出参数的变化情况。
测试环境为腾讯云1C1G云主机(配本地盘):

  • 操作系统: CentOS 7.4 64位
  • CPU: 1核
  • 内存: 1GB
  • 测试盘:
    • 50 GB本地数据盘
    • 50 GB普通云盘

使用的fio测试的模板为:

fio --refill_buffers --norandommap --randrepeat=0 --group_reporting --name=test \
-ioengine=libaio -direct=1 -time_based -runtime=30 -size=5G -filename=${filename}  \
-bs=[bs] \
-iodepth=[iodepth] \
-rw=[rw]

使用的dd测试顺序写的模板为:

dd if=/dev/zero of=${filename} bs=${ts[0]} count=${ts[1]} oflag=dsync,direct,nonblock

变量参数说明:

  • bs:一个I/O请求的块大小(Block Size)
  • iodepth:请求队列大小的上限
  • rw:测试类型,如write(顺序写)、randwrite(随机写)、randread(随机读)

测试结果

本地数据盘

fio参数 bs iodepth rw iostat输出 rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
4K 1 write 0.00 3.56 0.00 10226.51 0.00 39.96 8.00 0.86 0.08 0.00 0.08 0.08 86.28
4K 1 randwrite 0.00 128.95 1.84 4922.37 0.01 19.73 8.21 0.95 0.19 9.45 0.19 0.19 92.01
4K 1 randread 0.00 73.94 352.75 178.88 1.38 0.99 9.11 1.12 2.11 2.78 0.78 1.85 98.58
4K 32 write 0.00 99.35 0.00 58898.38 0.00 230.46 8.01 26.20 0.44 0.00 0.44 0.02 102.81
4K 32 randwrite 0.00 0.20 0.00 5889.48 0.00 23.01 8.00 31.51 5.34 0.00 5.34 0.17 100.29
4K 32 randread 0.00 0.10 4583.44 50.76 17.90 0.20 8.00 32.62 7.05 7.05 7.53 0.22 102.22
16K 1 write 0.00 0.36 111.02 9513.00 0.43 148.64 31.72 1.65 0.17 6.96 0.09 0.09 90.83
16K 1 randwrite 0.00 0.38 0.00 3656.53 0.00 57.13 32.00 0.95 0.26 0.00 0.26 0.26 95.34
16K 1 randread 0.00 0.10 240.19 159.17 3.75 2.48 31.99 0.99 2.47 3.91 0.29 2.47 98.54
16K 32 write 0.00 0.17 16.98 45059.94 0.27 704.06 32.00 25.28 0.56 3.69 0.56 0.02 100.91
16K 32 randwrite 0.00 0.34 0.00 7189.52 0.00 112.33 32.00 31.37 4.35 0.00 4.35 0.14 100.09
16K 32 randread 0.00 0.14 4278.55 299.55 66.85 4.68 32.00 32.66 7.16 6.97 9.86 0.22 102.33
128K 1 write 0.00 0.38 504.46 4656.53 7.88 582.00 234.08 4.03 0.78 6.54 0.16 0.16 84.25
128K 1 randwrite 0.00 0.34 0.00 1831.94 0.00 228.94 255.94 0.93 0.51 0.00 0.51 0.51 93.26
128K 1 randread 0.00 0.10 147.30 168.32 18.41 21.01 255.79 0.98 3.11 5.85 0.71 3.11 98.10
128K 32 write 0.00 0.17 30.29 10101.49 3.79 1262.64 255.99 26.33 2.60 4.74 2.59 0.10 102.72
128K 32 randwrite 0.00 0.34 0.00 3501.87 0.00 437.69 255.97 31.60 8.98 0.00 8.98 0.29 100.67
128K 32 randread 0.00 0.17 2078.02 217.14 259.75 27.10 255.96 32.17 14.08 12.71 27.21 0.44 100.93
dd参数 bs iostat输出 rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
4K 0.00 12396.10 0.00 8511.28 0.00 82.56 19.87 0.96 0.11 0.00 0.11 0.11 95.88
16K 0.00 13265.14 0.02 8217.27 0.00 122.53 30.54 0.95 0.12 6.00 0.12 0.12 95.41
128K 0.00 2372.71 0.05 1425.15 0.00 72.29 103.88 0.20 0.14 2.33 0.14 0.14 19.88

普通云硬盘

fio参数 bs iodepth rw iostat输出 rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
4K 1 write 0.00 3.30 0.00 598.48 0.00 2.35 8.04 0.98 1.64 0.00 1.64 1.64 97.89
4K 1 randwrite 0.00 22.78 10.21 589.62 0.04 2.39 8.30 0.98 1.63 1.08 1.64 1.63 97.72
4K 1 randread 0.00 12.88 863.47 0.78 3.37 0.05 8.12 0.90 1.05 1.04 8.09 1.04 89.70
4K 32 write 0.00 4.12 0.00 9667.01 0.00 37.78 8.00 31.68 3.28 0.00 3.28 0.11 101.89
4K 32 randwrite 0.00 83.87 0.00 2030.47 0.00 8.26 8.33 31.96 15.74 0.00 15.74 0.49 99.76
4K 32 randread 0.00 43.29 1995.73 4.01 7.80 0.18 8.17 32.37 16.19 15.70 259.64 0.50 99.72
16K 1 write 0.00 0.17 25.53 480.46 0.10 7.50 30.78 1.81 3.60 33.79 1.99 1.94 98.37
16K 1 randwrite 0.00 97.34 0.00 511.84 0.00 8.36 33.47 0.99 1.93 0.00 1.93 1.91 97.73
16K 1 randread 0.00 40.51 803.15 35.75 8.89 0.64 23.27 1.11 1.32 1.17 4.70 1.17 97.75
16K 32 write 0.00 14.91 55.88 5316.54 0.62 83.05 31.90 29.73 5.53 1.15 5.58 0.19 100.15
16K 32 randwrite 0.00 106.75 0.00 2255.85 0.00 35.60 32.32 31.95 14.16 0.00 14.16 0.44 99.92
16K 32 randread 0.00 53.93 1823.88 163.05 25.93 2.73 29.55 32.30 15.96 15.71 18.66 0.50 100.04
128K 1 write 0.00 0.17 204.58 291.59 2.92 36.41 162.33 4.21 9.71 19.25 3.02 1.98 98.24
128K 1 randwrite 0.00 56.03 0.00 317.69 0.00 39.83 256.78 0.98 3.08 0.00 3.08 3.06 97.28
128K 1 randread 0.00 25.97 806.03 47.02 44.85 5.29 120.38 1.47 1.72 1.56 4.44 1.15 97.96
128K 32 write 0.00 6.19 130.88 915.98 7.34 113.85 237.08 28.89 27.58 1.55 31.30 0.95 99.41
128K 32 randwrite 0.00 48.87 0.00 1021.46 0.00 127.29 255.21 32.34 31.67 0.00 31.67 0.98 99.63
128K 32 randread 0.00 17.42 1082.01 179.98 109.79 22.04 213.93 36.52 28.76 27.57 35.92 0.79 100.00
dd参数 bs iostat输出 rrqm/s wrqm/s r/s w/s rMB/s wMB/s avgrq-sz avgqu-sz await r_await w_await svctm %util
4K 0.00 692.72 0.00 519.71 0.00 4.79 18.89 1.00 1.92 0.00 1.92 1.92 99.95
16K 0.00 645.78 0.02 484.46 0.00 6.75 28.54 1.00 2.06 9.00 2.06 2.06 99.76
128K 0.00 616.89 0.15 398.22 0.00 20.03 102.96 0.97 2.43 5.44 2.43 2.43 96.66

结果分析

修改iodepth的影响

  • 由iodepth的定义可以看出,iodepth的修改直接影响的是avgqu-sz(平均队列大小),因此在iodepth从1升到32后,avgqu-sz也相应地从约等于1提升到约等于32。
  • 由于请求队列的扩容,块设备执行完一个I/O请求后可以立即从队列中取出下一个请求,无需等待通用块层将新的请求放入队列,设备执行I/O请求的时间比例增加,因此可以看到块设备的利用率%util和设备的IOPS(r/s+w/s)得到提升。
  • 此外,因为吞吐量(rMB/s, wMB/s) = IOPS * I/O 请求大小,因此随着IOPS增加,设备的吞吐量也提升。
  • 但可以看到,iops和吞吐量的增长率与队列大小的增长率不成正比,例如本地数据盘测试中队列大小扩大了32倍时,而在4K顺序写场景下iops和吞吐量只扩大了约5.8倍,在4K随机写场景下只是原来的1.2倍。
  • 队列中的请求数增加,使得每个请求在队列中等待的平均时间变长,因此请求从发起到处理完成的平均时间await增加

修改bs的影响

  • bs即I/O块大小从4K增加到16K、128K后,直接影响的是avgrq-sz(平均请求大小)增大,且avgrq-sz的递增与bs的递增成正比例。
  • 如前所述,吞吐量等于IOPS乘以请求大小,因此在IOPS不变、平均请求大小增大的情况下,吞吐量会上升
  • 由于请求的块平均大小增大,设备处理一个请求所需要的时间更长,因此IOPS会下降
  • 在请求队列大小为32的场景下,增大块大小也会使得await上升,原因同上

随机与顺序I/O的影响

  • 对于传统硬盘,由于磁头寻道需要时间,随机I/O比顺序I/O寻道时间长,即await增加
  • 在队列长度iodepth不变的条件下,由于请求的await增加,因此IOPS减少
  • IOPS减小,由公式可知在请求大小相同的情况下,随机I/O的吞吐量也减小

本地数据盘和普通云盘的对比

  • 由于CBS云盘上的存储空间是分成多个1M的块分散落在底层磁盘上的,而且数据需要走网络,所以在整体性能上IOPS、吞吐量、await等指标本地数据盘都要好于普通云盘
  • 但是在iodepth等于1的随机读场景下,普通云盘性能优于本地数据盘,因为此时本地数据盘的磁盘寻道时间比(CBS底层磁盘的寻道时间+网络传输时间)还要长,即此场景下云盘的await比本地数据盘低,因此IOPS和吞吐量要比本地数据盘高

总结(对于本地数据盘)

变量 merges IOPS throughput avgrq-sz avgqu-sz await %util
iodepth↑ - -
bs↑ - - - -
random - - - -

疑问

  1. 为什么在随机读/写场景下,云盘merge的个数远比本地数据盘多?
  2. 为什么同样测试顺序写,dd测出的merge的个数远比fio测出的多?

参考资料