Linux的调度程序-Schedule( )

Linux的调度程序-Schedule( )


5.3.1基本原理 

从前面我们可以看到,进程运行需要各种各样的系统资源,如内存、文件、打印机和最宝贵的CPU等等,所以说呢,调度的实质就是资源的分配。系统通过不同的 调度算法(Scheduling Algorithm)来实现这种资源的分配。通常来说,选择什么样的调度算法取决于的资源分配的策略(Scheduling Policy),我们不准备在这里详细说明各种调度算法,只说明与Linux调度相关的几种算法及这些算法的原理。 

一个好的调度算法应当考虑以下几个方面: 

(1)公平:保证每个进程得到合理的CPU时间。 
(2)高效:使CPU保持忙碌状态,即总是有进程在CPU上运行。 
(3)响应时间:使交互用户的响应时间尽可能短。 
(4)周转时间:使批处理用户等待输出的时间尽可能短。 
(5)吞吐量:使单位时间内处理的进程数量尽可能多。 

很显然,这5个目标不可能同时达到,所以,不同的操作系统会在这几个方面中作出相应的取舍,从而确定自己的调度算法,例如UNIX采用动态优先数调度、5.3BSD采用多级反馈队列调度、Windows采用抢先多任务调度等等。 

下面来了解一下主要的调度算法及其基本原理: 

1.时间片轮转调度算法 

时间片(Time Slice)就是分配给进程运行的一段时间。 

在分时系统中,为了保证人机交互的及时性,系统使每个进程依次地按时间片轮流的方式执行,此时即应采用时间片轮转法进行调度。在通常的轮转法中,系统将所 有的可运行(即就绪)进程按先来先服务的原则,排成一个队列,每次调度时把CPU分配给队首进程,并令其执行一个时间片。时间片的大小从几ms到几百ms 不等。当执行的时间片用完时,系统发出信号,通知调度程序,调度程序便据此信号来停止该进程的执行,并将它送到运行队列的末尾,等待下一次执行;然后,把 处理机分配给就绪队列中新的队首进程,同时也让它执行一个时间片。这样就可以保证运行队列中的所有进程,在一个给定的时间(人所能接受的等待时间)内,均 能获得一时间片的处理机执行时间。 

2.优先权调度算法 

为了照顾到紧迫型进程在进入系统后便能获得优先处理,引入了最高优先权调度算法。当将该算法用于进程调度时,系统将把处理机分配给运行队列中优先权最高的进程,这时,又可进一步把该算法分成两种方式: 

(1) 非抢占式优先权算法(又称不可剥夺调度:Nonpreemptive Scheduling) 

在这种方式下,系统一旦将处理机(CPU)分配给运行队列中优先权最高的进程后,该进程便一直执行下去,直至完成;或因发生某事件使该进程放弃处理机时, 系统方可将处理机分配给另一个优先权高的进程。这种调度算法主要用于批处理系统中,也可用于某些对实时性要求不严的实时系统中。 

(2) 抢占式优先权调度算法(又称可剥夺调度:Preemptive Scheduling) 

该算法的本质就是系统中当前运行的进程永远是可运行进程中优先权最高的那个。 

在这种方式下,系统同样是把处理机分配给优先权最高的进程,使之执行。但是只要一出现了另一个优先权更高的进程时,调度程序就暂停原最高优先权进程的执 行,而将处理机分配给新出现的优先权最高的进程,即剥夺当前进程的运行。因此,在采用这种调度算法时,每当出现一新的可运行进程,就将它和当前运行进程进 行优先权比较,如果高于当前进程,将触发进程调度。 

这种方式的优先权调度算法,能更好的满足紧迫进程的要求,故而常用于要求比较严格的实时系统中,以及对性能要求较高的批处理和分时系统中。Linux也采用这种调度算法。 

3.多级反馈队列调度 

这是时下最时髦的一种调度算法。其本质是:综合了时间片轮转调度和抢占式优先权调度的优点,即:优先权高的进程先运行给定的时间片,相同优先权的进程轮流运行给定的时间片。 

4.实时调度 

最后我们来看一下实时系统中的调度。什么叫实时系统,就是系统对外部事件有求必应、尽快响应。在实时系统中存在有若干个实时进程或任务,它们用来反应或控制某个(些)外部事件,往往带有某种程度的紧迫性,因而对实时系统中的进程调度有某些特殊要求。 

在实时系统中,广泛采用抢占调度方式,特别是对于那些要求严格的实时系统。因为这种调度方式既具有较大的灵活性,又能获得很小的调度延迟;但是这种调度方式也比较复杂。 

我们大致了解以上的调度方式以后,下面具体来看Linux中的调度程序,这里要说明的是,Linux的调度程序并不复杂,但这并不影响Linux调度程序的高效性!

5.3.2 Linux进程调度时机 

调度程序虽然特别重要,但它不过是一个存在于内核空间中的函数而已,并不神秘。Linux的调度程序是一个叫Schedule()的函数,这个函数被调用 的频率很高,由它来决定是否要进行进程的切换,如果要切换的话,切换到哪个进程等等。我们先来看在什么情况下要执行调度程序,我们把这种情况叫做调度时 机。 

Linux调度时机主要有: 

1、进程状态转换的时刻:进程终止、进程睡眠; 
2、当前进程的时间片用完时(current->counter=0); 
3、设备驱动程序 
4、进程从中断、异常及系统调用返回到用户态时; 

时机1,进程要调用sleep()或exit()等函数进行状态转换,这些函数会主动调用调度程序进行进程调度; 
时机2,由于进程的时间片是由时钟中断来更新的,因此,这种情况和时机4是一样的。 
时机3,当设备驱动程序执行长而重复的任务时,直接调用调度程序。在每次反复循环中,驱动程序都检查need_resched的值,如果必要,则调用调度程序schedule()主动放弃CPU。 

时机4,如前所述,不管是从中断、异常还是系统调用返回,最终都调用ret_from_sys_call(),由这个函数进行调度标志的检测,如果必要, 则调用调用调度程序。那么,为什么从系统调用返回时要调用调度程序呢?这当然是从效率考虑。从系统调用返回意味着要离开内核态而返回到用户态,而状态的转 换要花费一定的时间,因此,在返回到用户态前,系统把在内核态该处理的事全部做完。 

对于直接执行调度程序的时机,我们不讨论,因为后面我们将会描述调度程序的工作过程。前面我们讨论了时钟中断,知道了时钟中断的重要作用,下面我们就简单 看一下每个时钟中断发生时内核要做的工作,首先对这个最频繁的调度时机有一个大体了解,然后再详细讨论调度程序的具体工作过程。 

每个时钟中断(timer interrupt)发生时,由三个函数协同工作,共同完成进程的选择和切换,它们是:schedule()、do_timer()及ret_form_sys_call()。我们先来解释一下这三个函数: 

schedule():进程调度函数,由它来完成进程的选择(调度); 

do_timer():暂且称之为时钟函数,该函数在时钟中断服务程序中被调用,是时钟中断服务程序的主要组成部分,该函数被调用的频率就是时钟中断的频率即每秒钟100次(简称100赫兹或100Hz); 

ret_from_sys_call():系统调用返回函数。当一个系统调用或中断完成时,该函数被调用,用于处理一些收尾工作,例如信号处理、核心任务等等。 

这三个函数是如何协调工作的呢? 

前面我们看到,时钟中断是一个中断服务程序,它的主要组成部分就是时钟函数do_timer(),由这个函数完成系统时间的更新、进程时间片的更新等工作,更新后的进程时间片counter作为调度的主要依据。 

在时钟中断返回时,要调用函数ret_from_sys_call(),前面我们已经讨论过这个函数,在这个函数中有如下几行: 

cmpl $0, _need_resched 
jne reschedule 
…… 
restore_all: 
RESTORE_ALL 
reschedule: 
call SYMBOL_NAME(schedule) 
jmp ret_from_sys_call 

这几行的意思很明显:检测 need_resched 标志,如果此标志为非0,那么就转到reschedule处调用调度程序schedule()进行进程的选择。调度程序schedule()会根据具体的 标准在运行队列中选择下一个应该运行的进程。当从调度程序返回时,如果发现又有调度标志被设置,则又调用调度程序,直到调度标志为0,这时,从调度程序返 回时由RESTORE_ALL恢复被选定进程的环境,返回到被选定进程的用户空间,使之得到运行。 

以上就是时钟中断这个最频繁的调度时机。讨论这个的主要目的使读者对时机4有个大致的了解。 

最后要说明的是,系统调用返回函数ret_from_sys_call()是从系统调用、异常及中断返回函数通常要调用的函数,但并不是非得调用,对于那 些要经常被响应的和要被尽快处理的中断请求信号,为了减少系统开销,处理完成后并不调用 ret_from_sys_call()(因为很显然的,从这些中断处理程序返回到的用户空间肯定是那个被中断的进程,无需重新选择),并且,它们作的工 作要尽可能少,因为响应的频率太高了。 

Linux 调度程序和其他的UNIX调度程序不同,尤其是在“nice level”优先级的处理上,与优先权调度(priority高的进程最先运行)不同,Linux用的是时间片轮转调度(Round Robing),但同时又保证了高优先级的进程运行的既快、时间又长(both sooner and longer)。而标准的UNIX调度程序都用到了多级进程队列。大多数的实现都用到了二级优先队列:一个标准队列和一个实时(“real time”)队列。一般情况下,如果实时队列中的进程未被阻塞,它们都要在标准队列中的进程之前被执行,并且,每个队列中,“nice level”高的进程先被执行。 

总体上,Linux 调度序程在交互性方面表现很出色,当然了,这是以牺牲一部分“吞吐量”为代价的。

5.3.3 进程调度的依据 

调度程序运行时,要在所有处于可运行状态的进程之中选择最值得运行的进程投入运行。选择进程的依据是什么呢?在每个进程的task_struct结构中有这么五项: 

need_resched、nice、counter、policy 及rt_priority 

(1) need_resched: 在调度时机到来时,检测这个域的值,如果为1,则调用schedule() 。 
(2)counter: 进程处于运行状态时所剩余的时钟滴答数,每次时钟中断到来时,这个值就减1。当这个域的值变得越来越小,直至为0时,就把need_resched 域置1,因此,也把这个域叫做进程的“动态优先级”。 
(3 )nice: 进程的“静态优先级”,这个域决定counter 的初值。只有通过nice(), POSIX.1b sched_setparam() 或 5.4BSD/SVR4 setpriority()系统调用才能改变进程的静态优先级。 
(4) rt_priority: 实时进程的优先级 
(5) policy: 从整体上区分实时进程和普通进程,因为实时进程和普通进程的调度是不同的,它们两者之间,实时进程应该先于普通进程而运行,可以通过系统调用 sched_setscheduler( )来改变调度的策略。对于同一类型的不同进程,采用不同的标准来选择进程。对于普通进程,选择进程的主要依据为counter和nice 。对于实时进程,Linux采用了两种调度策略,即FIFO(先来先服务调度)和RR(时间片轮转调度)。因为实时进程具有一定程度的紧迫性,所以衡量一 个实时进程是否应该运行,Linux采用了一个比较固定的标准。实时进程的counter只是用来表示该进程的剩余滴答数,并不作为衡量它是否值得运行的 标准,这和普通进程是有区别的。 

这里再次说明,与其他操作系统一样,Linux的时间单位也是“时钟滴答”,只是不同的操作系统对一个时钟滴答的定义不同而已(Linux设计者将一个 “时钟滴答”定义为10ms)。在这里,我们把counter叫做进程的时间片,但实际上它仅仅是时钟滴答的个数,例如,若counter为5,则分配给 该进程的时间片就为5个时钟滴答,也就是5*10ms=50ms,实际上,Linux2.4中给进程初始时间片的大小就是50ms

5.3.4 进程可运行程度的衡量 

函数goodness()就是用来衡量一个处于可运行状态的进程值得运行的程度。该函数综合使用了上面我们提到的五项,给每个处于可运行状态的进程赋予一 个权值(weight),调度程序以这个权值作为选择进程的唯一依据。函数主体如下(为了便于理解,笔者对函数做了一些改写和简化,只考虑单处理机的情 况): 

static inline int goodness(struct task_struct * p, struct mm_struct *this_mm) 
{ int weight; /* 权值,作为衡量进程是否运行的唯一依据 * 
weight=-1; 
if (p->policy&SCHED_YIELD) 
goto out; /*如果该进程愿意“礼让(yield)”,则让其权值为-1 */ 
switch(p->policy) 

/* 实时进程*/ 
case SCHED_FIFO: 
case SCHED_RR: 
weight = 1000 + p->rt_priority; 
/* 普通进程 */ 
case SCHED_OTHER: 
{ weight = p->counter; 
if(!weight) 
goto out 
/* 做细微的调整*/ 
if (p->mm=this_mm||!p->mm) 
weight = weight+1; 
weight+=20-p->nice; 


out: 
return weight; /*返回权值*/ 
}

其中,在sched.h中对调度策略定义如下: 

#define SCHED_OTHER 0 
#define SCHED_FIFO 1 
#define SCHED_RR 2 
#define SCHED_YIELD 0x10 

这个函数比较很简单。首先,根据policy区分实时进程和普通进程。实时进程的权值取决于其实时优先级,其至少是1000,与conter和nice无关。普通进程的权值需特别说明两点: 

(1) 为什么进行细微的调整?如果p->mm为空,则意味着该进程无用户空间(例如内核线程),则无需切换到用户空间。如果 p->mm=this_mm,则说明该进程的用户空间就是当前进程的用户空间,该进程完全有可能再次得到运行。对于以上两种情况,都给其权值加1, 算是对它们小小的奖励。 

(2) 进程的优先级nice是从早期Unix沿用下来的负向优先级,其数值标志“谦让”的程度,其值越大,就表示其越“谦让”,也就是优先级越低,其取值范围为 -20~+19,因此,(20-p->nice)的取值范围就是0~40。可以看出,普通进程的权值不仅考虑了其剩余的时间片,还考虑了其优先级, 优先级越高,其权值越大。 

有了衡量进程是否应该运行的标准,选择进程就是轻而易举的事情了,弱肉强食,谁的权值大谁就先运行。 

根据进程调度的依据,调度程序就可以控制系统中的所有处于可运行状态的进程并在它们之间进行选择。

5.3.5进程调度的实现 

调度程序在内核中就是一个函数,为了讨论方便,我们同样对其进行了简化,略其对SMP的实现部分。

asmlinkage void schedule(void) 

struct task_struct *prev, *next, *p; /* prev表示调度之前的进程, 
next表示调度之后的进程 */ 
struct list_head *tmp; 
int this_cpu, c; 
if (!current->active_mm) BUG();/*如果当前进程的的active_mm为空,出错*/ 
need_resched_back: 
prev = current; /*让prev成为当前进程 */ 
this_cpu = prev->processor; 
if (in_interrupt()) {/*如果schedule是在中断服务程序内部执行, 
就说明发生了错误*/ 
printk("Scheduling in interrupt\n"); 
BUG(); 

release_kernel_lock(prev, this_cpu); /*释放全局内核锁, 
并开this_cpu的中断*/ 
spin_lock_irq(&runqueue_lock); /*锁住运行队列,并且同时关中断*/ 
if (prev->policy == SCHED_RR) /*将一个时间片用完的SCHED_RR实时 
goto move_rr_last; 进程放到队列的末尾 */ 
move_rr_back: 
switch (prev->state) { /*根据prev的状态做相应的处理*/ 
case TASK_INTERRUPTIBLE: /*此状态表明该进程可以被信号中断*/ 
if (signal_pending(prev)) { /*如果该进程有未处理的 
信号,则让其变为可运行状态*/ 
prev->state = TASK_RUNNING; 
break; 

default: /*如果为可中断的等待状态或僵死状态*/ 
del_from_runqueue(prev); /*从运行队列中删除*/ 
case TASK_RUNNING:;/*如果为可运行状态,继续处理*/ 

prev->need_resched = 0; 
/*下面是调度程序的正文 */ 
repeat_schedule: /*真正开始选择值得运行的进程*/ 
next = idle_task(this_cpu); /*缺省选择空闲进程*/ 
c = -1000; 
if (prev->state == TASK_RUNNING) 
goto still_running; 
still_running_back: 
list_for_each(tmp, &runqueue_head) { /*遍历运行队列*/ 
p = list_entry(tmp, struct task_struct, run_list); 
if (can_schedule(p, this_cpu)) { /*单CPU中,该函数总返回1*/ int weight = goodness(p, this_cpu, prev->active_mm); 
if (weight > c) 
c = weight, next = p; 


/* 如果c为0,说明运行队列中所有进程的权值都为0,也就是分配给各个进程的 
时间片都已用完,需重新计算各个进程的时间片 */ 
if (!c) { 
struct task_struct *p; 
spin_unlock_irq(&runqueue_lock);/*锁住运行队列*/ 
read_lock(&tasklist_lock); /* 锁住进程的双向链表*/ 
for_each_task(p) /* 对系统中的每个进程*/ 
p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice); 
read_unlock(&tasklist_lock); 
spin_lock_irq(&runqueue_lock); 
goto repeat_schedule; 


spin_unlock_irq(&runqueue_lock);/*对运行队列解锁,并开中断*/ 
if (prev == next) { /*如果选中的进程就是原来的进程*/ 
prev->policy &= ~SCHED_YIELD; 
goto same_process; 

/* 下面开始进行进程切换*/ 
kstat.context_swtch++; /*统计上下文切换的次数*/ 

struct mm_struct *mm = next->mm; 
struct mm_struct *oldmm = prev->active_mm; 
if (!mm) { /*如果是内核线程,则借用prev的地址空间*/ 
if (next->active_mm) BUG(); 
next->active_mm = oldmm; 
} else { /*如果是一般进程,则切换到next的用户空间*/ 
if (next->active_mm != mm) BUG(); 
switch_mm(oldmm, mm, next, this_cpu); 

if (!prev->mm) { /*如果切换出去的是内核线程*/ 
prev->active_mm = NULL;/*归还它所借用的地址空间*/ 
mmdrop(oldmm); /*mm_struct中的共享计数减1*/ 


switch_to(prev, next, prev); /*进程的真正切换,即堆栈的切换*/ 
__schedule_tail(prev); /*置prev->policy的SCHED_YIELD为0 */ 
same_process: 
reacquire_kernel_lock(current);/*针对SMP*/ 
if (current->need_resched) /*如果调度标志被置位*/ 
goto need_resched_back; /*重新开始调度*/ 
return; 
}
以上就是调度程序的主要内容,为了对该程序形成一个清晰的思路,我们对其再给出进一步的解释: 

· 如果当前进程既没有自己的地址空间,也没有向别的进程借用地址空间,那肯定出错。另外, 如果schedule()在中断服务程序内部执行,那也出错. 
· 对当前进程做相关处理,为选择下一个进程做好准备。当前进程就是正在运行着的进程,可是,当进入schedule()时,其状态却不一定是 TASK_RUNNIG,例如,在exit()系统调用中,当前进程的状态可能已被改为TASK_ZOMBE;又例如,在wait4()系统调用中,当前 进程的状态可能被置为TASK_INTERRUPTIBLE。因此,如果当前进程处于这些状态中的一种,就要把它从运行队列中删除。 
· 从运行队列中选择最值得运行的进程,也就是权值最大的进程。 
· 如果已经选择的进程其权值为0,说明运行队列中所有进程的时间片都用完了(队列中肯定没有实时进程,因为其最小权值为1000),因此,重新计算所有进程的时间片,其中宏操作NICE_TO_TICKS就是把优先级nice转换为时钟滴答。 
· 进程地址空间的切换。如果新进程有自己的用户空间,也就是说,如果next->mm与next->active_mm相同,那 么,switch_mm( )函数就把该进程从内核空间切换到用户空间,也就是加载next的页目录。如果新进程无用户空间(next->mm为空),也就是说,如果它是一个 内核线程,那它就要在内核空间运行,因此,需要借用前一个进程(prev)的地址空间,因为所有进程的内核空间都是共享的,因此,这种借用是有效的。 
· 用宏switch_to()进行真正的进程切换,后面将详细描述。 

你可能感兴趣的:(kernel)