时间系统、进程的调度与切换
注:本分类下文章大多整理自《深入分析linux内核源代码》一书,另有参考其他一些资料如《linux内核完全剖析》、《linux c 编程一站式学习》等,只是为了更好地理清系统编程和网络编程中的一些概念性问题,并没有深入地阅读分析源码,我也是草草翻过这本书,请有兴趣的朋友自己参考相关资料。此书出版较早,分析的版本为2.4.16,故出现的一些概念可能跟最新版本内核不同。
此书已经开源,阅读地址 http://www.kerneltravel.net
一、时间系统
大部分PC 机中有两个时钟源,他们分别叫做RTC 和OS(操作系统)时钟。RTC(Real Time Clock,实时时钟)也叫做CMOS 时钟,它是PC 主机板上的一块芯片(或者叫做时钟电路),它靠电池供电,即使系统断电,也可以维持日期和时间。由于它独立于操作系统,所以也被称为硬件时钟,它为整个计算机提供一个计时标准,是最原始最底层的时钟数据。
OS 时钟产生于PC 主板上的定时/计数芯片(8253/8254),由操作系统控制这个芯片的工作,OS 时钟的基本单位就是该芯片的计数周期。在开机时操作系统取得RTC
中的时间数据来初始化OS时钟,然后通过计数芯片的向下计数形成了OS 时钟,所以OS 时钟并不是本质意义上的时钟,它更应该被称为一个计数器。OS 时钟只在开机时才有效,而且完全由操作系统控制,所以也被称为软时钟或系统时钟。
Linux 的OS 时钟的物理产生原因是可编程定时/计数器产生的输出脉冲,这个脉冲送入CPU,就可以引发一个中断请求信号,我们就把它叫做时钟中断。Linux
中用全局变量jiffies 表示系统自启动以来的时钟滴答数目。每个时钟滴答,时钟中断得到执行。时钟中断执行的频率很高:100
次/秒(Linux 设计者将一个“时钟滴答”定义为10ms),时钟中断的主要工作是处理和时间有关的所有信息、决定是否执行调度程序。和时间有关的所有信息包括系统时间、进程的时间片、延时、使用CPU
的时间、各种定时器,进程更新后的时间片为进程调度提供依据,然后在时钟中断返回时决定是否要执行调度程序。
每个时钟中断(timer interrupt)发生时,由3 个函数协同工作,共同完成进程的选择和切换,它们是:schedule()、do_timer()及ret_form_sys_call()。我们先来解释一下这3
个函数。
• schedule():进程调度函数,由它来完成进程的选择(调度)。
• do_timer():暂且称之为时钟函数,该函数在时钟中断服务程序中被调用,是时钟中断服务程序的主要组成部分,该函数被调用的频率就是时钟中断的频率即每秒钟100 次(简称100
赫兹或100Hz);由这个函数完成系统时间的更新、进程时间片的更新等工作,更新后的进程时间片counter 作为调度的主要依据。
• ret_from_sys_call():系统调用、异常及中断返回函数。当一个系统调用或中断完成时,该函数被调用,用于处理一些收尾工作,例如信号处理、核心任务等。函数检测need_resched
标志,如果此标志为非0,那么就调用调度程序schedule()进行进程的选择。调度程序schedule()会根据具体的标准在运行队列中选择下一个应该运行的进程。当从调度程序返回时,如果发现又有调度标志被设置,则又调用调度程序,直到调度标志为0,这时,从调度程序返回时由RESTORE_ALL恢复被选定进程的环境,返回到被选定进程的用户空间,使之得到运行。
OS不是一直运行着的代码,而是一堆躺在内存里等着被调用的代码,中断处理在内核态,内核就是一个由
interrupt 驱动的程序。
可以是一个系统调用,x86 下很多OS的系统调用是靠 software interrupt 实现的,比如int 0x80,进入内核后就调用特定的内核函数执行。
也可以是一个用户程序产生的异常。比如执行cpu 指令违法,segment fault
什么的,操作系统一般会发送信号到进程,终止进程。
也可以是一个硬件产生的事件中断。比如由IO设备引起的可屏蔽中断,操作系统会调用特定的设备驱动程序进行服务。
一个用户程序运行的时候,Linux
进程就在内存里呆着,等着一个中断的到来。
一般的时分系统里,都会有个timer
interrupt 每隔一段时间到来,也就是上面说的时钟中断了。
二、linux 的调度程序 schedule()
进程的状态(简略版):
运行状态(Running):进程占用处理器资源;处于此状态的进程的数目小于等于处理器的数目。在没有其他进程可以执行时(如所有进程都在阻塞状态),通常会自动执行系统的空闲进程。
就绪状态(Ready):进程已获得除处理器外的所需资源,等待分配处理器资源;只要分配了处理器进程就可执行。就绪进程可以按多个优先级来划分队列。例如,当一个进程由于时间片用完而进入就绪状态时,排人低优先级队列;当进程由I/O操作完成而进入就绪状态时,排入高优先级队列。
阻塞状态(Blocked):当进程由于等待I/O操作或进程同步等条件而暂停运行时,它处于阻塞状态。
(一)、下面来了解一下主要的调度算法及其基本原理。
1.时间片轮转调度算法
时间片(Time Slice)就是分配给进程运行的一段时间。
在分时系统中,为了保证人机交互的及时性,系统使每个进程依次地按时间片轮流的方式执行,此时即应采用时间片轮转法进行调度。在通常的轮转法中,系统将所有的可运行(即就绪)进程按先来先服务的原则,排成一个队列,每次调度时把CPU
分配给队首进程,并令其执行一个时间片。时间片的大小从几ms 到几百ms 不等。当执行的时间片用完时,系统发出信号,通知调度程序,调度程序便据此信号来停止该进程的执行,并将它送到运行队列的末尾,等待下一次执行。然后,把处理机分配给就绪队列中新的队首进程,同时也让它执行一个时间片。这样就可以保证运行队列中的所有进程,在一个给定的时间(人所能接受的等待时间)内,均能获得一时间片的处理机执行时间。
2.优先权调度算法
为了照顾到紧迫型进程在进入系统后便能获得优先处理,引入了最高优先权调度算法。当将该算法用于进程调度时,系统将把处理机分配给运行队列中优先权最高的进程,这时,又可进一步把该算法分成两种方式。
(1)非抢占式优先权算法(又称不可剥夺调度,Nonpreemptive Scheduling)
在这种方式下,系统一旦将处理机(CPU)分配给运行队列中优先权最高的进程后,该进程便一直执行下去,直至完成;或因发生某事件使该进程放弃处理机时,系统方可将处理机分配给另一个优先权高的进程。这种调度算法主要用于批处理系统中,也可用于某些对实时性要求不严的实时系统中。Linux
2.4 之前 kernel is nonpreemptive
(2)抢占式优先权调度算法(又称可剥夺调度,Preemptive Scheduling)
该算法的本质就是系统中当前运行的进程永远是可运行进程中优先权最高的那个。在这种方式下,系统同样是把处理机分配给优先权(weight,goodness()函数求出)最高的进程,使之执行。但是只要一出现了另一个优先权更高的进程时,调度程序就暂停原最高优先权进程的执行,而将处理机分配给新出现的优先权最高的进程,即剥夺当前进程的运行。因此,在采用这种调度算法时,每当出现一新的可运行进程,就将它和当前运行进程进行优先权比较,如果高于当前进程,将触发进程调度。这种方式的优先权调度算法,能更好的满足紧迫进程的要求,故而常用于要求比较严格的实时系统中,以及对性能要求较高的批处理和分时系统中。Linux
2.6开始也实现了这种调度算法。
3.多级反馈队列调度
这是时下最时髦的一种调度算法。其本质是:综合了时间片轮转调度和抢占式优先权调度的优点,即:优先权高的进程先运行给定的时间片,相同优先权的进程轮流运行给定的时间片。
4.实时调度
最后我们来看一下实时系统中的调度。什么叫实时系统,就是系统对外部事件有求必应、尽快响应。在实时系统中存在有若干个实时进程或任务,它们用来反应或控制某个(些)外部事件,往往带有某种程度的紧迫性,因而对实时系统中的进程调度有某些特殊要求。在实时系统中,广泛采用抢占调度方式,特别是对于那些要求严格的实时系统。因为这种调度方式既具有较大的灵活性,又能获得很小的调度延迟;但是这种调度方式也比较复杂。
(二)、进程调度的时机
Linux 调度时机主要有。
(1)进程状态转换的时刻:进程终止、进程睡眠;
(2)当前进程的时间片用完时(current->counter=0);
(3)设备驱动程序;
(4)进程从中断、异常及系统调用返回到用户态时。
时机1,进程要调用sleep()或exit()等函数进行状态转换,这些函数会主动调用调度程序进行进程调度。
时机2,由于进程的时间片是由时钟中断来更新的,因此,这种情况和时机4 是一样的。
时机3,当设备驱动程序执行长而重复的任务时,直接调用调度程序。在每次反复循环中,驱动程序都检查need_resched 的值,如果必要,则调用调度程序schedule()主动放弃CPU。
时机4,如前所述,不管是从中断、异常还是系统调用返回,最终都调用ret_from_sys_call(),由这个函数进行调度标志need_resched的检测,如果必要,则调用调度程序。那么,为什么从系统调用返回时要调用调度程序呢?这当然是从效率考虑。从系统调用返回意味着要离开内核态而返回到用户态,而状态的转换要花费一定的时间,因此,在返回到用户态前,系统把在内核态该处理的事全部做完。
(三)、进程调度的依据
调度程序运行时,要在所有处于可运行状态的进程之中选择最值得运行的进程投入运行。选择进程的依据是什么呢?在每个进程的task_struct 结构中有如下5 项:
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
只是用来表示该进程的剩余滴答数,并不作为衡量它是否值得运行的标准,这和普通进程是有区别的。
(四)、进程可运行程度的衡量
函数goodness()就是用来衡量一个处于可运行状态的进程值得运行的程度。该函数综合使用了上面我们提到的5 项,给每个处于可运行状态的进程赋予一个权值(weight),调度程序以这个权值作为选择进程的唯一依据。
C++ Code
1
2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 |
//其中,在sched.h 中对调度策略定义如下: #define SCHED_OTHER 0 #define SCHED_FIFO 1 #define SCHED_RR 2 #define SCHED_YIELD 0x10 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; break; /* 普通进程 */ case SCHED_OTHER: { weight = p->counter; if(!weight) goto out; /* 做细微的调整*/ if (p->mm = this_mm || !p->mm) weight = weight + 1; weight += 20 - p->nice; break; } default: break; } out: return weight; /*返回权值*/ } |
这个函数比较很简单。首先,根据policy 区分实时进程和普通进程。实时进程的权值取决于其实时优先级,其至少是1000,与conter 和nice 无关。普通进程的权值需特别说明如下两点。
(1)为什么进行细微的调整?如果p->mm 为空,则意味着该进程无用户空间(例如内核线程),则无需切换到用户空间。如果
p->mm=this_mm,则说明该进程的用户空间就是当前进程的用户空间,该进程完全有可能再次得到运行。对于以上两种情况,都给其权值加1,算是对它们小小的“奖励”。
(2)进程的优先级nice 是从早期UNIX 沿用下来的负向优先级,其数值标志“谦让”的程度,其值越大,就表示其越“谦让”,也就是优先级越低,其取值范围为-20~+19,因此,(20-p->nice)的取值范围就是0~40。可以看出,普通进程的权值不仅考虑了其剩余的时间片,还考虑了其优先级,优先级越高,其权值越大。
(五)、进程调度的实现
调度程序在内核中就是一个函数schedule().函数所做的事解释如下:
• 如果当前进程既没有自己的地址空间,也没有向别的进程借用地址空间,那肯定出错。另外,如果schedule()在中断服务程序内部执行,那也出错。
• 对当前进程做相关处理,为选择下一个进程做好准备。当前进程就是正在运行着的进程,可是,当进入schedule()时,其状态却不一定是TASK_RUNNIG,例如,在exit()系统调用中,当前进程的状态可能已被改为TASK_ZOMBE;又例如,在wait()系统调用中,当前进程的状态可能被置为TASK_INTERRUPTIBLE。因此,如果当前进程处于这些状态中的一种,就要把它从运行队列中删除。
• 从运行队列中选择最值得运行的进程,也就是权值最大的进程。
• 如果已经选择的进程其权值为0,说明运行队列中所有进程的时间片都用完了(队列中肯定没有实时进程,因为其最小权值为1000),因此,重新计算所有进程的时间片,其中宏操作NICE_TO_TICKS
就是把优先级nice 转换为时钟滴答。
• 进程地址空间的切换。如果新进程有自己的用户空间,也就是说,如果next->mm 与next->active_mm 相同,那么,switch_mm()函数就把该进程从内核空间切换到用户空间,也就是加载next
的页目录。如果新进程无用户空间(next->mm 为空),也就是说,如果它是一个内核线程,那它就要在内核空间运行,因此,需要借用前一个进程(prev)的地址空间,因为所有进程的内核空间都是共享的,因此这种借用是有效的。
• 用宏switch_to()进行真正的进程切换。
三、进程切换
由于i386 CPU 要求软件设置TR 及TSS,Linux 内核只不过“走过场”地设置TR 及TSS,以满足CPU 的要求。但是,内核并不使用任务门,也不使用JMP 或CALL 指令实施任务切换。内核只是在初始化阶段设置TR,使之指向一个TSS,从此以后再不改变TR
的内容了。也就是说,每个CPU(如果有多个CPU)在初始化以后的全部运行过程中永远使用那个初始的TSS。同时,内核也不完全依靠TSS 保存每个进程切换时的寄存器副本,而是将这些寄存器副本保存在各个进程自己的内核栈中(task_struct中的thread_struct 结构)。
这样以来,TSS 中的绝大部分内容就失去了原来的意义。那么,当进行任务切换时,怎样自动更换堆栈?我们知道,新任务的内核栈指针(SS0
和ESP0)应当取自当前任务的TSS,可是,Linux 中并不是每个任务就有一个TSS,而是每个CPU 只有一个TSS。Intel 原来的意图是让TR 的内容随着任务的切换而走马灯似地换,而在Linux 内核中却成了只更换TSS
中的SS0 和ESP0,而不更换TSS 本身,也就是根本不更换TR 的内容。这是因为,改变TSS 中SS0 和ESP0 所化的开销比通过装入TR 以更换一个TSS 要小得多。因此,在Linux内核中,TSS 并不是属于某个进程的资源,而是全局性的公共资源。在多处理机的情况下,尽管内核中确实有多个TSS,但是每个CPU
仍旧只有一个TSS。
参考:http://www.ibm.com/developerworks/cn/linux/l-cn-timers/