在路上...

The development of life
我们一直都在努力,有您的支持,将走得更远...

站内搜索: Google

  :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::
一、耗子 vs Linux ?

        “鼠目寸光”,应该是个暴光率挺高的成语了,常用来说某人看事情没有深度,看不透本质。毫无疑问,这是一个贬义100%的词。但不管是认识什么未知事物,都一定会有个“寸光”的过程,如果有进而持续不断地努力,才可能做到对之了如直掌。

        Linux内核是个复杂的软件,作为一个成熟的操作系统核心部件,最可贵的就是它的开放源码。我想,有着“忧国忧民”抱负的程序员恐怕每天都会有“我要读懂它”的冲动。正是在这股“贼心”的驱使下,我开始了对Linux内核的学习。一路走来,我还远不能说是已经AtoZLinux内核,甚至不敢确定自己是不是已摆脱了“啮齿动物”的行列。但在领略其中的独特风景后,踏踏实实地体会到真是有些欲罢不能了。例如,内核中表面上看似稀松平常的五六行代码,有时却隐藏了许多秘密,反复思考之后,有时依然不得要领,在请教LKML上的牛人之后,才恍然大悟。每逢此时都禁不住感叹,“同是程序员,咋差距凑这么大哩?”。这种探索的乐趣是研究学习一般软件所不能获得的,我想这也是Linux内核的魅力所在吧。

        我猜想,有不少朋友都曾经尝试过阅读Linux内核源代码,但没有坚持下来。所幸自己“贼胆”不小,终于还算成功地迈出了第一小步。现在有了些小小的斩获,不甘独享,拿出来“显摆”一下,真诚地期望有更多的“小鼠”朋友能够加入学习Linux内核的行列。

        即便是解释了这么半天,以《鼠眼看XX》作为文章题目是依旧是需要些勇气的,澄清一下,我的本意其实是,文章中不会有太深的技术内容,难度水平是以一般Linux应用程序开发者能够看懂为限,当然,我会尽所能避免“寸光”,尽量使“知其所以然/阅读难度”的比值大些。

        最后,本文讨论Linux 2.6.13内核的调度功能,包括调度器的工作机制、时间片、优先级的计算等内容,也简要地讨论了一点staircase调度。闲话结束,开“看”!


二、“看上去很美"的调度器。

        讨论调度,最直接就是从它们功能入口谈起了。不同的调度器暴露其功能的方法不尽相同:

1、系统调用。这是最重要的方法。甚至于已经成为了一部分POSIX标准,但其数量并不太多,即使如此,这里也不可能一一展开讨论,我们关心是最常用的两个:nice()sched_setscheduler(),尤其是前者。

        2sysctl参数。某些调度器的配置项可以用sysctl命令调整。比如可以提高交互性的staircase调度补丁,它的几个版本(比如CK8中的)就带有compute等三个sysctl参数可以配置调度的行为。

        3、编译时的C常量。这是配置调度器功能的最底层手段。

显然,(3)适合系统程序员、(2)更多是管理员的任务。只有(1)方法才是我们应用程序开发者最常用的调度功能接口。

        先来简单回顾一下Linux上这两个系统调用的功能吧(下面可不是POSIX标准的原文哦):

  

                int nice(int inc);

调整当前进程的运行优先级。inc越小,当前进程的运行优先级会变的越高,反之优先级越低。inc可以是任意整数(尤其是可以小于0)。

                int sched_setscheduler(pid_t pid, int policy, const struct sched_param *p);

配置进程号为pid的进程的调度参数,包括策略policy,和参数p,目前p只是运行优先级。policy可以是软实时调度(SCHED_RR)、先来先服务调度(SCHED_FIFO)和正常的时间片轮转调度和优先级调度混合的调度策略(SCHED_OTHER)

本文只关心默认的最常用的策略SCHED_OTHERSCHED_RRSCHED_FIFO也不是想像的那么复杂,事实上,它们的实现比SCHED_OTHER简单的多,只是作为SCHED_OTHER的特殊情况对待罢了,体现在代码上,就是在正常处理逻辑上多了几个条件语句。下面的介绍中我们会绕过它们。有兴趣的读者可以过后直接研习代码,难度不大。

需要指出的是,上面介绍的有些地方是不太准确的,特别是术语“进程”和“运行优先级”比较含糊,下面我们就逐个揭开它们的面纱。

三、先瞧两眼Linux上的进程与线程。

        我想大家对“进程""线程"这两个概念的含义已经烂熟于心了,小生就不班门弄斧了。不知是哪本牛书的结论了,好像是《现代操作系统》,有个很精辟的结论,回忆如下:进程主要作为资源分配的单元,而线程更大程度上是作为任务调度单元使用的。

众所周知,UNIX进程模型是基于进程复制的,这个复制过程集中体现于fork()系统调用。系统启动后,首先在用户空间建立一个PID1叫作init的进程。然后,整个系统中的所有用户空间上的进程都是由这个init进程直接或间接复制出来的,只要系统在正常运转,这个init进程就可以说是“永不落进程”。

Linux也采用了UNIX进程模型,甚至更彻底:Linux上创建线程也是通过复制方法的得到的。新的NTPL线程库采用的是MN的线程模型,即M个用户空间线程对应N个内核线程。虽然表层的API仍然是依照POSIX标准的,但已经与前任线程实现有了很大不同。

说到底,创建进程最终会用到fork()系统调用,而创建线程则最终使用clone()系统调用。让我们看一眼这两个系统调用的直接实现,不要慌张,它们很简单:


asmlinkage int sys_fork(struct pt_regs regs)
{
    return do_fork(SIGCHLD, regs.esp, &regs, 0, NULL, NULL);
}

asmlinkage int sys_clone(struct pt_regs regs)
{
    unsigned long clone_flags;
    unsigned long newsp;
    int __user *parent_tidptr, *child_tidptr;

    /* 我们在这里省略几行代码 */
    return do_fork(clone_flags, newsp, &regs, 0, parent_tidptr, child_tidptr);
}



很明显,fork()clone()系统调用使用的是同一个函数do_fork(),区别只有参数不同,至此,我们可以大胆的推测,Linux在对待进程和线程的问题上,是将两者看作基本相同的概念。事实上,在内核里它们都使用task_struct结构体描述,传统的进程号保存在该结构的tgid成员(线程组ID)中,而线程号则保存在pid成员中。所以,在下文中我们统称“进程”和“线程”两者为“任务”。在后面介绍nice()sched_setscheduler()两个系统调用的时,所用的术语“进程”或“线程”,如果不加特殊声明,都可以换成“任务”。

    有Linux开发经验读者可能知道,Linux上还有一个系统调用可以创建新任务,这就是vfork()。你可能对它的实现方式有兴趣,下面是就是它的直接实现:

 


asmlinkage int sys_vfork(struct pt_regs regs)
{
    return do_fork(CLONE_VFORK | CLONE_VM | SIGCHLD, regs.esp, &regs, 0, NULL, NULL);
}


嗯,看来vfork()依旧没有摆脱do_fork()的如来掌心。

探讨对Linux上进程和线程的实现是十分有意思的挑战,但对它们作深入讨论已经超出本文的范围,如果有时间,下次我们再用“鼠眼“仔细瞧瞧Linux任务。

三、nice()系统调用。

        限于这篇文章的写作目标,这里不可能完整解释与调度有关的每一行代码,但绝不放过每一个有价值的地方。从现在开始,我会以简化代码的方式引用Linux内核的代码,简化的标准是只保留与主功能直接相关的部分,例如内核中的各种同步细节、次要的错误检查,甚至安全方面的代码,我都会省略掉,但是我只删代码,绝不会修改原有代码,这样既有利于抓住核心环节,缩减篇幅,又不妨碍有兴趣的读者顺着这些线索亲自“咀嚼”代码。

    下面就是与nice()系统调用有关的简化代码:


asmlinkage long sys_nice(int increment)
{
    int retval;
    long nice;

1>  if (increment < -40)
        increment = -40;
    if (increment > 40)
        increment = 40;

2>    nice = PRIO_TO_NICE(current->static_prio) + increment;
3>    if (nice < -20)
        nice = -20;
    if (nice > 19)
        nice = 19;

4>    if (increment < 0 && !can_nice(current, nice))
        return -EPERM;

5>    set_user_nice(current, nice);
    return 0;
}


    细心的读者可能已经发现,所有系统调用实现函数都是以sys_加上系统调用名称命名的,这是Linux内核的一个命名习惯。Linux系统调用的机制在不少的内核书上都有介绍,这里就不多说了。可以粗略地认为,sys_函数的参数就是我们在C程序里面传递给系统调用时参数。

下面我们就按照上面代码中的注脚的顺序分析一下这个函数:

1> 非常简单,常规的边界值检查。小于0increment最终会提高优先级,大于0的反之会降低运行优先级。我想现在大家可以知道了,前面所说“inc越小,当前进程的运行优先级越高,反之优先级越低。”其实是不准确的——小于-40inc值都是按-40对待的。

2> 这行代码碍眼的地方有两处:

1、宏current。我们知道,Linux内核是使用task_struct结构表示任务的。Current表示的就是正在执行nice()系统调用的那个任务的task_struct结构。current是从当前内核栈中获取的,task_struct结构恐怕可以跻身于Linux内核里最复杂的几个数据结构了,但我们用后脚跟也可以想像的到,其中肯定保存有任务运行上下文信息、任务运行优先级、运行状态、时间片之类的基本信息。成员static_prio就是所谓的“静态优先级”。一会儿我们再详细介绍它。


2、宏PRIO_TO_NICE()。顾名思义,它的功能就是将任务的静态运行优先级(即current->static_prio)转换成所谓的nice值。经过简单的推导后,可以知道nice值的取值范围是[-2019],这也是许多经典UNIX编程书籍所介绍的nice值范围相同。接着,这个旧nice值与increment相加,将结果赋给变量nicenice数值越小,换算得到的静态优先级越高。由于,nice值和任务的静态优先级是一个线性对应关系。所以,我们可以将静态优先级与nice值看成是一个东西。只不过nice是用户直接可见的,静态优先级则隐藏在内核里的。


3>
常规的边界值检查,确保nice不超过边界。


4>
我们知道,只有具有管理员权限的帐号才可以增加任务的运行优先级。这里所做的检查就是这个审查这个条件了。如果increment<0就意味着提高运行优先级,此时就利用can_nice()进行实际的检查。

5> set_user_nice(),正式开工喽。代码如下:


void set_user_nice(task_t *p, long nice)
{
    unsigned long flags;
    prio_array_t *array;
    runqueue_t *rq;
    int old_prio, new_prio, delta;

1>    if (TASK_NICE(p) == nice || nice < -20 || nice > 19)
        return;
    
2>    array = p->array;
    if (array)
        dequeue_task(p, array);

3>    old_prio = p->prio;
    new_prio = NICE_TO_PRIO(nice);
    delta = new_prio - old_prio;
    p->static_prio = NICE_TO_PRIO(nice);
    p->prio += delta;

4>    if (array) {
        enqueue_task(p, array);
        if (delta < 0 || (delta > 0 && task_running(rq, p)))
            resched_task(rq->curr);
    }
}


        下面是代码的解释:

        虽然在我们的场景中,p肯定是当前任务(nice()系统调用只能修改当前任务的nice值),但set_user_nice()还能为其它系统调用所使用,比如后面提到的setpriority(),所以我们不能就此断定p就是当前任务。这样,set_user_nice()有两个参数也就不足为怪了。

1> TASK_NICE()task_struct为参数,作用与前面提到的PRIO_TO_NICE()相同,得到的结果还是任务的nice值。至此,这个if语句的含义已经很明显了。

        2> 这里看起来简单,但解释起来有些复杂。我想许多读者可能听说过2.6Linux内核任务调度器是完全重新设计实现的,它的时间复杂性是O(1)级的。这里的array,即“优先级数组”,是实现O(1)算法的重要数据结构。对它的集中介绍过后会呈现给大家。现在只要知道,这是在把任务p从运行队列删掉。

        3> 这是我们关心的焦点:

先看old_prionew_prio。虽然两者表示的都是运行优先级,但它们有很大不同。注意是old_prio是从p->prio复制过来的,其实它就是传说中任务的“动态优先级”。再看new_prio,它是从nice值换算过来,换算过来的什么呢?静态优先级呗!

        再下面就是简单地更新任务优先级的过程,不用多说了。

        4> 先扼要的解释如下吧:

首先,如果前面把任务从运行队列中删掉了,这里就将任务重新加入到运行队列的尾部。然后根据以下两个条件判断是不是让运行队列rq上的当前任务(rq->curr)放弃处理器,而选择下一个任务进行(注意:此时p也在运行队列rq里):

        1、如果任务p的优先级提高了(delta<0)。

        2、任务优先级降低(delta>0)了并且任务p正在运行(task_running(rq, p))。把task_running的定义列出来也许会更直观:

#define task_running(runqueue, task) (runqueue->curr == task)

        因此,nice()系统调用一般都会使当前任务放弃处理器,但如果它满足调度器的任务选择要求,它还会马上占用处理器的。有些读者可能觉得反复加入运行队列没有什么必要,但注意在再次加入运行队列之前,任务的动态优先级很有可能已经发生了变化,这些看似多余的操作,其实是实现O(1)调度的重要手段。

        到这里了,不能不说还有一个能够影响任务的优先级的系统调用:setpriority(),但它在本质上只是一个扩展了的nice(),它的实现代码也非常简单,就留给读者自己“咀嚼”吧。

四、sched_setscheduler()系统调用。

        这个系统调用的调用层次和代码都比nice复杂些,所涉及的也有不少我们这里不感兴趣的东西,因此就不再以展示代码的方法介绍它们了。这里仅在功能层次上,从与nice()对比的角度上对它做一个简要介绍:

1、因为sched_setscheduler()系统调用不仅可以修改当前任务的调度策略和优先级,还可以修改指定任务的这个信息。所以,它的合法性检查更严格些,最重要的是增加了用户身份验证,当然这个检查依旧是在task_struct结构上做的。

        2、和nice()一样,sched_setscheduler()也不特别区分进程和线程,将两者作等同处理,该任务也会有重新加入运行队列的行为。

        3sched_setscheduler()只修改动态优先级,对于默认调度策略,它被设置为与静态优先级相等。

五、静态优先级里的猫腻。

        说了这么多静态优先级如何如何,它到底是个什么玩意儿?现在就让我们剥掉其上所有可能的“耗子药”,弄清楚它到底是怎样影响进程的。“静态优先级”,之所以冠之以“静态”前缀,是因为内核自己从不主动修改它,只有通过系统调用才能修改它。那么,它在调度里到底扮演什么角色呢?容俺仔细道来:

        1、计算任务时间片。

        让代码说话,先看task_timeslice()实现:


/*
 * task_timeslice() scales user-nice values [ -20 ... 0 ... 19 ]
 * to time slice values: [800ms ... 100ms ... 5ms]
 *
 * The higher a thread's priority, the bigger timeslices
 * it gets during one round of execution. But even the lowest
 * priority thread gets MIN_TIMESLICE worth of execution time.
 */


#define SCALE_PRIO(x, prio) \
    max(x * (MAX_PRIO - prio) / (MAX_USER_PRIO/2), MIN_TIMESLICE)

static inline unsigned int task_timeslice(task_t *p)
{
    if (p->static_prio < NICE_TO_PRIO(0))
        return SCALE_PRIO(DEF_TIMESLICE*4, p->static_prio);
    else
        return SCALE_PRIO(DEF_TIMESLICE, p->static_prio);
}

        从名字上也看得出来,这个函数就是用来计算任务时间片的。一般说来,只有在时间片消耗光的时候才重新计算任务的时间片,而这个计算过程只与静态优先级有关。这个函数的逻辑很简单,如果任务pnice<0(也就是说静态优先级<120)的话,按表达式   

        SCALE_PRIO(DEF_TIMESLICE*4,p->static_prio)

        计算静态优先级,否则按SCALE_PRIO(DEF_TIMESLICE, p->static_prio),让我们仔细看一下静态优先级是怎么影响时间片计算的,如果设静态优先级为Ps,总结时间片的计算公式如下:

        当静态优先级小于120时:

        time_slice_h = DEF_TIMESLICE*8*(MAX_PRIO-Ps) / MAX_USER_PRIO

        当静态优先级大于等于120时:

        time_slice_l = time_slice_h/4

其中:

DEF_TIMESLICE是默认时间片的意思,它的值是100 jiffies,在默认的配置里(宏HZ等于1000),它等于100(单位:毫秒)。

MAX_PRIO是最大的任务优先级数值。由于数值越大的优先级表示的优先级越低,所以它代表实际上最低的优先级。取值140

MAX_USER_PRIOMAX_PRIO-MAX_RT_PRIO,即40。除了Ps外,所有参数都是编译时常量。

        time_slice_h = 20 * (140-Ps)

        更明确地写出它和time_slice_l的取值范围(单位:毫秒)

        time_slice_h[20, 800]

        time_slice_l[5, 200]

        换句话说,静态优先级越高,时间片就越长。

请注意,静态优先级只有通过系统调用才会改变。并且,nice值与静态优先级是线性关系,因此,当我们修改任务的nice值时,实际上也会影响任务时间片的计算,当然只在具有管理员(root)权限的帐号下才能做到增大时间片。你任务的时间片越长会怎么样呢?它的吞吐量会增长,但响应能力或交互性变差。

2、判断任务的交互性。

所有“体面”的操作系统参考书都会对所谓CPU-bound任务(计算密集型任务)和I/O-bound任务(I/O密集型任务)有所介绍。几乎所有操作系统也都会对两者区分对待,但真正的困难在于一个任务在其整个生命过程中,可能一会儿是计算密集型任务,一会儿又变成了I/O密集型任务。即便是有任务比较“本份”,依赖于任务自身提供这种提示信息也是不可取的,因为这给了不良用户危害整个系统的机会。

Linux不无例外地对这两种任务做了区分,它是通过评估所谓的“平均休眠时间”的判断任务的种类的,这种方法是基于这样一个事实:现代操作系统的I/O模型绝大多数都使用中断机制,这里也包括以中断为基础发展起来的其它I/O机制,例如最常用的DMADMAI/O操作完成时也是使用中断机制将这个完成消息通知CPU。而触发中断的任务一般会引起任务状态上的变化,通常都进入某种休眠状态。通过计算“平均休眠时间”就可以知道一个任务I/O的历史,进而根据程序的局部性原理预测它的I/O倾向。所谓“交互性任务”。直观上看,就是经常与像键盘、鼠标这样的输入设备和显示设备打交道的任务。这正符合I/O密集型任务的定义。所以,Linux将两者一视同仁。

现在,我们有了用来判断任务交互性的有力依据,但还缺一把尺子。这时静态优先级再次“粉墨登场”,这就是宏TASK_INTERACTIVE(p)的实现了:

#define TASK_INTERACTIVE(p) ((p)->prio <= (p)->static_prio - DELTA(p))

        推导计算公式的过程有些繁琐,这里就不将步骤一一罗列出来,只给出来结果:

 task->prio <= task->static_prio * 3/4 + 28

 从中可以看出,低静态优先级(静态优先级数值较高)的任务,更容易被判定为“交互性”任务,进而在进一步的调度中得到“晋升”的机会,这可以抑制低优先级任务长时间得不到CPU,从而使它们难以响应自身的IO子例程的完成通知。

        此外,还有一个宏用来判断上次休眠是否属于“交互性休眠”:



    #define INTERACTIVE_SLEEP(p) \
        (JIFFIES_TO_NS(MAX_SLEEP_AVG * \
            (MAX_BONUS / 2 + DELTA((p)) + 1) / MAX_BONUS - 1))


上面的DELTA(p)也与任务的nice值(静态优先级)有关,简化后如下:

INTERACTIVE_SLEEP(task) = 799 + 25 * nice

        对它的分析就留给读者吧:如果静态优先级越高,就会......


3、交换优先级数组的频率。

拖了现在,终于到了“供述”调度器最核心的部分了。有着FreeBSD/Solaris经验的朋友会觉得Linux在这里与它们有些相似。

Linux的调度算法的O(1)效果主要来自于它的优先级调度的设计。拍拍脑瓜,如果我们自己实现优先级调度的话,直接能够想到的方法就是给每个任务结构分别加一个优先级成员,当需要选取一个任务运行时,就搜索任务链表找到最大优先级的若干任务中的一个,拿出来运行就行了。这样当然也没有什么错误,但效率一定不高,我相信,聪明的读者一定想到各种各样的优化方法。但我们还是先来分析一下为什么这样不好吧,首先在中等繁忙的系统中任务总数达到数千个是轻而易举的事情,而动辄就得搜索整个任务链表的设计显然是不现实的,并且任务切换是个很频繁的操作,这更是恶化了执行效率。其次,对于有实时要求的任务,在任务切换时这种不可预测的搜索延时更是不可接受的。相似的环节还有重新计算时间片和优先级。如果我们在固定的时间点上集中计算所有任务的时间片和优先级的话,也会带来与上述搜索任务相同的负面效果。

不过,LinuxO(1)调度算法也没有想像的复杂,它的基本想法是这样的:事先规定好优先级的数量,在Linux上它是141,但只有IDLE任务才使用最低的优先级140MAX_PRIO)。所以,Linux将一整个任务链表拆分成140个任务链表,每个优先级对应一个。每当有任务使用完了它的时间片时,就立即重新计算它的新时间片和动态优先级,并将它插入到对应的动态优先级的任务链表尾部。这样,即使任务状态和数量随时变化,每个任务链表上的任务也都会具有相同的优先级,并且是一个FIFO链表,因此更准确地说,任务链表应该称为“任务队列”。这140个任务队列构成一个任务队列数组,这个任务队列数组的索引其实就是动态优先级,所以它也称为优先级数组。在选择任务运行时,它采用从索引0(优先级0,最高优先级)开始的线性搜索这个优先级数组方法,它首先定位到一个非空的任务队列。因而搜索结果会是一个具有最小索引值的任务队列,而索引值其实就是动态优先级呀,这便是“数值越小,级别越高”的真实原因了。最后,选取这个任务队列中头一个任务运行,也就是最先进入该任务队列的那个任务。最后,这140个任务队列和一些附加信息构成“运行队列(runqueue)”。(注意区分“任务队列”和“运行队列”)。

当然,真实的Linux调度比上面我们所介绍的还要复杂一些,主要的区别是每个CPU有两个优先级数组(每种优先级对应两个任务队列)。一个是活动优先级数组、一个过期优先级数组。那些时间片没消耗完的任务都在活动数组中,过期数组里放的是把时间片都花光了的穷光蛋任务。其实这么定义它们也不是十分精确,但这样的近似不伤大雅。当活动数组中没有任务时,内核就交换这两个数组。不用担心,这个交换过程也只是做个指针游戏,很高效的。

说到这里,读者可能已经晕了,看看下面的图可以清理一下有些紊乱的思绪:




如果你打算研究代码,那么“优先级数组”对应于prio_array_t类型,“运行队列”对应于runqueue_t类型。

那为什么要有两个优先级数组呢?先看看调度算法作者Ingo写的文档(Documentation/sched_design.txt)关于这点是怎么说的吧,下面是原文和拙译:

the split-array solution enables us to have an arbitrary number of active and expired tasks, and the recalculation of timeslices can be done immediately when the timeslice expires.

“数组分离”的解决方案允许我们在活动数组和过期数组中保存有任意数量的任务,并且可以在任务花光时间片时立刻就重新计算好计算片。

不知道Ingo给出的解释有没有说服大家,至少笔者认为这两个理由都不太令人满意。首先,第一个理由“可能保存有任意数量的任务”,单优先级数组(SPA)算法也可以满足这个要求,所谓可以有“任意数量的任务”,一般考虑的是性能问题,但这里的搜索过程是以优先级搜索的基础的,和任务数量没有什么大关系。是的,每种优先级可能会有很多任务,但搜索过程只需要使用其中头一任务就行了,根本不需要遍历特定优先级的任务队列。但是,这并不是说这条理由不正确,只是它没有说的点子上。其次,“马上重新计算好计算片”,计算时间片能否立即完成与有几个优先级数组一点关系也没有,事实上,甚至于像staircase调度算法,这样有着更复杂时间片计算规则的SPA算法,它的时间片也是随用随算的。也许,这句解释里面有历史上的原因,也许,还有笔者还没领会到更深的技术背景原因,如果是这样,请大家不吝赐教。

笔者认为设置两个优先级数组,更多的是杜绝饥饿任务的出现。所谓“饥饿任务”,就是长时间占用不到CPU的任务。优先级调度的原则就是只选高优先级的任务,不选低的。让我们想象一种极端情况,系统内只设置两个优先级,如果高优先级的任务队列很长,此时低优先级的任务就长时间得不到CPU而成为“饥饿任务”。你可能要说,低优先级任务吗,就应该这样处理呀。我觉得这样的说法是错误的,笔者更认为优先级的高低是任务与CPU的亲近程度。优先级高任务与CPU打的更火热,优先级低的任务离CPU疏远一点,低优先级任务和CPU 绝不是“老死不相往来”的关系。所以,长期不让低优先级的任务占用CPU是不合理的,尤其是桌面系统里,这可以在一定程度上可以改善交互性。如果我们生生地在优先级调度上开个口子,不是不行,只是有些......,通过设置两个优先级数组,适时交换两者,实在是个十分巧妙的主意。只要任务的数量达到一定程度,饥饿任务的形成趋势就可能越发明显。这也是我刚才为什么说Ingo的第一个理由不是不正确,只是没说到点子上的原因。当然,即使是SPA算法,也有可能通过其他方法避免任务饥饿情况的发生。这里没有是非之分,在我们后面还要集中介绍的staircase算法里,就是用调整任务优先级的方法避免饥饿任务的。

OK,上段我们说到任务可能会感到“饥饿”。那我们怎么判断出这种情况呢?由于存在两个优先级数组,活动优先级数组上的任务是可以很快得到运行的。可能饥饿的任务只会发生在过期数组中。具体的判据需要综合考虑,比如有运行时间上的考虑,运行中的任务数量、任务优先级的上考虑。这里我们只关心与优先级有关的部分,在判断某运行队列上的过期数组中任务是否饥饿的宏(EXPIRED_STARVING)有如下一个条件:

    runqueue->currrent_task->static_prio > runqueue->best_expired_prio

best_expired_prio成员记录了runqueue(运行队列)中过期数组中最高优先级任务的优先级,特别说明的是,它记录的是静态优先级。所以这里是在与刚刚消耗完时间片的那个任务的静态优先级相比较。如果过期数组中的这个任务的优先级高就说明有任务“饥饿”了,具体是怎么处理呢?大家可以研究一下时间片轮转算法的主实现函数scheduler_tick(),难度不是太大,如果在这里用语言描述的既不精确,也有故意骗稿费之嫌。这里没用动态优先级作为比较标准是有道理的:马上会讲到,动态优先级只是任务在一个时间片内的临时优先级,它是根据任务的动态运行情况综合评估出来,它不是用户直接可控的,并不能准确代表要运行它的用户的直接意图。静态优先级是用户可以直接控制的,并且只有用户通过系统调用才可以改变。因此判断任务的饥饿与否也与用户的取向有一定关系。高静态优先级任务数量越多,判断任务饥饿的标准越低,从而交换优先级数组的效率就越少,带来的直接性能影响就是吞吐量提高,但交互性会有所降低。

顺便说一下,有些老兄可能觉得100多个优先级的搜索过程也不快呀,实际上,Linux在搜索优先级时也并不是真的去遍历每个任务队列检查它们是否为空。首先,Linux为每个优先级数组都准备了一个对应的整数数组,这个整数数组中的每个位代表优先级数组中一个任务队列,只要我们增加某个任务到任务队列中去,就会将对应位置1,如果某个任务队列为空,就将对应的比特位清零。这两个动作正是我们前面看到的enqueue_task()dequeue_task()的功能。比特级的置位和清零,搜索,在许多体系结构上都有单独的指令支持,比如在i386上是它们是btslbtrlbsfl等指令。所以这个过程是很快的。

六、动态优先级里的猫腻。

        动态优先级,又是个什么玩意儿呢?

        相对于看似简单实则内涵丰富的静态优先级,动态优先级则刚好相反,它的计算比静态优先级复杂一点,但其中道理很大一部分已经在介绍优先级数组时提到了。

        还是先看看代码吧:




static int effective_prio(task_t *p)
{
    int bonus, prio;
    
    bonus = CURRENT_BONUS(p) - MAX_BONUS / 2;
    prio = p->static_prio - bonus;
    
    /* MAX_RT_PRIO = 100 */
    /* MAX_PRIO = 140 */
    /* 小于MAX_RT_PRIO的优先级,只用实时任务。*/
    if (prio < MAX_RT_PRIO)         
        prio = MAX_RT_PRIO;
    if (prio > MAX_PRIO-1)
        prio = MAX_PRIO-1;
    return prio;
}


我想这里对大家唯一感到神秘的就是CURRENT_BONUS(p)MAX_BONUS了。(如果不是这样,我的这篇文章可就真是太失败了~)。OK,让我们一一揭开这些最后的谜底:

#define MAX_BONUS (MAX_USER_PRIO * PRIO_BONUS_RATIO / 100)

#define CURRENT_BONUS(p) \

(NS_TO_JIFFIES((p)->sleep_avg) * MAX_BONUS / \

MAX_SLEEP_AVG)

要想摸透这两个宏,还得从任务的“交互性”说起。刚才我们说到,从“平均休眠时间”里我们得到了任务的是计算密集型的还是I/O密集型的。但仅仅分类不是我们的目标,我们的最终目的是对I/O密集型任务做些特殊照顾。那么用什么手段呢?时间片,我们已经讨论过了,它和任务种类根本不靠谱。那就只有通过任务的优先级了,这就是任务的另一种优先级,即耳闻已久的“动态优先级",它与静态优先级不同,这个优先级随任务的实际运行情况(主要是平均休眠时间)调整。这个调整就是上面的代码中反复提到的“BONUS”了,它是一个“代数BONUS”,可正可负。既然有奖有罚。就得有个额度吧,这就是MAX_BONUS了,根据它的定义:

MAX_BONUS = ( 40 * 25 ) / 100 = 10

        这里的MAX并不是直观上的最大奖励值,理解它的最好方法就是分析CURRENT_BONUS(p),它计算任务p应该获得多少奖励:

CURRENT_BONUS(task) = task->sleep_avg * MAX_BONUS / MAX_SLEEP_AVG

其中:

MAX_SLEEP_AVG是我们硬性规定的最大平均休眠时间,如果任务的休眠时间大于它,也强制将其设置成这个最大值。

CURRENT_BONUS(task)中的计算公式是不是有点绕?我作点手脚,大家再看看:

       CURRENT_BONUS(task) = task->sleep_avg MAX_SLEEP_AVG * MAX_BONUS

        在继续之前,大家可以思考一下为什么内核使用第一种看似“别扭”的写法呢?限时120秒。计时开始!

        ......

        提醒一下下,回忆C语言的整数操作符/的运行规则。有答案了吗?如果还没有,看来你得复习复习C了。

        OK,言归正传。我想大家应该清楚了,再明确一下,CURRENT_BONUS(task)的返回的值的范围是[010],再结合effective_prio()中第一行代码:

        bonus = CURRENT_BONUS(p) - MAX_BONUS / 2;

        即,bonus = CURRENT_BONUS(p) - 5;

        我们现在就可以知道一个任务的动态优先级的奖励最大是-5,惩罚值是+5。也就是说每次动态优先级的调整幅度是上下5级。不过还是这里还是有点绕,以至于有时候我写代码也被绕了进去:不管是静态优先级,还是动态优先级,优先级数值越高,代表的优先级越低。所以,-5是奖励任务,+5是在惩罚任务。不仅仅是Linux,许多UNIX类的操作系统都是这设计的。

七、说了这么半天,我还是不知道调度到底是怎么工作的!!!

       的确,这里说了一溜八开的Linux是怎么通过摆弄优先级和时间片收拾一个个任务的,但这一切究竟是怎么发生的哩,也就是我们怎么进入调度过程的呢?虽然这不是本文的重点,但是如果不说一下就感觉有些不完整。

也不用把调度程序想像成“玉皇大帝”似的,它也是由代码堆砌而成,只是功能和入口特殊一些罢了:毛德操老前辈有句比喻很贴切:“时钟中断好比系统的脉膊。”,它是实现时间片轮转算法的命门所在。定时器每秒产生固定数量的时钟中断,在这个中断的处理函数里会调用scheduler_tick()函数。这个scheduler_tick()负责减少当前任务的时间片,并且也维护上面所说的“平均休眠时间”,只要当前任务的时间片使用干净了,就重新计算新时间片和动态优先级,然后做一些清理工作,最终触发所谓的主调度功能入口函数schedule()schedule()主要负责实现优先级调度算法,比如搜索下一个要占用CPU的任务,交换优先级数组等等都是在这个函数里完成的,最后,它会导致任务的切换。

        进入调度的途径当然不只一种:任务还可以通过休眠、主动放弃方法进入调度。主动休眠,比如任务使用waitpid()、启动一次IO操作;有些系统调用也有使其调用者放弃CPU的副作用。可能大家都知道,2.6内核可以配置为支持内核级抢占。“抢占”也是进入调度的一种方式,它也不像想像中的那么神秘和无规律似的“霸道”,可惜详细描述抢占已经超出本文的目标,许多较新的内核材料对抢占也有不错的综述。有机会,我们下次就再瞧瞧它。

八、再用余光瞥一眼Staircase

        Staircase,这是我们上面反复提到的另一种调度算法。它是有名的CK补丁的一个重要组成部分,主要用于提升Linux内核任务调度的交互性和及时响应能力,其目标是应用于桌面系统。如果诸位还没把操作系统的知识还给老师的话,那么可以将staircase算法看成是‘多级队列调度算法’的一个变种,如果有读者熟悉VMS的话,那么,有人还说,Staircase让他想起了VMS的调度。

        下面描述的是staircase v12版本,它和以前的v7/v8已经有些不同了:

        Staircase调度的基础设施,与默认的Ingo调度相同,它也有“优先级数组”、“运行队列”的数据结构,并且绝大多数成员还保持不变。在数据结构唯一重要的变化就是每个运行队列只有一个优先级数组了,但算法复杂一些。

        在Stairecase中,每个任务初始时会用静态优先级初始化动态优先级开始运行,每过一个时间片就向低优先级“降级”。如果动态优先级最终降成最低优先级(MAX_PRIO-2),就再从静态优先级开始重复这个“降级”过程。不一定每次都最终“降级”到最低优先级。根据任务的运行情况,调度算法会增长其在最高优先级上的时间片。

        在这个算法里,任务在占用CPU时有两种时间片。这里暂称之为一个“粗粒度时间片”,一个是“细粒度时间片”。粗粒度时间片由一定数量的细粒度时间片组成。在每个粗粒度时间片内,任务的优先级由任务自身的静态优先级向低优先级依次降低,纯计算密集型任务在每个优先级上停留的时间都一样,即一个细粒度时间片。I/O活动比较多的任务,会在最高优先级上停留的时间比较长,由于粗粒度时间片是固定的,所以这导致IO密集型任务不会滑落到很低的优先级上。不过,即使停留在高优先级上的时间片比较长,任务也不会一次使用完毕,它会像Ingo的算法那样,将长时间片再打成几个片断分散执行。所以,所谓的“细粒度时间片”其实可以直接对应到Ingo算法中的时间片概念上;而“粗粒度时间片”可以看成是“细粒度时间片”的一个轮回周期,它是“细粒度时间片”的整数倍。

        由上可见,在Staircase每个任务都频繁徘徊在多个优先级之间,这一方面可以带来交互性和响应能力的提高(这一点不太容易定量测量,但可以通过在重负荷下比较连续输入字符的连续性得出结论),但是另一方面,细粒度时间片通常都比Ingo算法中的时间片短很多,而每次调整优先级实际上都是一次任务切换过程(这里应该有优化的余地!),这不可避免地带来吞吐量的下降,这可以通过大规模复制文件测量,复制速率大约下降了30%

 不过,值得指出的是,Staircase是可以在运行时调整调度参数的,我们可以选择配置是倾向于桌面系统的,还是服务器系统的,上面的吞吐量下降30%是在设置为“桌面系统”后测量得到的结果。如果设置为服务器系统的调度方案,其内部会通过一些条件判断,减少这种原因引起的任务切换次数。

十、一些阅读代码的建议。

        相对于复杂的虚存系统而言,任务调度是比较简单的内核组成部件了。从个人经验上上看,我也推荐从任务、调度这一块内容开始阅读内核。因为它的难度不太大,至少牵扯到内核其它部分较少。但仍有一些必要的预备知识需要掌握:GCC内嵌汇编语言方法,特定体系结构(一般就是指i386)方面的知识。

        关于Linux调度的材料最好的可能就是这份来自于SGI公司的文档《Understanding the Linux 2.6.8.1 CPU Scheduler》了,虽然其中有些内容与最新的代码不符,但绝大多数介绍非常有阅读价值。这篇文档可以在网上很容易的找到。

阅读代码好比是在打一场艰苦的攻坚战,武器对战士至关重要。选择好代码阅读工具可以事半功倍,当然每个人的习惯不同,在选择工具时通常也是只选合用的,最好的未必就合用。我个人绝大多数时间在Linux上工作,所以这里只有Linux上的经验可以介绍:我个人比较喜欢Source-Navigator Extensions,这是一个可以sf.net上找到的开放源代码的软件。它是对标准的Source-Navigator做了一些非常好用的扩展功能,截图见下图。缺点是需要使用者“宽容些”,因为它有些bug可能导致无法在项目添加新目录,甚至自身崩溃,例如在项目中增加单个文件时是会出现Stack trace窗口的。如果笔者熟悉Tk并且有时间的话,倒是很愿意修复这些问题。不用担心,这些无法使用的功能,都是可以想法绕过去的,笔者认为它利大于弊。另一个推荐使用的工具是LXR。这是一个基于Web的代码阅读工具,此外,GNU Global也可以用用。




        如果你还打算研读虚存系统,除了经典的《Understanding the Linux Virtual Memory Manager》,还建议先看一下这篇文章《2QA Low Overhead High Performance Buffer Management Replacement Algorithm》,因为本质上,Linux现有的页面替换算法是简化过的2Q算法。如果你有精力,还可以简要阅读一下关于Clock-ProCAR算法的文章。现在似乎有这样一种趋势,页面替换算法在朝自适应(Adaptive)方向的发展。此外,有篇关于《The Performance Impact of Kernel Prefetching on Buffer Cache Replacement Algorithms》的文章,结论虽然不出人意料,但也非常值得一读(感谢周应超兄台的推荐)

十一、结语。

        行文至此,联想起以前翻译过Python的一些东西,小有感触,不管是翻译还是写作都不是件轻松之事。这篇文章虽然不长,却也足足横跨了俺几周时间,但如果能激起大家能对Linux内核的兴趣的话,我的这些时间就是非常物超所值了。同时也希望《鼠眼看Linux 中断处理》、《鼠眼看Linux VMM》,或者《鹰眼看Linux ABC》等类似文章的出现。

posted on 2009-08-27 20:52  palam  阅读(2391)  评论(0编辑  收藏  举报