linux内核分析笔记----调度

跟風遠走 提交于 2019-12-21 03:38:09

       调度?咋这熟悉,我们是不是常在哪里听到。没错,是的,调度我们时常听过,比如交通管制调度啦等。这不,夏天这热, 标语贴的好:相应国电电力调度,做文明市民,好别扭啊!不管了。你要是还是不懂,再啰嗦讲个事,过年回家,和漂亮的GF回家,为了张普通的硬座票还要排老久对,甚至还可能被坑拿到黄牛票,这时你嘴里咧咧的啥:XX,啥火车站,做的啥春运调度啊!唉,这次你说到点上了。

       总结一下:调度就是通过调度程序的合理调度,实现系统资源的最大限度发挥作用。多进程的并发正是这样的效果。其实原理一点也不复杂,道理也一样简单:只要又可以执行的进程,那么就总是会有进程正在执行。但简单的问题也会复杂化,比如:我们买票为啥抱怨调度,归根接地感谢当年的人海战术(多说一句,其实现实的很多问题,一个人海战术解决所有,这战术中国人用起来最得心应手)。好么,一般系统中进程的数目总会比处理器的个数多,所以竞争在所难免,调度的问题就集中在解决竞争的问题。

       种类问题不多说:抢占和非抢占。linux提供了抢占式的多任务模式,下一阶段谁得到CPU的执行时间由调度程序决定。这怎么决定,是不是请个客,喝个酒啥的。对不起,linux无情的说,我是开源的,对所有人公平,哥不吃这一套。我有自己的一套原则(策略,这个我们待会儿再讲)。接着来术语,上面的过程叫做抢占,进程得到CPU的执行机会这段时间叫时间片,是由系统定义好的。后面我们会看到,linux调度程序采用动态方法计算时间片。说了抢占,再说说非抢占,就是说没人跟你抢,除非自己放弃,否则你自己运行下去吧。进程主动放弃挂起自己的行为叫让步。这种机制看似很和谐(不要被和谐哦),但问题挺多,比如系统不知道每个进程执行多长时间。而且一旦一个悬挂进程不愿意或者忘了做出让步,那系统崩了咋办,我的money,我的future,我的lover,从此一去不复返。

       说了那多,开始正题。原则,一切都有原则,linux的原则。开始原则以前,再来认识一下我们前边说过的进程,其实进程远不如以前那样简单。进程也有种类的,至少分为IO型和CPU型。IO型指那种大部分时间用来提交I/O请求或是等待I/O请求的进程。这样的进程挺讨人厌的,他通常都是运行一小小会儿,因为它总是在等待更多的I/O请求时最后总会阻塞。与之对应的则是CPU型进程。这样的进程把时间大多放在执行代码上,除非被抢占,他们就一直贪得无厌的运行。因为它们没有太多的IO请求。由于它们不是IO驱动类型,也就不存在用户交互的问题(IO驱动不仅仅指磁盘,还指键盘鼠标等),所有从系统交互响应速度而言,调度器不应该经常让他们运行。即降低它们的运行频率,而将它们的运行时间拖长一些。这里所说的原则就是:调度策略要在进程响应迅速(相应时间短)和进程吞吐量高之间做出艰难的决定。这个原则有时不公平,很残酷。残酷的让人不懂,不懂低优先级的有时不能公平对待。由于交互式进程属于IO型进程,所以linux为了保证交互式应用,所以调度策略会向IO型进行倾斜。

        上面提到优先级,调度算法中最基本的一类当然就是基于优先级的调度。挺简单:优先级高的先运行,相同优先级的按轮转式进行调度。优先级高的进程使用的时间片也长。调度程序总是选择时间片未用尽且优先级最高的进程运行。这句话就是说用户和系统可以通过设置进程的优先级来响应系统的调度。基于此,linux设计上一套动态优先级的调度方法。一开始,先为进程设置一个基本的优先级,然而它允许调度程序根据需要来加减优先级。linux内核提供了两组独立的优先级范围。第一种是nice值,范围从-20到19,默认为0.nice值越大优先级越小。另外nice值也用来决定分配给进程时间片的长短。第二种范围是实时优先级。默认范围是从0到99.任何实时的优先级都高于普通优先级。这个我们后面再说。

       说了那么多的时间片,说起来不费劲,做起来那不是一个麻烦可以说清楚的。时间片就是一个数值,它表明进程在被抢占前所能持续运行的时间。默认的时间片是20ms,很短。linux调度程序还能根据进程的优先级动态调整分配给他的时间片。特别说明的是,进程不一定要一次用完所有分配给它的时间片。一旦进程的时间片耗尽后,就认为进程到期了。没有时间片的进程不会再投入运行。除非等到其他的进程都耗尽了它们的时间片,这时,所有进程的时间片就会被重新计算。

       前边讲到了抢占的话题,当一个进程进入TASK_RUNNING状态时,内核就会检测它的优先级是否高于当前正在执行的进程。如果是,则调度程序会被唤醒,重新选择新的进程执行(套用书上的话, 应该是刚刚进入可运行状态的这个进程)。此外,当一个进程的时间片变为0时,它会被抢占,调度程序被唤醒以选择一个新的进程。(具体的例子大家可以看看参考书籍的p28页3.1.5)

       linux的调度程序定义于kernel/sched.c中,在2.6内核中的调度程序和以前有了很大的差别,体现在下面:

1.充分实现O(1)调度.这个应该懂得,可以懂的
2.在多核处理器的今天,linux也不落伍,全面实现SMP的扩展性。每个处理器拥有自己的锁和自己的可执行队列
3.强化SMP的亲和力。这是有关多核CPU亲和力的说明,我目前正在研究这个,后面我会专门在一篇博文中详细说明。
4.加强交互能力。
5.保证公平。
6.虽然最常见的优化情况是系统中只有1~2个可运行进程,但是优化也完全有能力扩展到具有多处理器且每个处理器上运行多个进程的系统中。

      上面第二条可执行队列,它是调度程序中最基本的数据结构,定义于kernel/sched.c中,由结构runquene表示。它是给定处理器上的可执行进程的链表,每个处理器维护一个。每个可投入运行得进程都唯一的归属于一个可执行队列。此外,可执行队列中还包含每个处理器的调度信息。具体的结构这里就不给了,自己要有看源代码的能力哦。宏cpu_rq(processor)用于返回给定处理器可执行队列的指针,this_rq()用于来返回当前处理器的可执行队列,task_rq(task)返回给定任务所在的队列指针。

       在其拥有者读取或改写其队列成员的时候,可执行队列包含的锁用来防止队列被其他代码改动,由于每个可执行队列唯一地对应一个处理器,所以很少出现一个处理器需要锁其他处理器的可执行队列的情况,但这种情况是事实存在的。最常见的情况是发生在一个特定的任务在运行队列上执行时。此时需要用到task_rq_lock()和task_rq_unlock()函数,或者可以用this_rq_lock()来锁住当前的可执行队列,用rq_unlock(struct runqueue *rq)释放给定队列上的锁。关于锁的操作,我们再次飘过,为啥?一方面,这超出了本节的重点,二者我在linux驱动理论帖中对加解锁做了详细说明,三者,我后面可能还会详细说。所以,那句话真是太真理了:暂时的放下,是再次的拿起。

        从运行队列的结构中,我们发现了两个有关优先级的数组(定义在kernel/sched.c中,名字叫prio_array,具体自己去看吧):活跃的和过去的。这两个数组是实现O(1)级算法的数据结构。优先级数组使可运行处理器的每一种优先级都包含一个相应的队列,而这些队列包含对应优先级上的可执行进程链表。结构中的优先级位图主要是为了帮助提高查找当前系统内拥有最高优先级的可执行进程的效率而设计的。关于结构中的定义常量,MAX_PRIO定义了系统拥有的优先级个数,默认值是140,BITMAP_SIZE是优先级位图数组的大小,值是5,这个是可以计算出来的。比如:位图数组的类型是unsigned long类型,长是32位,假定每一位代表一个优先级(4*32 = 128<140),就需要5个这样的长整型才能表示。所以,bitmap就正好含有5个数组项。

        开始时,位图成员的所有位都被清0,当某一优先级的进程开始准备执行时,位图中对应的位就置1,这样,查找系统中最高的优先级就变成了查找位图中被设置的第一位。鉴于优先级个数的定值性,查找的时间就不受系统到底有多少可执行进程的影响,是个定值。关于对位图的快速查找算法对应的函数是sched_find_first_bit().

        优先级数组的list_head队列是一个链表,这个链表包含了该处理器上相应优先级的全部可运行线程。要找到下一个要运行的任务,直接从该链表中选择下一个元素。对于给定的优先级,按轮转方式调度任务。优先级数组的计数器nr_active,它保存了该优先级数组内可执行进程的数目。

         下一话题,我前边反复提到时间片的话题,一旦所有进程的时间片都用完会怎样了,总不能系统宕掉吧,好在操作系统提供了一种显式的方法来重新计算每个进程的时间片。典型就是循环访问每个进程。借用书上的一个例子:

for (系统中的每个任务){
           重新计算优先级
           重新计算时间片

       这种方法简单,但弊端很多:

1.可能耗费相当长的时间。
2.为了保护任务队列和每个进程的描述符,必要要用到锁的机制。这样做很明显会加剧对锁的争用,影响系统性能。
3.重算时间片的时机不确定。
4.实现显得很粗糙。

       现在采用的是新的调度方法,每个处理器拥有上面提到的两个优先级数组,活动数组上的进程都有时间片剩余,而过期数组中的进程都耗尽了时间片,当一个进程的时间片耗尽时,它会移至过期数组,但在此之前,时间片已经给它重新计算好了,重新计算时间片现在变的非常简单,只要在活动和过期数组之间来回切换就可以了。又因为数组是通过指针进行访问的,所以交换它们用的时间就是交换指针需要的时间。这个动作由schedule()完成:

struct prio_array *array = rq->active;
if(!array->nr_active){
        rq->active = rq->expired;
        rq->expired = array;
}

       这种交换是O(1)级调度程序的核心。O(1)级调度程序根本不需要从头到尾都忙着重新计算时间片,它只要完成一个两个步骤就能实现数组的切换,这种实现完美地解决了前面列举的所有弊端。linux的调度程序是在schedule()函数实现的。当内核代码想要休眠时,会直接调用该函数,如果哪个进程将被抢占,也会被唤起执行。调度的第一步是判断谁是优先级最高的进程:

struct task_struct *prev, *next;
struct list_head *queue;
struct prio_array array;
int idx;
prev = current;
array = rq->active;
idx = sched_find_first_bit(array->bitmpa);
queue = array->queue + idx;
next = list_entry(queue->next, struct task_struct, run_list);

       分析上面这段代码,首先在活动优先级数组中找到第一个被设置的bit位,该位对应着最高的可执行进程。然后,调度程序选择这个级别链表里的头一个进程。这个进程就是系统中优先级最高的可执行程序,也是马上就会被调度执行的进程。上面中的prev和next不等,说明被选中的进程不是当前进程,这时就要调用context_switch来进程切换。最后强调一点,不存在任何影响schedule()时间长短的因素,因为前边说过,所用的时间是恒定的。

       前边多次说过:调度程序是利用优先级和时间片来做出决定的。下边看看实际代码的实现方式。进程结构体告诉我们进程拥有一个初始的优先级,叫做nice值,最低是19,最高是20,结构体的static_prio域就存放这个值,static就是说这个一开始就由用户指定好了,不能改变。调度程序要用的动态优先级用prio域表示,两者的关系在于通过一个关于静态优先级和进程交互性的函数计算而来。啥叫进程交互性?这是个新词,第一次提到,这样说吧,我们前边说过I/O消耗性和CPU消耗性的进程区别,进程交互性就是指IO消耗性的,因为和用户进行交互就是电脑IO和用户交互,像鼠标键盘等。但我们怎么确定一个进程的交互性的强弱呢?最明显的标准莫过于通过计算进程休眠的时间长短来做预测了。大部分时间都在休眠的进程当然是IO消耗型的,如果一个进程把所有的时间几乎都放在了CPU执行处理上,那..不说了。在linux中,用值tast_struct中的sleep_avg域记录了一个进程用于休眠和用于执行的时间,范围为0~MAX_SLEEP_AVG,默认值是10ms。当一个进程从休眠状态恢复到执行状态时,sleep_avg会根据它休眠时间的长短而增长直到最大。相反,进程没运行一个时钟节拍,sleep_avg就做相应的递减,到0为止。这种方法不仅会奖励交互性强的进程,还会惩罚处理器耗时量的进程。再加之奖励和处罚都是建立在作为基数的nice值上,所有用户仍然能够通过改变nice指来调整程序的调度。effective_prio()函数可以返回一个进程的动态优先数,该函数以nice值为基数,再加上我们上面提到的从-5到+5的进程交互性型奖励处罚分。

       一旦有了上面的动态优先级,再来重新计算时间片就方便多了。另外,当一个进程创建的时候,新建的子进程和父进程均分父进程剩余的时间片。这样的分配很平均并且防止用户通过不断的创建新进程来不断攫取时间片。task_timeslice()函数为给定任务返回一个新的时间片,时间片的计算只需要把优先级按比例缩放,使其符合时间片的数值范围要求就可以了。优先级最高的进程能获得的最大时间片长度(MAX_TIMESLICE)是200ms,最短时间片(MIN_TIMESLICE)是10ms。默认优先级(nice值为0,没有交互性奖罚分)的进程得到的时间片长度为100ms。上面我们也提到过,活动数组内的可执行队列上的进程的时间片耗尽时,它就会移植到过期数组。但是,如果一个进程的交互性很强,特别特别强,那么调度程序支持另外一种机制来支持这种交互进程:当时间片用完后,它会被再放置到活动数组而不是过期数组中。回忆一下上面O(1)调度器的实现机制:进程用尽其时间片后,都会被移动到过期数组,当活动数组中没有剩余进程的时候,这个时候两个数组就会被交换。但是在发生这种交换以前,交互性很强的一个进程有可能已经处于过期数组中了,当他需要交互的时候,这时可能数组交互还没有发生,所以交互也就无法进行,这时对于这种交互特别特别强的进程重新插入到活动数组就可以避免这种问题。注意,虽然被放回到活动数组,但不会立即就运行,它还要和其他具有相同优先级的进程轮流运行。该逻辑在scheduler_tick()中实现,该函数会被定时器中断调用。看下面的实现代码:

struct  task_struct *task = current;
struct runqueue *rq = this_rq();
if(!—task->time_slice){
       if(!TASK_INTERACTIVE(task) || (EXPIRED_STARVING(rq)))
                 enqueue_task(task, rq->expired);
       else
                 enqueue_task(task, rq->active);
}

       这段代码先减少进程时间片的值。宏TASK_INTERACTIVE主要是基于进程的nice值来查看这个进程是否是交互性很强的进程。宏EXPIRED_STARVING负责检查过期数组内的进程是否处于饥饿状态,是否有相当长的时间没有发生数组交换了。如果最近一直没有发生切换,那么再把当前的进程放置到活动数组会进一步拖延切换--过期数组内的进程会来越来越饿.只要不发生这种情况,进程就会被重新设置在活动数组里,否则,进程会被放入过期数组里。

      有关休眠我就不用多讲了。内核对休眠的处理都相同:进程把自己标记成休眠状态,然后将自己从可执行队列移出,放入等待队列,然后调用schedule()选择和执行一个其他进程,唤醒的过程刚好相反。要明白一点就是:即使休眠也有两种进程状态,TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE.前者如果接收到一个信号会被提前唤醒并响应该信号,后者会忽略信号。这两种进程位于同一等待队列(wake_queue_head_t)上,等待某些事情,不能够运行。有关等待队列的知识,跳过(我已经在Linux驱动开发理论帖里讲过,自己去看吧)现在,在内核中进行休眠的推荐操作相比较以前而言复杂了很多,但更安全:

1.调用DECLARE_WAITQUEUE() 创建一个等待队列的项。
2.调用add_wait_queue()把自己加入到等带对列中。
3.将进程的状态变更为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE.
4.检查条件是否为真,如果是的话,就没必要休眠了。不为真,就调用schedule().
5.当进程被唤醒的时候,它会再次检查条件是否为真。如果是,它就退出循环,如果不是,它再次调用schedule()并一直重复这步操作。
6.当条件满足后,进程将自己设置为TASK_RUNNING并调用remove_wait_queue把自己移出等待队列。

       如果在进程开始休眠之前条件就已经达成了,那么循环会退出,进程不会存在错误的进入休眠的倾向。有休眠就有唤醒,唤醒是通过wake_up()进行。她唤醒指定的等待队列上的所有进程。它调用try_to_wake_up,该函数负责将进程设置为TASK_RUNNING状态,调用active_task()将此进程放入可执行队列,如果被唤醒进程的优先级比当前正在执行的进程的优先级高,还要设置need_resched标志。编程的技巧,通常哪段代码促使等待条件达成,它就要负责随后调用wake_up函数。说明:存在虚假的唤醒,有时候进程被唤醒并不是因为它所等待的条件达成了,所以才需要用一个循环处理来保证它等待的条件真正到达。

       开始新的问题,现在市场上,你说单核CPU,你都没脸见人,现在是啥时代,多核的时代,瞧,我这都酷睿i7了(四核),我用的服务器实验平台是8核心的。和我有啥关系,和我今天讲的有啥关系。关系大了去了。前边提到过,linux的调度程序为对称多处理器系统的每个处理器准备了单独的可执行队列和锁,出于效率的考虑,整个调度系统从每个处理器来看都是独立的。那么这样的情况咋办呢?一个处理器上有5个进程,另外一个处理器的队列上只有一个进程,这时很明显出现了系统负载不均衡的情况。这时咋办呢?由负载均衡程序来解决(kernel/sched.c的load_balance来实现)。它有两种调用方法,在schedule执行的时候,只要当前的可执行对列为空,它就会被调用。此外,它也会被定时器调用,系统空闲时每隔1ms调用一次或者在其他情况下每隔200ms调用一次。单系统中,它不会被调用,甚至不会被编译进内核,因为那里边只有一个可执行队列,根本没有平衡负载的必要。load_balances调用时需要锁住当前处理器的可执行队列并且屏蔽中断,以避免可执行队列被并发的访问,所完成的操作步骤如下所示:

1.load_balance()调用find_busiest_queue(),找到最繁忙的可执行队列(进程数目最多)并返回该队列。如果没有哪个可执行队列中进程的数
  目比当前队列中的数目多25%或25%以上。它就返回NULL,同时load_balance也返回。
2.load_balance从最繁忙的运行队列中选择一个优先级数组以便抽取进程。最好是过期数组,如果过期数组为空,那就只能选活动数组。
3.load_balance寻找到含有进程并且优先级最高的链表,因为把优先级高的进程分散开来才是最重要的。
4.分析找到的所有这些优先级相同的进程,这样一个不是正在执行,也不会因为处理器相关性而不可移动,并且不在高速缓存中的进程。如
  果有,就调用pull_task将其从最繁忙的队列中抽取到当前队列
5.只要可执行队列之间仍然不平衡,就重复上面两个步骤,这最终会消除不平衡。然后解除对当前运行队列进行的锁定,从load_balance返
  回。

我们后面会提到的上下文切换,就是从一个可执行进程切换到另一个可执行进程,由定义在kernel/sched.c的context_switch函数负责处理。每当一个新的进程被选出来准备投入运行的时候,schedule就会调用该函数。它主要完成如下两个工作:

1.调用定义在include/asm/mmu_context.h中的switch_mm().该函数负责把虚拟内存从上一个进程映射切换到新进程中。
2.调用定义在include/asm/system.h的switch_to(),该函数负责从上一个进程的处理器状态切换到新进程的处理器状态,这包括保存,恢复
   栈信息和寄存器信息。

      内核也必须知道什么时候调用schedule(),单靠用户代码显示调用schedule(),他们可能就会永远地执行下去,相反,内核提供了一个need_resched标志来表明是否需要重新执行一次调度。当某个进程耗尽它的时间片时,scheduler_tick()就会设置这个标志,当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()也会设置这个标志。下表给出了用于访问和操作need_resched的函数。

函数

说明

set_tsk_need_resched(task) 设置指定进程中的need_resched标志
clear_tsk_need_resched(task) 清除指定进程中的nedd_resched标志
need_resched() 检查need_resched标志的值,如果被设置就返回真,否则返回假

       再返回用户空间以及从中断返回的时候,内核也会检查need_resched标志,如果已被设置,内核会在继续执行之前调用该调度程序。最后,每个进程都包含一个need_resched标志,这是因为访问进程描述符内的数值要比访问一个全局变量要快(因为current宏速度很快并且描述符通常都在高速缓存中)。在2.6内核中,他被移到了thread_info结构体里,这是和以前不同的。

       抢占,还是抢占!我们总是逃不了抢占的话题。内核放回到用户空间的时候,如果need_resched标志被设置,就会导致schedule()被调用。简儿言之,用户抢占在以下情况下发生:从系统调用返回到用户空间和从中断处理程序返回用户空间。

       在linux2.6内核中,内核引入了抢占内力。为了支持内核抢占所做的第一个变动就是为每个进程的thread_info引入preempt_count计数器,该计数器初始为0,每当使用锁的时候,该值减1。当为0的时候,内核就可执行抢占。从中断返回内核空间的时候,内核会检查need_resched和preempt_count的值,如果need_resched被设置,并且preempt_count为0的时候,这说明有一个更重要的任务需要执行并且安全地抢占,此时,调度程序就会被调度。如果,preempt_count不为0,说明当前任务持有锁,这时抢占是不安全的。这时,就会想通常那样直接从中断返回当前执行进程。如果当前进程持有的锁都被释放了,那么preempt_count就会重新为0。此时,释放锁的代码会检查need_sheduled是否被设置,如果是的话,就会调用调度程序,有些内核需要允许或禁止内核抢占,这个后面具体问题具体分析。如果内核中的进程阻塞了,或它显示地调用了schedule(),内核抢占也会显示地发生,这种形式的内核抢占从来都是支持的,因为根本无需额外的逻辑来保证内核可以安全地被抢占,如果代码显示的调用了schedule(),那么它清楚自己是可以安全地被强化的。

       我们前边讲的是普通的,非实时的调度策略(SCHED_OTHER),Linux也支持两种实时调度策略(SCHED_FIFO和SCHED_RR).SCHED_FIFO从名字中我们也知道,就不说了。它不使用时间片。该级的进程会比任何SCHED_OTHER级的进程都先得到调度。一旦一个SCHED_FIFO级进程处理可执行状态,就会一直执行,直到它自己被阻塞或是显示地释放处理器为止。由于不基于时间片,所以它会一直执行下去。多个SCHED_FIFO级进程存在时,他们会轮流执行。而且只要有SCHED_FIFO级的进程存在,普通级进程级只能等待它结束后才有机会执行。SCHED_RR是一种带有时间片的SCHED_FIFO。以上两种实时调度算法实现都是静态优先级的。内核不为实时进程计算动态优先级。这样就能保证给定优先级级别的实时进程总能抢占优先级比它低的进程。

       linux的实时调度算法分为软实时和硬实时。软实时的含义是,内核调度进程尽可能使进程在它的限定时间到来前执行,但内核不会拍着胸脯说:我一定能保证。对应地,硬实时会这样哥们义气哦。linux下的实时调度不做任何保证,只保证只要实时进程可执行,就尽量去执行它。现在开来,效果还是不错的。

       实时优先级范围从0~MAX_RT_PRIO-1.默认情况下,MAX_RT_PRIO为100.SCHED_OTHER级进程的nice值共享了这个取值空间。它的取值范围是从MAX_RT_PRIO到MAX_RT_PRIO+40.也就是说,nice值从-20到+19对应的是从100到140的实时优先级范围。

       最后,是一些关于和调度有关的系统调用,这个我就不说了,感觉就是函数调用,说了没啥意思,自己看吧。反正就是一书在手,万事不愁啊..呵呵

易学教程内所有资源均来自网络或用户发布的内容,如有违反法律规定的内容欢迎反馈
该文章没有解决你所遇到的问题?点击提问,说说你的问题,让更多的人一起探讨吧!