进程调度之6:进程的调度与切换

date: 2014-10-31 12:16linux

1 linux的调度机制

在讨论进程的调度与切换时,咱们关注以下几个问题:安全

  1. 切换的时机:在何时进行切换
  2. 调度策略(policy):根据什么准则挑选下一个进行运行的进程
  3. 调度的方式:是可剥夺(preemptive)仍是不可剥夺(nonpreemptive)。当正在运行的进程没有觉悟自愿放弃对CPU的使用权时,是否能够强制性的暂时剥夺其使用权,中止其运行而给其余进程一个机会?若是可剥夺,是否任何条件下均可剥夺,有没有例外?

那么linux内核的调度机制是怎样的呢?先来看看进程状态转换关系示意图:app

进程状态机

1.1 调度的时机分两种状况

首先自愿的调度随时均可以进行。在内核空间中,一个进程能够随时经过调用schedule来启动一次调度。在用户空间中,进程能够经过系统调用pause来自愿让出cpu从而启动一次调度。从应用的角度来看,只有在用户空间自愿放弃(pause系统调用以及nanosleep系统调用,注意sleep不是系统调用而是C库函数)这一举动是可见的;而进程陷入内核后的自愿放弃行为是不可见的,它隐藏在其余可能受阻的系统调用,好比open、read、write等。进程因这些系统调用而陷入内核,若是这些调用被阻塞,总不能让CPU阻塞在这里啥都不干吧,因而内核就替进程作主,自愿放弃CPU启动一次调度。ide

此外,若是一个进程运行太长时间,调度器可能会进行一次强制调度。非自愿的被强制的调度(发生在每次从系统调用返回到用户空间的前夕,以及每次从中断或异常处理返回到用户空间的前夕。注意这里的“返回到用户空间的前夕”的限定,对系统调用来讲,确定是返回到用户空间了;对中断或异常来讲,它有可能发生在用户空间(当进程在用户空间运行时中断来了),也可能发生在内核空间(即当进程陷入内核后,中断来了),那么中断有可能返回到用户空间也有可能返回到内核空间。有了这个限定之后,只有当在用户空间发生的中断,其返回到用户空间前夕,才会进行一次强制调度;而在内核空间发生的中断,其返回时不会进行强转调度。这就给内核的设计与实现带来了便利。想一想看,若是没有这个限定的话,在内核空间中,当前进程可能由于中断而被强转换出,其正在使用的资源可能会被新运行的进程所修改,这样一来,全部在进程间共享的数据都要经过互斥来保护了,这种多进程共享的数据何其多矣,加不胜加呀。函数

还要指明,强制调度还有一个条件,那就是当前进程task_struct结构的need_resched字段被置1(前面讲fork流程时,父进程将本身的need_resched置1,所以,从fork返回时会发生一次强制性调度),那么谁来设置该字段了,天然只有内核了,用户空间没法访问到task_struct结构的。什么状况下设置该字段呢?其一,在某些系统调用的内核实现中设置,好比系统调用pause、fork中,还有其余调用可能受阻的系统调中;其二在时钟中断服务程序中,发现当前进程运行过久时设置;其三,内核中因某种缘由唤醒一个进程时。this

1.2 调度方式为“有条件可剥夺”方式

当进程在用户空间中运行时,无论自愿不自愿,一旦有必要(好比运行太长时间),内核就能够暂时剥夺其运行转而调度其余进程运行。但是,一旦进程进入内核空间,就像进入“安全地带”,这时,尽管内核知道要调度了,也只能干等着,等待进程离开“安全地带”返回用户空间前夕将其剥夺。所以说,linux的调度方式是可剥夺的,但因为剥夺时机的限制而变成有条件可剥夺的了。 那么,剥夺式的调用发生在何时呢?一样是进程从系统空间返回用户空间的前夕。设计

其实,这里讨论“有条件可剥夺”与前面的调度时机是密切相关的,剥夺式的调度即非自愿的强制调度,它剥夺当前进程的运行权利而让其余进程运行。3d

1.3 调度政策

调度政策为以优先级为基础的调度。内核为每一个进程计算出一个反应其运行资格的权值,而后挑选权值最高的进程投入运行。而资格的运算则是以优先级为基础。unix

为了适应不一样的需求,内核实现了三种不一样的政策:SCHED_FIFO、SCHED_RR以及SCHED_OTHER。SCHED_FIFO适应于实时性要求比较强、而每次运行的耗时又比较短的进程;SCHED_RR适用于实时性要求较高但每次运行耗时较长的进程,其中的RR表示“Round Robin”即轮流之意,意即当多个进程具备同一优先级时,轮流调度运行;SCHED_OTHER则为传统的调度政策,适用与交互式的分时应用。 既然每一个进程都有本身使用的调度政策,那么在计算运行资格时涉及到“归一化”的问题,即在计算资格时将政策也考虑进去,就像高考时,给符合某条件的考生加分同样。计算资格的函数为goodness,咱们在后面会详细讲到。指针

2 schedule函数流程以及进程切换过程

2.1 主要流程

在exit一节中,一个即将去世的进程在do_exit中的最后一件事就是调用schedule自愿让出CPU,这是自愿调度的情形;此外,每当系统调用(或者是中断)返回到用户空间的前夕,内核会检查当前进程的need_resched字段,若是该字段非0,则调用schedule()进行一次强制性调度(这部分代码在<arch/i386/kernel/entry.s>中),这是强制调度的情形。

本小节咱们来看看schedule的流程,其定义在<kernel/sched.c>文件中,流程图以下(源代码里用了大量的goto 语句,这里为了描述方便,在不影响流程的状况下,省略对这些跳转描述):

schedule流程

2.2 active_mm

进程的task_struct结构中有两个mm_struct结构指针:一个是mm,指向进程的用户空间,另外一个是active_mm。对于具备用户空间的进程这两个指针是一致的(好比在execve系统调用中会设置成一致,参考exec_mmap函数的详细代码);可是当一个不具有用户空间的内核进程被调度运行时,要求它必须有一个active_mm,因此只好借用一个。问谁借呢,最简单就是为借用当前进程(即将被换出的进程)的active_mm(当前进程也多是是个内核进程,它的active_mm也多是借来的),由于这样能够省去用户空间切换的开销,而在该进程被换成中止运行时,要记得归还它借来的active_mm。

为何必需要有一个active_mm?由于指向页面映射目录表的指针pgd就在这个结构中,内核进程不是没有用户空间吗,它要pgd何用?不要忘了,目录表中除了有用户空间的虚存页面映射,还有内核空间的虚存页面映射,参考第2章第6节。

2.3 中断上下文

schedule只能由进程在内核空间中主动调用,或者在当进程从系统空间返回到用户空间前夕被动地调用,而不能在一个中断服务程序内部调用。即便一个中断服务程序有调度的要求,也只能经过设置当前进程的need_resched字段为1来表达这种需求,而不能直接调用schedule。那么怎么判断当前处在中断上下文(即在中断服务程序里还没出来)呢?咱们来看看in_interrupt的定义。

<include/asm/hardirq.h>
    20 /*
    21  * Are we in an interrupt context? Either doing bottom half
    22  * or hardware interrupt processing?
    23  */
    24 #define in_interrupt() ({ int __cpu = smp_processor_id(); \
    25     (local_irq_count(__cpu) + local_bh_count(__cpu) != 0); })

在单CPU系统中,__cpu为0。在中断服务的入口和出口出,分别会调用irq_enter()和irq_exit()来递增和递减计数器local_irq_count[__cpu],只要这个计数器非0,就说明CPU在中断服务程序中还未离开。相似的,只要计数器local_bh_count[__cpu]非0就说明CPU在执行某个bh函数。就像停车场,每开入一辆车计算加1,每开出一辆车计数器减1,若是这个计数器非0,则说明停车场内还有车。

2.4 goodness函数以及进程运行资格的计算

/*
     * This is the function that decides how desirable a process is..
     * You can weigh different processes against each other depending
     * on what CPU they've run on lately etc to try to handle cache
     * and TLB miss penalties.
     *
     * Return values:
     *	 -1000: never select this
     *	     0: out of time, recalculate counters (but it might still be selected)
     *	   +ve: "goodness" value (the larger, the better)
     *	 +1000: realtime process, select this.
     */
    
    static inline int goodness(struct task_struct * p, int this_cpu, struct mm_struct *this_mm)
    {
    	int weight;
    
    	/*
    	 * select the current process after every other
    	 * runnable process, but before the idle thread.
    	 * Also, dont trigger a counter recalculation.
    	 */
    	weight = -1;
    	if (p->policy & SCHED_YIELD)
    		goto out;
    
    	/*
    	 * Non-RT process - normal case first.
    	 */
    	if (p->policy == SCHED_OTHER) {
    		/*
    		 * Give the process a first-approximation goodness value
    		 * according to the number of clock-ticks it has left.
    		 *
    		 * Don't do any other calculations if the time slice is
    		 * over..
    		 */
    		weight = p->counter;
    		if (!weight)
    			goto out;
    			
    		/* .. and a slight advantage to the current MM */
    		if (p->mm == this_mm || !p->mm)
    			weight += 1;
    		weight += 20 - p->nice;
    		goto out;
    	}
    
    	/*
    	 * Realtime process, select the first one on the
    	 * runqueue (taking priorities within processes
    	 * into account).
    	 */
    	weight = 1000 + p->rt_priority;
    out:
    	return weight;
    }

这个函数比较简单,不一样调度政策运行资格的计算可参考以下表格:

goodness

回到流程图中,若是遍历完可运行队列中全部的进程,那么候选进程的运行资格c的值有以下几种可能:

goodness返回值

为进程从新分配时间配的代码以下:

for_each_task(p)
        p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice);

宏NICE_TO_TICKS的定义以下。参考注释,做者的意图是但愿NICE_TO_TICKS获得的时间片在50ms左右,所以须要根据时钟频率HZ来定义。好比,若是HZ为200,表示每秒中断200次,那么一个滴答tick为5ms,20-(nice)的取值为[1 ,40],平均值为20,将20右移1位即除以2为10,10个滴答即50ms。当时钟频率HZ越高,每一个滴答所表明的时间越短,NICE_TO_TICKS分配的滴答数越多,但最大只是20-(nice)的值左移2位即乘以4,极大值为160,这是没法与实时调度政策中最低运行资格为1000相抗衡的。

/*
     * Scheduling quanta.
     *
     * NOTE! The unix "nice" value influences how long a process
     * gets. The nice value ranges from -20 to +19, where a -20
     * is a "high-priority" task, and a "+10" is a low-priority
     * task.
     *
     * We want the time-slice to be around 50ms or so, so this
     * calculation depends on the value of HZ.
     */
    #if HZ < 200
    #define TICK_SCALE(x)	((x) >> 2)
    #elif HZ < 400
    #define TICK_SCALE(x)	((x) >> 1)
    #elif HZ < 800
    #define TICK_SCALE(x)	(x)
    #elif HZ < 1600
    #define TICK_SCALE(x)	((x) << 1)
    #else
    #define TICK_SCALE(x)	((x) << 2)
    #endif
    
    #define NICE_TO_TICKS(nice)	(TICK_SCALE(20-(nice))+1)

另外,须要说明,在从新计算时间配额时,对全部进程都进行了更新。并且更新是将原有配额除以2再加上NICE_TO_TICKS。那么那些不在可运行队列中的调度政策为SCHED_OTHER的进程,会所以得到较高的时间配额,在未来的调度中会占必定的优点。但这种更新方式也决定了更新后的时间配额不会超过两倍的NICE_TO_TICKS。所以即便调度政策为SCHED_OTHER的进程通过长期的“韬光养晦”,其运行资格也没法超过实时调度政策的进程。

2.5 switch_to

任务切换的核心为switch_to,这是一段嵌入式汇编代码,定义在<include/asm/system.h>文件中:

#define switch_to(prev,next,last) do {					\
    	asm volatile("pushl %%esi\n\t"					\
    		     "pushl %%edi\n\t"					\
    		     "pushl %%ebp\n\t"					\
    		     "movl %%esp,%0\n\t"	/* save ESP */		\
    		     "movl %3,%%esp\n\t"	/* restore ESP */	\
    		     "movl $1f,%1\n\t"		/* save EIP */		\
    		     "pushl %4\n\t"		/* restore EIP */	\
    		     "jmp __switch_to\n"				\
    		     "1:\t"						\
    		     "popl %%ebp\n\t"					\
    		     "popl %%edi\n\t"					\
    		     "popl %%esi\n\t"					\
    		     :"=m" (prev->thread.esp),"=m" (prev->thread.eip),	\
    		      "=b" (last)					\
    		     :"m" (next->thread.esp),"m" (next->thread.eip),	\
    		      "a" (prev), "d" (next),				\
    		      "b" (prev));					\
    } while (0)

switch_to()有三个参数,schedule()调用它时,第一个和第三个参数传入的是当前进程,即要被调度器换出的进程,设为进程A,第二个参数传入的是候选进程,及要被调度器调度运行的进程,设为进程B,咱们用三步法来分析下这段代码:

;伪寄存器
    ;  prev->thread.esp --> r0
    ;  prev->thread.eip --> r1
    ;  last --> r2
    ;  next->thread.esp --> r3
    ;  next->thread.eip --> r4
    
    ;伪寄存器与通用寄存器结合的"建议"
    ;  prev --> eax
    ;  netx --> edx
    ;  prev --> ebx
    
    
    016    pushl %esi           /*在A进程的系统空间堆栈中进行入栈操做*/
    017    pushl %edi
    018    pushl %ebp
    019    movl %esp, r0        /*保存A进程系统空间堆栈的栈顶指针esp到其task_struct
                                    结构的thread成员中*/
    020    movl r3, %esp        /*从B进程task_struct结构的thread成员中恢复B进程的
                                   系统空间堆栈esp,执行该指令以后,系统空间堆栈已经切换
                                   到了B进程的系统空间堆栈 */
    021    move $lf, r1         /*设置A进程下一次调度执行时系统空间eip为标号1的地址
                                   (保存到A进程task_struct结构的thread成员中)*/
    022    push r4               /*B进程系统空间的eip入栈(此时,固然是B进程的系统空间
                                    堆栈) */ 
    023    jmp __switch_to      /*这里经过jmp而不是call调用函数__switch_to,因而
                                    __switch_to函数的返回地址就上上条指令压入的r4,即
                                   进程B系统空间的eip*/
    024 1:
    025    popl %ebp             /*在B进程的系统空间堆栈中进行出栈操做*/
    026    popl %edi
    027    popl %esi

代码的意图请参考注释。这段代码对进程A与进程B的操做以下图所示:

switch_to示意图

咱们再来逐行说明下:

  • 第16~18行,备份寄存器esi、edi与ebp到进程A的系统空间堆栈中;第19行,将进程A系统空间堆栈的栈顶位置备份到其task_struct结构下属的thread_struct结构中;
  • 第20行,将进程B备份的系统空间堆栈栈顶位置装载进esp寄存器,这样便完成了系统空间堆栈的切换,此时current宏所表明的进程就是进程B了。咱们以动态的眼光来看,进程B以前确定也被换出过,也曾作过“进程A”,意即也曾“趟过”第16~19行代码,那么其系统空间堆栈中确定已经备份了寄存器esi、edi与ebp,而其esp指向备份寄存器后的栈顶(这里有一例外,就是fork产生的子进程初次运行时,见后文分析)。
  • 第21行,设置进程A系统空间的eip(一样存储在task_struct结构下属的thread_struct结构中)为标号1的地址。进程A被暂停运行,下一次被调度运行,将从标号1处开始执行(参考后续分析)。
  • 第22行,进程B的eip入栈。一样,用动态的眼光来看,进程B的备份的eip也指向标号1处。注意,这里有一个例外,那就是fork产生的子进程。还记得fork系统调用中的copy_thread()函数吗,在该函数中将子进程的eip设置为ret_from_fork(定义在<arch/i386/kernel/entry.s>中的第179行)。
  • 第23行,经过jmp指令调用__switch_to()函数,因为不是经过call指令来调用子函数(call指令调用子函数时,会将子函数的返回地址即call指令的下一条指令的地址入栈),那么第22行入栈的eip即为__switch_to()函数的返回地址。那么当从__switch_to()返回后,进程B接着从标号1处开始执行。

咱们来看看B进程的执行路径:

  • 25行~27行,寄存器出栈,这与16~18行相对应。因为switch_to()自己是个宏,编译后“内嵌”到schedule()函数中。因此接下来进程B继续在schedule()函数中执行,直到schedule()函数结束后返回。

  • 进程B从schedule()函数返回时,要从B进程的系统空间堆栈弹出返回地址。用动态的眼光来看,B进程当初被暂停运行时,确定也曾调用过schedule(有一个特例,fork系统调用产生的子进程,下文详述),系统空间堆栈中确定保存着schedule()的返回地址。若是进程B当初在内核代码中主动调用schecule(),那么如今将回到schedule()的下一条代码执行;

  • 若是进程B当初是在从系统空间返回用户空间前夕被强制调用了schedule(),那么这时将会回到<arch/i386/kernel/entry.s>中的第289行(见下),调用ret_from_sys_call恢复进程B用户空间的现场(从进程B系统空间堆栈顶部的pt_regs处恢复),进程B就回到它的用户空间继续运行了。

    287 reschedule:
        288    call SYMBOL_NAME(schedule)    # test
        289    jmp ret_from_sys_call

咱们再来看看fork产生的子进程首次被调度运行时的运行路线:

  • 第22行,进程B的eip入栈。进程的eip被设置为ret_from_fork();

  • 第23行,经过jmp指令调用__switch_to()函数。__switch_to()函数的返回地址即为ret_from_fork()函数的入口,那么当从__switch_to()函数返回时,直接跳转到ret_from_fork()处执行,继而跳转到ret_from_sys_call处,到达ret_with_reschedule时,因为子进程的need_resched字段为0,那么就直接返回到用户空间了。这部分代码在<arch/i386/kernel/entry.s>中第205行到223行。

  • fork产生的子进程初次被调度时,没有执行switch_to()的第25行到27行,也不涉及从schedule()函数中返回,它“抄了一段近路”直接到跳转至ret_from_fork(),而后返回到用户空间。

  • 至于函数__switch_to(),那只是内核“应付”intel的“硬”任务切换。intel支持由CPU硬件来实施任务切换(核心是TSS),但linux并不买帐。由于这个“大而全”硬件切换一是缺乏灵活性,二是切换速度不必定快(由于有不少多余的切换动做)。

相关文章
相关标签/搜索