最近在看iotop的源码ioprio.py中有需要获取进程I/O优先级的内容。于是研究了下Linux的I/O调度算法。

参考文章:

http://blog.csdn.net/theorytree/article/details/6259104

<Understanding the linux kernel,3rd edition> 中的 "The I/O Scheduler" 一段内容

RH422 中对I/O调度相关的内容


尽管linux块设备驱动程序可以一次性传输一个扇区(512bytes),但是块设备的I/O层不会执行磁盘上的每个扇区的单次I/O操作,这会导致很差的磁盘性能问题,因为在磁盘上去寻找一个扇区的物理地址是很耗时的。Linux内核会尽可能地尝试将多个扇区集合在一起当作一个扇区,以此可以减少磁头移动的平均次数。

W020110530320191787759.jpg

当一个内核组件想要读或写一些磁盘数据时,它实际上会创建一个块设备请求(a block device request).这个请求会描述要请求的扇区和将要对他们执行的操作(read or write)。然而,Linux内核并不会立即满足一个刚创建的I/O请求,而是会根据调度算法调度这个请求,过后再去执行。当请求一个新的块数据传输(a new block data transfer)时,Linux内核将检查这个请求是否可以合并到之前一个正处于等待处理状态的请求中。因为磁盘更趋向于被顺序地访问,这种简单机制更有效。

推迟请求会增加块设备处理的复杂度。举个例子,假设一个进程打开一个普通文件,因此,一个文件系统驱动需要从磁盘中读取这个普通文件相应的inode。块设备驱动把这个请求放入到一个队列中,请求I/O的进程将会被停止,知道存储这个普通文件的inode数据块被传输。然而,这个块设备驱动本身是不会被阻塞的,因为其他任何要尝试访问相同磁盘的进程也会被阻塞。

为了不使块设备驱动被阻塞,每个I/O操作都会被异步处理。特别的,块设备驱动一种中断驱动:总的块设备层调用I/O调度程序创建一个新的块设备请求或增大一个已经存在的请求,然后结束。这个块设备驱动过后将被激活,它调用strategy routine去选择一个pending request,通过向磁盘控制器发送相应的指令来满足请求。当这个I/O操作结束时,磁盘控制器会发起一个中断,相应的处理程序会再次调用strategy routine,如果有必要,去处理另外一个pending request.

每个块设备驱动维护它自己的请求队列(request queue),这个请求队列包含有关这个设备的pending request列表。如果磁盘控制器(disk controller)同时处理多块磁盘,通常会为每个物理块设备分配一个请求队列(request queue),I/O调度在每个请求队列中分开执行,这样可以增加磁盘性能。


实质上,请求队列(request queue)是一个由请求描述符(request descriptors)组成的双向链表。

每个block device的pending request都是由request descriptor表示。



管理请求描述符的分配Managing the allocation of request descriptors 

在系统高负载和过多磁盘活动的情况下,进程想要把一个新请求加入到一个请求队列时,有限的动态内存可能会成为一个瓶颈。


避免请求队列拥塞Avoding request queue congestion

每个请求队列允许的pending request数量有个最大值。请求队列描述符中的nr_requests字段存储允许的pending request的最大值。默认情况下,一个队列允许最大128个pending read requests和128个pending write requests。如果pending read(write) requests的数量超过了nr_requests的值,通过在请求队列描述符(request queue descriptor)中的queue_flags字段设置一个QUEUE_FLAG_READFULL(QUEUE_FLAG_WRITEFULL)标示,这个队列就被标注为满了。那些想要尝试添加请求的阻塞进程会被放在相应的等待队列中处于休眠状态。


一个被填满的队列会对系统性能产生负面影响,因为它会使很多正在等待I/O数据传输完成的进程处于休眠状态。因此,如果一个pending requests的数量超过了请求描述符request descriptor中的nr_congestion_on字段设置的值,默认是113,Linux内核就会把这个队列视作拥塞,然后降低生成新请求的速率。当pending requests的值小于nr_congestion_off字段设置的值时,默认是111,一个拥塞的请求队列变得不拥塞。blk_congestion_wait()函数会使当前进程休眠,直到任何请求队列变得不拥塞或者过了超时时间。


激活块设备驱动 Activationg the block device driver

推迟激活块设备驱动来达到增加把多块相邻的块设备集合在一起的几率是一种权宜之计。这种推迟的方式通过device plugging and unplugging来实现的。只要一个快设备驱动被阻塞(plug)后,即使这个块设备驱动的请求队列中有需要被处理的请求,这个块设备驱动也不会被激活。


I/O调度算法  I/O Scheduling Algorithms

当一个新请求被加入到一个请求队列中去时,总的块设备层(generic block layer)会调用I/O调度程序来决定这个新元素在这个队列中的准确位置。I/O调度程序会尽量使请求队列以扇区顺序排列。如果处理的请求在请求队列中是被顺序放置的,那么总的磁盘寻道时间会大幅度减少,因为磁头是从内磁道线性移动到外磁道或是从外磁道线性移动到内磁道,而不是随机地从一个磁道跳跃到另一个磁道。这个和电梯移动的算法相似,I/O调度程序也叫做电梯调度。

在高负载的情况下,严格按照扇区编号顺序的I/O调度算法不会工作得太好。这种情况下,数据传输的完成时间严重依赖于这些数据在磁盘上的物理位置。以此,如果一个块设备驱动处理队列前端的请求时(小的扇区编号),具有小的扇区编号的新请求将会被连续地添加到队列中,这样位于队列末端的请求就会很容易处于饥饿状态不能处理。所以I/O调度算法被设计得相当复杂。

Linux2.6 提供四中I/O调度算法,Anticipatory,Deadline,CFQ和Noop。内核默认的算法可以在系统启动时添加内核参数 elevator=(noop,as,anticipatory,deadline,cfq任意一个)这样设置。默认使用anticipatory算法。也可以在系统运行时动态的设置

 echo "noop" > /sys/block/hda/queue/scheduler 这样去设置。


通常来讲,所有算法都会利用调度队列(dispatch queue)。


The "Noop" elevator

这个是最简单的I/O调度算法,它不会对调度队列排序,新的请求总是添加到调度队列的首段或末端,下一个要被处理的请求总是调度队列中的第一个请求。


The "CFQ"  elevator

Complete Fairness Queueing完全公平队列电梯算法的主要目标是确保所有触发I/O请求的进程的磁盘I/O带宽占用的公平分配。为了实现达到这样的结果,CFQ利用大量有序队列,默认是64个队列来存储来自不同进程的I/O请求。无论何时,当一个请求被CFQ调度时,Linux内核会调用一个哈希函数来将当前进程的线程组标示符(通常和PID对应)转换成队列中的索引,然后,CFQ将这个请求插入到队列的首部或尾部。因此,来自同一个进程的所有请求总是被插入到同一个队列中。

为了能够再次填满调度队列,CFQ会以轮询的方式扫描I/O输入队列,选择第一个非空的队列,然后将这个队列里的一批请求移入到调度队列中。


The "Deadline" elevator

除了调度队列外,Deadline还使用4个队列。两个是有序队列(sorted queues)--分别包含读请求和写请求,这些读请求或写请求是按照他们的初始扇区编号来排序的。另外两个队列是截止时间队列(deadline queues),分别包含读请求和写请求,这些请求按照各自的截止时间(deadline)排序。

引入这些队列的目的是为了避免请求被饿死(requests starvation),当调度策略忙于优先处理其他靠近上个被服务的请求时,由于长时间没有处理当前的请求,就会造成这个请求被饿死。一个请求截止时间(request deadline)实际上就是一个过期时间计时器,当这个请求通过调度器时,计时器开始计时。默认情况下,读请求的过期时间是500毫秒,写请求的过期时间是5秒,读请求的优先级高于写请求,因为读请求会阻塞和读请求相关的进程。Dealine确保调度器会去查看一个已经等待很长时间的请求,即使这个请求位于队列的后端。


当调度器elevator 必须要填充调度队列dispatch queue时,它会首先判定下一个请求的数据方向。如果读请求和写请求同时被调度时,调度器会选择处理读请求,除非写请求被丢弃了太多次,才会去处理写请求,要避免写请求饿死。

然后,调度器检查选择的读请求(或写请求)过期队列(deadline queue),如果队列中的第一个请求的过期时间已经过了,调度器会将这个请求移动到队列的末端,它也会将紧挨着这个过期请求的从有序队列中取出的一批请求移走。

最后,如果没有请求过期,调度器会从紧挨着上一个从有序队列中取出的请求开始调度一批请求。当达到有序队列的末端时,又从头开始循环。


The "Anticipatory" elevator

Anticipatory算法是Linux提供的最复杂的I/O调度算法。它是"Deadline"算法的改进版,和"Deadline"一样提供了两个过期队列dead lines和有序队列sorted lines.I/O调度器持续扫描有序队列,在读请求队列和写请求队列之间来回切换,但是会优先扫描读请求队列。扫描是顺序地,除非一个请求过期了。默认情况下,读请求的过期时间是125毫秒,写请求的过期时间是250毫秒。