tpps: 一个可做cgroup资源隔离的高效IO调度器

(本文里说的“资源隔离”主要是指cgroup根据blkio.weight的值来按比例调配io的带宽和IOPS,不包括io-throttle即blkio.throttle.xxx的一系列配置,因为linux的io-throttle机制不依赖于IO调度器)

由于现在的SSD越来越快也越来越便宜,如何高效的利用SSD就变得十分重要。高效利用SSD有两个办法,一个是加大应用程序发出的io深度(比如用aio),对于无法加大io深度的一些重要应用(比如mysql数据库),则可以用第二个办法:在一个高速块设备上跑多个应用实例。
现在问题来了,既然跑多个实例,就要避免它们互相干扰,比如一个mysql由于压力大把io带宽都占了,这对另一个mysql实例就不太公平。现在常用的办法当然是用cgroup来做io资源的隔离(参考这篇),但是,有个尴尬的事情,目前只有cfq这一个io调度器是支持cgroup的,而cfq调度器在高速设备上表现却不尽人意。
deadline调度器准备了两个队列,一个用来处理写请求,一个用来处理读请求,然后根据io快要到期的时间(即deadline)来选择哪个io该发出去了,这个选择同时也要考虑“读比写优先”、“尽可能连续发射io”等约束,但是不管怎么说,既然把io的响应时间做为了该调度算法的第一要素,那deadline调度器的io延时性就是相对最好的(即response time很短),它也是SSD设备的推荐调度器。问题在于,“两个队列”,这结构太简单了,自然也无法支持cgroup这样复杂的特性,所以,虽然deadline调度器很快,RT很短,但是做不了资源隔离。
再来看cfq,cfq最早就是基于磁盘来做优化的,它的算法尽可能保证io的连续性,而不保证io的及时响应(所以,在对磁盘做高io压测的时候,cfq调度器有时会制造出长达几秒的io响应来),比如,对于单个io,它会做一个小小的等待,看有没有和这个io连上的下一个io到来,如果有,可以一起发出去,以形成连续的io流,但是,这个”小小的等待“,就大大延长了io响应时间。cfq调度器为cgroup中的每个group单独创建一个"struct cfq_queue“实例,然后以各group的weight所占的权重来决定改发哪个group的io,所以cfq是支持资源隔离的。我讲的这些还没有碰到cfq的皮毛,它四千多行的代码有很多复杂和技巧性的算法和数据结构,我估计只有fusionio的李少华能把它说清楚,我这里就不班门弄斧了。
介绍IO调度器的文章,可参考这篇

总的来说,deadline适合SSD设备,但是它不支持资源隔离;cfq支持资源隔离,但是在SSD上跑延时又太差,且代码复杂,不易改造。(noop铁定不支持资源隔离,而anticipator跟deadline代码相差不大,这两个都出局了)最终,经过我、高阳伯瑜三个人的讨论,得出决定:如果要方便应用方的使用,最好是做一个新的、代码非常简单的新IO调度器。这样,应用方如果是用硬盘,那就选择cfq;如果是用SSD,就选择deadline;如果既用了高速设备,又想做资源隔离,那就选这个新调度器。既方便应用方灵活选择,又方便我们自己维护。(如果把新调度器做进deadline,后期的维护和backport就会越来越吃力)。新调度器的原理很简单:通过类似cfq的xxx_queue结构来记录各group的信息,针对每个group都创建一个list,存放从该group到来的io,然后在dispatch io时,用该设备的io request容量(即nr_request)减去已经发出但还没有处理完成的io数(即rq_in_driver),得出的就是该设备还可处理的io数(即下面代码中的quota),然后根据这个“可处理io数”和各group的权重,算出各group的list上可以dispatch的io数,最后,就是按照这些数去list上取io,把它们发出去了。

static int tpps_dispatch_requests(struct request_queue *q, int force)
{
        struct tpps_data *tppd = q->elevator->elevator_data;
        struct tpps_group *tppg, *group_n;
        struct tpps_queue *tppq;
        struct list_head *next;
        int count = 0, total = 0, ret;
        int quota, grp_quota;

        if (unlikely(force))
                return tpps_forced_dispatch(tppd);

        if (!tppd->total_weight)
                return 0;

        quota = q->nr_requests - tppd->rq_in_driver;
        if (quota < MIN_DISPATCH_RQ)
                return 0;

        list_for_each_entry_safe(tppg, group_n, &tppd->group_list, tppd_node) {
                if (!tppg->nr_tppq)
                        continue;
                tpps_update_group_weight(tppg);
                grp_quota = (quota * tppg->weight / tppd->total_weight) -
                                tppg->rq_in_driver;
......

这样,就是按cgroup的权重比例来发出io了,而且,不像cfq一个队列一个队列的处理,这个新调度器是拿到quota后从每个group list里都取一定量的io,所以也还保证了一定的“公平性”,同时,quota是按照设备的处理能力算出来的,所以也能保证尽可能把设备打满。简单、按比例并发处理(准确的说,是公平处理),所以我们给这个新io调度器起名字叫“Tiny Parallel Proportion Scheduler",简称tpps。代码已经合并到了ali_kernel,patch链接是1,2,3,4,5,6

另外申明一下,这个调度器并不复杂,也不高端,讲求的是简单好用,所以如果有哪位内核高手看到这个调度器后发表不满:“这么简单也好意思拿出来说?!”,那大可不必。我们就是做出来解决问题的,问题解决就ok,不必做得那么高深。我写这篇文章也只是方便有兴趣的DBA或者SA来试用和提建议,不是要说明这个调度器有多牛逼。

想要使用tpps调度器需要使用ali_kernel:

git clone git@github.com:alibaba/ali_kernel.git
git checkout dev
#编译并重启内核

机器重启后

modprobe tpps-iosched
#再用 cat /sys/block/sdX/queue/scheduler 可以看到多个io调度器,其中有tpps
echo tpps > /sys/block/sdX/queue/scheduler

我用一个快速设备测试了一下cfq和tpps对cgroup的支持,fio测试脚本如下:

[global]
direct=1
ioengine=libaio
runtime=20
bs=4k
rw=randwrite
iodepth=1024
filename=/dev/sdX
numjobs=4

[test1]
cgroup=test1
cgroup_weight=1000

[test2]
cgroup=test2
cgroup_weight=800

[test3]
cgroup=test3
cgroup_weight=600

[test4]
cgroup=test4
cgroup_weight=400

测试结果:

各个cgroup的IOPS

io调度器 test1 test2 test3 test4
cfq 6311 5334 3983 2569
tpps 8592 8141 7152 5767

各个cgroup的平均RT(Response Time)单位:毫秒

io调度器 test1 test2 test3 test4
cfq 161 221 241 398
tpps 114 127 141 177

各个cgroup的最大RT(Response Time)单位:毫秒

io调度器 test1 test2 test3 test4
cfq 312 387 445 741
tpps 173 262 260 322

从测试看来,tpps既能实现资源隔离,同样的设备和io压力下性能也略高于cfq。
重要提示:对tpps来说,只有当设备被压满时才会有不同cgroup的不同iops比例出现,所以,测试时压力一定要大。

scsi command的不同路径

本来是在调试一个新的io-scheduler(在基于rhel 2.6.32-279的ali_kernel上),但是出现了一些BUG_ON(),追查了很久,在添加了大堆的trace信息以后,终于在一个多月后的昨天发现了原因。

linux的block层在分配io request以后会对其调用回调函数 elevator_set_req_fn() 做初始化,在request放入每个设备的request queue后还要调用回调函数 elevator_add_req_fn() ,(这两个回调函数都是给io-scheduler来分别实现的,不同的io-scheduler会有各自不同的实现)看起来一个request一般应该经历这两个函数,而我从调试中开始怀疑:会不会有request只经历set_req,而不经历add_req?基于这个假设我翻了好几遍代码,发现当调度器切换的瞬间,新发出的request不走这两个函数。但毕竟是两个函数都不走,不是我怀疑的”只set_req不add_req“。

于是我不得不放弃自己的怀疑,实验了别的重现步骤和错误分析,一个月后还是没有进展。
靠着trace的不断堆砌,疑点终于还是回到了“只set_req不add_req”,这次把各种可能调用到get_request_wait的函数都加上了调试信息,又跑了一个星期,终于发现,确实有io request只走elevator_set_req_fn()而不走elevator_add_req_fn()!它就是scsi command。
当我们用sginfo一类命令查看IO设备的信息时,是会向kernel发送scsi命令的,这些scsi命令一样是走get_request_wait() --> get_request() --> blk_alloc_request()来分配request,也就一样要调用elevator_set_req_fn,但是,它们却是用blk_execute_rq_nowait来将新分配的request放入request queue的,而不是通常用的elv_insert(),而blk_execute_rq_nowait直接以ELEVATOR_INSERT_FRONT或ELEVATOR_INSERT_BACK为参数调用__elv_add_request,把request不经io-scheduler直接放入request queue了,不会调用elevator_add_req_fn()!

void blk_execute_rq_nowait(struct request_queue *q, struct gendisk *bd_disk,
                           struct request *rq, int at_head,
                           rq_end_io_fn *done)
{
        int where = at_head ? ELEVATOR_INSERT_FRONT : ELEVATOR_INSERT_BACK;

        WARN_ON(irqs_disabled());

        rq->rq_disk = bd_disk;
        rq->end_io = done;

        spin_lock_irq(q->queue_lock);

        if (unlikely(blk_queue_dead(q))) {
                rq->errors = -ENXIO;
                if (rq->end_io)
                        rq->end_io(rq, rq->errors);
                spin_unlock_irq(q->queue_lock);
                return;
        }

        __elv_add_request(q, rq, where, 1);
......

所以各个io-scheduler都是要自己处理这种情况的,不能产生“set_req后必然add_req”的假设。

不容易,一个多月才发现scsi命令的奇妙路径,也算是有所收获。

io调度器问题追查

linux内核里,所有的io都由bio结构来管理,然后bio又合并成request(个别设备会直接处理bio),最后request丢进设备队列里。一个设备能允许最多一次性丢进去多少request,由 /sys/block/sdx/queue/nr_requests 参数决定,默认值是128。
后来,io也有了cgroup,这就意味着我可以设定两个进程A和B到两个group,group1的weight是1000,group2的weight是500,那么如果两个进程都在使劲读文件,那B进程的带宽(或iops)只能是A进程的一半,但是,有例外,如果B进程采用aio,一次发出128个io请求,一下子把设备能处理的128个request缓冲都用完,那A进程就取不到新的request也就无法发出io了,于是,cgroup按比例分配就失效了,高优先级的A进程被低优先级的B进程“抢占”了资源。为了解决这个问题,Tejun Heo提了一个patch系列,办法就是让每个group都可以有nr_requests个请求(针对每个group和一个queue之间,建立一个request_list),这样,如果一个设备由被来自n个group的进程访问,它实际上可以分配(n * nr_requests)个请求(因为有n个request_list),这样group与group之间就没有了竞争,优先级反转的bug就这么fix了。这个patch系列我在做flashcache改造的时候一起打进了阿里kernel(基于redhat-2.6.32)。

故事还没有说完。
最近在rhel 2.6.32-279 kernel上开发io scheduler相关的东西,发现一个诡异想象:我正在使用cfq调度器的cgroup功能,io正哗啦啦的往下发,这时猛然把调度器切换到deadline,会有一部分cfq时发下去的io在调度起切换以后才从设备返回,然后,由于此时调度起已经变了,最后由deadline调度器处理这写返回的io。有点说不通,一个调度起发出的request让另一个调度起来做完成处理,万一request里有cfq特有的指针,deadline岂不悲剧了。于是仔细看了一下调度器切换的代码。

elevator_switch
    --> elv_quiesce_start
        --> blk_drain_queue

函数名很清楚,切换新调度器之前要把老调度器上的request都抽干(drain_queue),看看函数实现:

void blk_drain_queue(struct request_queue *q, bool drain_all)
{   
    while (true) {
        bool drain = false;
        int i;

        spin_lock_irq(q->queue_lock);

        elv_drain_elevator(q);
        if (drain_all)
            blk_throtl_drain(q);

        __blk_run_queue(q);
        
        drain |= q->root_rl.elvpriv;
        
......

        spin_unlock_irq(q->queue_lock);

        if (!drain)
            break;
        msleep(10);
    }
}

__blk_run_queue是把queue里的request都发往设备,难道不等这些request返回就直接切还调度器吗?“等request全部返回”这个功能是这个函数里的哪个语句完成的?又用trace_printk跟踪了一下,哦,是这句

drain |= q->root_rl.elvpriv;

当kernel分配request时调用 rl.elvpriv++,释放request是调用 rl.elvpriv--,elvpriv如果等于0说明所有发出去的request都返回了。这不挺对的吗?哦,request_list现在不只一个了,它是多个!也就是说,如果进程A放在cgroup的group1里,它在group1里申请的request都算在group1的rl.elvpriv里,而blk_drain_queue里却判断root group的root_rl的elvpriv,当然不对了。
再倒回去看看upstream的代码,原来Tejun Heo早就有补丁 https://lkml.org/lkml/2012/6/4/552 ,是我打补丁打漏了......唉,backport也是个高风险高要求的苦活啊。