1.5.5 块设备I/O调度程序
我们建立请求队列建的目录是,当向请求队列增加一条新的请求,即产生一个request数据结构时,通用块层会调用I/O调度程序来确定该新request将在请求队列中的确切位置。I/O调度程序试图通过扇区将请求队列排序。如果顺序地从链表中提取要处理的请求,那么就会明显减少磁头寻道的次数,因为磁头是按照直线的方式从内磁道移向外磁道(反之亦然),而不是随意地从一个磁道跳跃到另一个磁道。
这就是著名的电梯算法,回想一下,电梯算法处理来自不同层的上下请求。电梯是往一个方向移动的;当朝一个方向上的最后一个预定层到达时,电梯就会改变方向而开始向相反的方向移动。因此,块设备I/O调度程序也被称为电梯算法(elevator)。
在重负载和磁盘操作频繁的情况下,固定数目的动态空闲内存将成为进程想要把新请求加入请求队列q的瓶颈。为了解决这种问题,每个request_queue描述符包含一个request_list数据结构:
struct request_list { int count[2]; //两个计数器,分别用于记录分配给READ和WRITE请求的请求描述符数 int starved[2]; //两个标志,分别用于标记为读或写请求的分配是否失败 int elvpriv; // mempool_t *rq_pool; //一个指针,指向请求描述符的内存池 wait_queue_head_t wait[2]; //两个等待队列, //分别存放了为获得空闲的读和写请求描述符而睡眠的进程。 }; |
我们可以通过blk_get_request()函数从一个特定请求队列的内存池中获得一个空闲的请求描述符;如果内存区不足并且内存池已经用完,则要么挂起当前进程,要么返回NULL(如果不能阻塞内核控制路径)。如果分配成功,则将请求队列的request_list数据结构的地址存放在请求描述符的r1字段中。blk_put_request()函数则释放一个请求描述符;如果该描述符的引用计数器的值为0,则将描述符归还回它原来所在的内存池。
每个请求队列都有一个允许处理的最大请求数。请求队列描述符的nr_requests字段存放了每个数据传送方向所允许处理的最大请求数。缺省情况下,一个队列至多有128个待处理读请求和128个待处理写请求。如果待处理的读(写)请求数超过了nr_requests,那么通过设置请求队列描述符request_queue数据结构的queue_flags字段的QUEUE_FLAG_READFULL(QUEUE_FLAG_WRITEFULL)标志将该队列标记为已满,试图把请求加入到某个传送方向的可阻塞进程被放置到request_list结构所对应的等待队列中睡眠。
一个填满的请求队列对系统性能有负面影响,因为它会强制许多进程去睡眠以等待I/O数据传送的完成。因此,如果给定传送方向上的待处理请求数超过了存放在请求描述符的nr_congestion_on字段中的值(缺省值为113),那么内核认为该队列是拥塞的,并试图降低新请求的创建速率。当待处理请求数小于nr_congestion_off的值(缺省值为111)时,拥塞的请求队列才变为不拥塞。blk_congestion_wait()函数挂起当前进程直到所有请求队列都变为不拥塞或超时已到。
我们前面谈到,延迟激活块设备驱动程序有利于把相邻块的请求进行集中。这种延迟是通过所谓的设备插入和设备拔出技术来实现的,其实,我们更愿意用激活-不激活来描述。在块设备驱动程序被插入时,该驱动程序并不被激活,即使在驱动程序队列中有待处理的请求。
blk_plug_device()函数的功能是插入一个块设备——更准确地说,插入到某个块备驱动程序处理的请求队列中。本质上,该函数接收一个请求队列描述符的地址q作为其参数。它设置q->queue_flags字段中的QUEUE_FLAG_PLUGGED位;然后,重新启动q->unplug_timer字段中的内嵌动态定时器。
blk_remove_plug()则拔去一个请求队列q:清除QUEUE_FLAG_PLUGGED标志并取消q->unplug_timer动态定时器的执行。当“视线中”所有可合并的请求都被加入请求队列时,内核就会显式地调用该函数。此外,如果请求队列中待处理的请求数超过过了请求队列描述符的unplug_thresh字段中存放的值(缺省值为4),那么I/O调度程序也会去掉该请求队列。
如果一个设备保持插入的时间间隔为q->unplug_delay(通常为3ms),那么说明blk_plug_device()函数激活的动态定时器的时间已用完,因此就会执行blk_unplug_timeout()函数。因而,唤醒内核线程kblockd所操作的工作队列kblockd_workqueue。kblockd执行blk_unplug_work()函数,其地址存放在q->unplug_work结构中。接着,该函数会调用请求队列中的q->unplug_fn方法,通常该方法是由generic_unplug_device()函数实现的。generic_unplug_device()函数的功能是拔出块设备:首先,检查请求队列是否仍然活跃;然后,调用blk_remove_plug()函数;最后,执行策略例程request_fn方法来开始处理请求队列中的下一个请求。
总结一下就是:
1. blk_plug_device()负责戒严。
2. blk_remove_plug()负责解禁。
3. 但是戒严这东西吧,也是有时间限制的,所以在戒严的时候,设了一个定时器,unplug_timer,一旦时间到了就自动执行blk_remove_plug去解禁。
4. 而在解禁的时候就不要忘记把这个定时器给关掉.(即del_timer)
5. 解禁之后调用request_fn()开始处理队列中的下一个请求,或者说车流开始恢复前行。
这样我们就算是明白这两个戒严与解禁的函数了。最后,题外话,关于unplug和plug,我觉得更贴切的单词是activate和deactivate,或者说激活与冻结,或者简单的说,开与关。
编写这两个函数究竟是为了什么呢?不妨这样理解,假设你经常开车经过长安街,你会发现经常有戒严的现象发生,比如某位领导人要出行,比如某位领导人要来访,而你可以把blk_plug_device()想象成戒严,把blk_remove_plug想象成开放。车流要想行进,前提条件是没有戒严,换言之,没有设卡,而QUEUE_FLAG_PLUGGED这个flag就相当于“卡”,设了它队列就不能前进了,没有设才有可能前进。之所以需要设卡,是因为确实有这个需求,有时候确实不想让队列前进,因为延迟激活块设备驱动程序有利于把相邻块的请求进行集中。
在重负载情况下,严格遵循扇区号顺序的I/O调度算法运行的并不是很好。因为,数据传送的完成时间主要取决于磁盘上数据的物理位置。因此,如果设备驱动程序处理的请求位于队列的首部(小扇区号),并且拥有小扇区号的新请求不断被加入队列中,那么队列末尾的请求就很容易会饿死。因而I/O调度算法会非常复杂。
当前的I/O调度程序或电梯算法很多,也给了人们扩展电梯算法的多种方法。Linux 2.6中自带了四种不同类型的电梯算法,分别为“预期(Anticipatory)”算法、“最后期限(Deadline)”算法、“CFQ (Complete Fairness Queueing,完全公平队列)”算法以及“Noop (No Operation)”算法。对大多数块设备而言,内核使用的缺省电梯算法可在引导时通过内核参数elevator=<name>进行再设置,其中<name>值可取下列任何一个:as、deadline、cfq和noop。如果没有给定引导参数,那么内核缺省使用“预期”I/O调度程序。总之,设备驱动程序可以用任何一个调度程序取代缺省的电梯算法;设备驱动程序也可以自己定制I/O调度算法,但是这种情况很少见。
此外,系统管理员可以在运行时为一个特定的块设备改变I/O调度程序。例如,为了改变第一个IDE通道的主磁盘所使用的I/O调度程序,管理员可把一个电梯算法的名称写入sysfs特殊文件系统的/sys/block/hda/queue/scheduler文件中。
清求队列中使用的I/O调度算法是由一个elevator_t类型的elevator对象表示的;请求队列描述符request_queue的elevator字段指向一个elevator对象。elevator对象的elevator_ops包含了几个方法,它们覆盖了elevator所有可能的操作:链接和断开elevator,增加和合并队列中的请求,从队列中删除请求,获得队列中下一个待处理的请求等等。elevator对象也存放了一个表的地址,表中包含了处理请求队列所需的所有信息。而且,每个请求描述符包含一个elevator_private字段,该字段指向一个由I/O调度程序用来处理请求的附加数据结构。
现在我们从易到难简要地介绍一下四种I/O调度算法。注意,设计一个I/O调度程序与设计一个CPU调度程序很相似:启发算法和采用的常量值是测试和基准外延量的结果。
一般而言,所有的算法都使用一个调度队列(dispatch queue),队列中包含的所有请求按照设备驱动程序应当处理的顺序进行排序—也即设备驱动程序要处理的下一个请求通常是调度队列中的第一个元素。调度队列实际上是由请求队列描述符的queue_head字段所确定的请求队列。几乎所有的算法都使用另外的队列对请求进行分类和排序。它们允许设备驱动程序将bio结构增加到已存在请求中,如果需要,还要合并两个“相邻的”请求。
“Noop”算法
这是最简单的 I/O调度算法。它没有排序的队列:新的请求通常被插在调度队列的开头或末尾,下一个要处理的请求总是队列中的第一个请求。
“CFQ”算法
"CFQ(完全公平队列)”算法的主要目标是在触发I/O请求的所有进程中确保磁盘I/O带宽的公平分配。为了达到这个目标,算法使用许多个排序队列——缺省为64——它们存放了不同进程发出的请求。当算法处理一个请求时,内核调用一个散列函数将当前进程的线程组标识符(通常,它对应其PID;然后,算法将一个新的请求插人该队列的末尾。因此,同一个进程发出的请求通常被插入相同的队列中。
为了再填充调度队列,算法本质上采用轮询方式扫描I/O输入队列,选择第一个非空队列,然后将该队列中的一组请求移动到调度队列的末尾。
“最后期限”算法
除了调度队列外,“最后期限”算法还使用了四个队列。其中的两个排序队列分别包含读请求和写请求,其中的请求是根据起始扇区号排序的。另外两个最后期限队列包含相同的读和写请求,但这是根据它们的“最后期限”排序的。引人这些队列是为了避免请求饿死,由于电梯策略优先处理与上一个所处理的请求最近的请求,因而就会对某个请求忽略很长一段时间,这时就会发生这种情况。请求的最后期限本质上就是一个超时定时器,当请求被传给电梯算法时开始计时。缺省情况下,读请求的超时时间是500ms,写请求的超时时间是5s——读请求优先于写请求,因为读请求通常阻塞发出请求的进程。最后期限保证了调度程序照顾等待很长一段时间的那个请求,即使它位于排序队列的末尾。
当算法要补充调度队列时,首先确定下一个请求的数据方向。如果同时要调度读和写两个请求,算法会选择“读”方向,除非该“写”方向已经被放弃很多次了(为了避免写请求饿死)。
接下来,算法检查与被选择方向相关的最后期限队列:如果队列中的第一个请求的最后期限已用完,那么算法将该请求移到调度队列的末尾。同时,它也会移动该过期的请求后面的一组来自排序队列的相同扇区号的请求。如果将要移动的请求在磁盘上物理相邻,那么这一批队列的长度会很长,否则就很短。
最后,如果没有请求超时,算法对来自于排序队列的最后一个请求连带之后的一组相同扇区的请求进行调度。当指针到达排序队列的末尾时,搜索又从头开始(“单方向算法”)。
“预期”算法
“预期”算法是Linux提供的最复杂的一种1/O调度算法。基本上,它是“最后期限”算法的一个演变,借用了“最后期限”算法的基本机制:两个最后期限队列和两个排序队列;I/O调度程序在读和写请求之间交互扫描排序队列,不过更倾向于读请求。扫描基本上是连续的,除非有某个请求超时。读请求的缺省超时时间是125ms,写请求的缺省超时时间是250ms。但是,该算法还遵循一些附加的启发式准则:
有些情况下,算法可能在排序队列当前位置之后选择一个请求,从而强制磁头从后搜索。这种情况通常发生在这个请求之后的搜索距离小于在排序队列当前位置之后对该请求搜索距离的一半时。
算法统计系统中每个进程触发的I/O操作的种类。当刚刚调度了由某个进程p发出的一个读请求之后,算法马上检查排序队列中的下一个请求是否来自同一个进程p。如果是,立即调度下一个请求。否则,查看关于该进程p的统计信息:如果确定进程p可能很快发出另一个读请求,那么就延迟一小段时间(缺省大约为7ms)。因此,算法预测进程p发出的读请求与刚被调度的请求在磁盘上可能是“近邻”。
好了,有这些来自ULK-3的基础知识后,来看elevator_init函数,来自block/elevator.c:
220 int elevator_init(request_queue_t *q, char *name) 221 { 222 struct elevator_type *e = NULL; 223 struct elevator_queue *eq; 224 int ret = 0; 225 void *data; 226 227 INIT_LIST_HEAD(&q->queue_head); 228 q->last_merge = NULL; 229 q->end_sector = 0; 230 q->boundary_rq = NULL; 231 232 if (name && !(e = elevator_get(name))) 233 return -EINVAL; 234 235 if (!e && *chosen_elevator && !(e = elevator_get(chosen_elevator))) 236 printk("I/O scheduler %s not found/n", chosen_elevator); 237 238 if (!e && !(e = elevator_get(CONFIG_DEFAULT_IOSCHED))) { 239 printk("Default I/O scheduler not found, using no-op/n"); 240 e = elevator_get("noop"); 241 } 242 243 eq = elevator_alloc(q, e); 244 if (!eq) 245 return -ENOMEM; 246 247 data = elevator_init_queue(q, eq); 248 if (!data) { 249 kobject_put(&eq->kobj); 250 return -ENOMEM; 251 } 252 253 elevator_attach(q, eq, data); 254 return ret; 255 } |
重点关注elevator_alloc():
179 static elevator_t *elevator_alloc(request_queue_t *q, struct elevator_type *e) 180 { 181 elevator_t *eq; 182 int i; 183 184 eq = kmalloc_node(sizeof(elevator_t), GFP_KERNEL, q->node); 185 if (unlikely(!eq)) 186 goto err; 187 188 memset(eq, 0, sizeof(*eq)); 189 eq->ops = &e->ops; 190 eq->elevator_type = e; 191 kobject_init(&eq->kobj); 192 snprintf(eq->kobj.name, KOBJ_NAME_LEN, "%s", "iosched"); 193 eq->kobj.ktype = &elv_ktype; 194 mutex_init(&eq->sysfs_lock); 195 196 eq->hash = kmalloc_node(sizeof(struct hlist_head) * ELV_HASH_ENTRIES, 197 GFP_KERNEL, q->node); 198 if (!eq->hash) 199 goto err; 200 201 for (i = 0; i < ELV_HASH_ENTRIES; i++) 202 INIT_HLIST_HEAD(&eq->hash[i]); 203 204 return eq; 205 err: 206 kfree(eq); 207 elevator_put(e); 208 return NULL; 209 } |
无非就是申请一个struct elevator_t结构体变量的空间并且初始化为0。
而真正引发我们兴趣的是192行,很显然,就是因为这里把eq的kobj的name设置为”iosched”,才会让我们在queue目录下看到那个“iosched”子目录。
而这个子目录下那些乱七八糟的文件又来自哪里呢?正是下面这个elv_register_queue()函数,这个正是我们在blk_register_queue()中调用的那个函数:
931 int elv_register_queue(struct request_queue *q) 932 { 933 elevator_t *e = q->elevator; 934 int error; 935 936 e->kobj.parent = &q->kobj; 937 938 error = kobject_add(&e->kobj); 939 if (!error) { 940 struct elv_fs_entry *attr = e->elevator_type->elevator_attrs; 941 if (attr) { 942 while (attr->attr.name) { 943 if (sysfs_create_file(&e->kobj, &attr->attr)) 944 break; 945 attr++; 946 } 947 } 948 kobject_uevent(&e->kobj, KOBJ_ADD); 949 } 950 return error; 951 } |
936行保证了,iosched是出现在queue目录下而不是出现在别的地方,而942行这个while循环则是创建iosched目录下面那么多文件的。我们先来看这个attr到底是什么,这里它指向了e->elevator_type->elevator_attrs,而在刚才那个elevator_alloc()函数中,190行,我们看到了eq->elevator_type被赋上了e,回溯至elevator_init(),我们来看e究竟是什么?
首先,当我们在blk_init_queue_node()中调用elevator_init的时候,传递的第二个参数是NULL,即name指针是NULL。
那么很明显,235行和238行这两个if语句对于elevator_type 类型的e变量的取值至关重要。它就是我们所谓的电梯算法。具体使用哪种算法我们可以在启动的时候通过内核参数elevator来指定,比如在我的grub配置文件中就这样设置过:
###Don't change this comment - YaST2 identifier: Original name: linux###
title Linux
kernel (hd0,0)/vmlinuz root=/dev/sda3 selinux=0 resume=/dev/sda2 splash=silent elevator=cfq showopts console=ttyS0,9600 console=tty0
initrd (hd0,0)/initrd
让elevator=cfq,因此cfq算法将是我们的IO调度器所采用的算法。而另一方面我们也可以单独的为某个设备指定它所采用的IO调度算法,这就通过修改在/sys/block/sda/queue/目录下面的scheduler文件。比如我们可以先看一下我的这块硬盘:
[root@localhost ~]# cat /sys/block/sda/queue/scheduler
noop anticipatory deadline [cfq]
可以看到我们这里采用的是cfq。现在暂时不去细说怎样使用这几种算法,我们接着刚才的话题,还看elevator_init()。
首先chosen_elevator是定义于block/elevator.c中的一个字符串.
160 static char chosen_elevator[16];
这个字符串就是用来记录启动参数elevator的。如果没有设置,那就没有值。
而CONFIG_DEFAULT_IOSCHED是一个编译选项。它就是一字符串,在编译内核的时候设置的,比如我的是cfq。
119 CONFIG_DEFAULT_IOSCHED="cfq"
你当然也可以选择其它三个,看个人喜好了,喜欢哪个就选择哪个,总之这个字符串会传递给elevator_get这个来自block/elevator.c的函数:
133 static struct elevator_type *elevator_get(const char *name) 134 { 135 struct elevator_type *e; 136 137 spin_lock(&elv_list_lock); 138 139 e = elevator_find(name); 140 if (e && !try_module_get(e->elevator_owner)) 141 e = NULL; 142 143 spin_unlock(&elv_list_lock); 144 145 return e; 146 } |
这里elevator_find()也来自同一个文件.
112 static struct elevator_type *elevator_find(const char *name) 113 { 114 struct elevator_type *e; 115 struct list_head *entry; 116 117 list_for_each(entry, &elv_list) { 118 119 e = list_entry(entry, struct elevator_type, list); 120 121 if (!strcmp(e->elevator_name, name)) 122 return e; 123 } 124 125 return NULL; 126 } |
这里我们要提一下elv_list链表,不管我们选择这四种算法中的哪一种,在正式登台演出之前,都需要做一些初始化,系统初始化过程中的一项工作就是调用elv_register()函数来注册自己。而这个注册主要就是往elv_list这张链表里登记:
int elv_register(struct elevator_type *e){ char *def = "";
spin_lock(&elv_list_lock); BUG_ON(elevator_find(e->elevator_name)); list_add_tail(&e->list, &elv_list); spin_unlock(&elv_list_lock);
if (!strcmp(e->elevator_name, chosen_elevator) || (!*chosen_elevator && !strcmp(e->elevator_name, CONFIG_DEFAULT_IOSCHED))) def = " (default)";
printk(KERN_INFO "io scheduler %s registered%s/n", e->elevator_name, def); return 0; } |
注意观察list_add_tail那一行,这个elevator_type结构体代表着一种电梯算法的类型,比如对于cfq,在cfq-iosched.c文件中,就定义了这么一个结构体变量iosched_cfq:
static struct elevator_type iosched_cfq = { .ops = { .elevator_merge_fn = cfq_merge, .elevator_merged_fn = cfq_merged_request, .elevator_merge_req_fn = cfq_merged_requests, .elevator_allow_merge_fn = cfq_allow_merge, .elevator_dispatch_fn = cfq_dispatch_requests, .elevator_add_req_fn = cfq_insert_request, .elevator_activate_req_fn = cfq_activate_request, .elevator_deactivate_req_fn = cfq_deactivate_request, .elevator_queue_empty_fn = cfq_queue_empty, .elevator_completed_req_fn = cfq_completed_request, .elevator_former_req_fn = elv_rb_former_request, .elevator_latter_req_fn = elv_rb_latter_request, .elevator_set_req_fn = cfq_set_request, .elevator_put_req_fn = cfq_put_request, .elevator_may_queue_fn = cfq_may_queue, .elevator_init_fn = cfq_init_queue, .elevator_exit_fn = cfq_exit_queue, .trim = cfq_free_io_context, }, .elevator_attrs = cfq_attrs, .elevator_name = "cfq", .elevator_owner = THIS_MODULE, }; |
同样,我们可以找到,对于noop,也有类似的变量。所以,我们就知道这个e到底是要得到什么了,如果你什么都没设置,那么它只能选择最差的那个,noop。于是到现在我们终于明白elv_register_queue()中那个e->elevator_type是啥了。而我们要的是e->elevator_type->elevator_attrs。对于cfq,很显然,它就是cfq_attrs。在block/cfq-iosched.c中:
static struct elv_fs_entry cfq_attrs[] = { CFQ_ATTR(quantum), CFQ_ATTR(fifo_expire_sync), CFQ_ATTR(fifo_expire_async), CFQ_ATTR(back_seek_max), CFQ_ATTR(back_seek_penalty), CFQ_ATTR(slice_sync), CFQ_ATTR(slice_async), CFQ_ATTR(slice_async_rq), CFQ_ATTR(slice_idle), __ATTR_NULL }; |
所以,那个while循环的sysfs_create_file的功绩就是以上面这个数组的元素的名字建立一堆的文件。而这正是我们在/sys/block/sdf/queue/iosched/目录下面看到的那些文件。
至此,elv_register_queue就算是结束了,从而blk_register_queue()也就结束了,而add_disk这个函数也大功告成。这一刻开始,整个块设备工作的大舞台就已经搭好了。对于sd那边来说,sd_probe就是在结束add_disk之后结束的。