2.6版以前内核进程调度机制简介
Linux的进程管理由进程控制块、进程调度、中断处理、任务队列、定时器、bottom half队列、系统调用、进程通信等等部分组成。
进程调用分为实时进程调度和非实时进程调度两种。前者调度时,可以采用基于动态优先级的轮转法(RR),也可以采用先进现出算法(FIFO)。后者调度时,一律采用基于动态优先级的轮转法。某个进程采用何种调度算法由改进程的进程控制块中的某些属性决定,没有专门的系统用来处理关于进程调度的相关事宜。Linux的进程调度由schedule()函数负责,任何进程,当它从系统调用返回时,都会转入schedule(),而中断处理函数完成它们的响应任务以后,也会进入schedule()。
1. 进程控制块数据结构
Linux系统的进程控制块用数据结构task_struct表示,这个数据结构占用1680个字节,具体的内容不在这里介绍,详细内容见《Linux内核2.4版源代码分析大全》第二页。
进程的状态主要包括如下几个:
TASK_RUNNING 正在运行或在就绪队列run-queue中准备运行的进程,实际参与进程调度。
TASK_INTERRUPTIBLE 处于等待队列中的进程,待资源有效时唤醒,也可由其它进程通过信号或定时中断唤醒后进入就绪队列run-queue。
TASK_UNINTERRUPTIBLE 处于等待队列的进程,待资源有效时唤醒,不也可由其它进程通过信号或者定时中断唤醒。
TASK_ZOMBIE 表示进程结束但尚未消亡的一种状态(僵死),此时,进程已经结束运行并且已经释放了大部分资源,但是尚未释放进程控制块。
TASK_STOPPED 进程暂停,通过其它进程的信号才能唤醒。
所有进程(以PCB形式)组成一个双向列表。next_task和prev_task就是链表的前后向指针。链表的头尾都是init_task(init进程)。不过进程还要根据其进程ID号插入到一个hash表当中,目的是加快进程搜索速度。
2. 进程调度
Linux进程调度由schedule()执行,其任务是在run-queue队列中选出一个就绪进程。
每个进程都有一个调度策略,在它的task_struct中规定(policy属性),或为SCHED_RR,SCHED_FIFO,或为SCHED_OTHER。前两种为实时进程调度策略,后一种为普通进程调度策略。
用户进程由do_fork()函数创建,它也是fork系统调用的执行者。do_fork()创建一个新的进程,继承父进程的现有资源,初始化进程时钟、信号、时间等数据。完成子进程的初始化后,父进程将它挂到就绪队列,返回子进程的pid。
进程创建时的状态为TASK_UNINTERRUPTIBLE,在do_fork()结束前被父进程唤醒后,变为TASK_RUNNING。处于TASK_RUNNING状态的进程被移到就绪队列中,当适当的时候由schedule()按CPU调度算法选中,获得CPU。
如果进程采用轮转法,当时间片到时(10ms的整数倍),由时钟中断触发timer_interrupt()函数引起新一轮的调度,把当前进程挂到就绪队列的尾部。获得CPU而正在运行的进程若申请不到某个资源,则调用sleep_on()或interruptible_sleep_on()睡眠,并进入就绪队列尾。状态尾TASK_INTERRUPTIBLE的睡眠进程当它申请的资源有效时被唤醒,也可以由信号或者定时中断唤醒,唤醒以后进程状态变为TASK_RUNNING,并进入就绪队列。
首先介绍一下2.6版以前的的调度算法的主要思想,下面的schedule()函数是内核2.4.23中摘录的:
asmlinkage void schedule(void)
{
struct schedule_data * sched_data;
struct task_struct *prev, *next, *p;
struct list_head *tmp;
int this_cpu, c;
spin_lock_prefetch(&runqueue_lock);
BUG_ON(!current->active_mm);
need_resched_back:
/*记录当前进程和处理此进程的CPU号*/
prev = current;
this_cpu = prev->processor;
/*判断是否处在中断当中,这里不允许在中断处理当中调用sechedule()*/
if (unlikely(in_interrupt())) {
printk("Scheduling in interruptn");
BUG();
}
release_kernel_lock(prev, this_cpu);
/*'sched_data' 是收到保护的,每个CPU只能运行一个进程。*/
sched_data = & aligned_data[this_cpu].schedule_data;
spin_lock_irq(&runqueue_lock);
/*如果当前进程的调度策略是轮转RR,那么需要判断当前进程的时间片是否已经用完,如果已经用完,则重新计算时间片值,然后将该进程挂接到就绪队列run-queue的最后*/
if (unlikely(prev->policy == SCHED_RR))
if (!prev->counter) {
prev->counter = NICE_TO_TICKS(prev->nice);
move_last_runqueue(prev);
}
/*假如前进程为TASK_INTERRUPTTIBLE状态,则将其状态置为TASK_RUNNING。如是其它状态,则将该进程转为睡眠状态,从运行队列中删除。(已不具备运行的条件) */
switch (prev->state) {
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;
/*遍历进程就绪队列,如果该进程能够进行调度(对于SMP来说就是判断当前CPU未被占用能够执行这个进程,对于非SMP系统则为1),则计算该进程的优先级,如果优先级大于当前进程,则next指针指向新的进程,循环直到找到优先级最大的那个进程*/
list_for_each(tmp, &runqueue_head) {
p = list_entry(tmp, struct task_struct, run_list);
if (can_schedule(p, this_cpu)) {
int weight = goodness(p, this_cpu, prev->active_mm);
if (weight > c)
c = weight, next = p;
}
}
/* 判断是否需要重新计算每个进程的时间片,判断的依据是所有正准备进行调度的进程时间片耗尽,这时,就需要对就绪队列中的每一个进程都重新计算时间片,然后返回前面的调度过程,重新在就绪队列当中查找优先级最高的进程执行调度。 */
if (unlikely(!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;
}
/*CPU私有调度数据中记录当前进程的指针,并且将当前进程与CPU绑定,如果待调度进程与前面一个进程属于同一个进程,则不需要调度,直接返回。*/
sched_data->curr = next;
task_set_cpu(next, this_cpu);
spin_unlock_irq(&runqueue_lock);
if (unlikely(prev == next)) {
/* We won't go through the normal tail, so do this by hand */
prev->policy &= ~SCHED_YIELD;
goto same_process;
}
/*全局统计进程上下文切换次数*/
kstat.context_swtch++;
/*如果后进程的mm为0 (未分配页),则检查是否被在被激活的页里(active_mm),否则换页。令后进程记录前进程激活页的信息,将前进程的active_mm中的mm_count值加一。将cpu_tlbstate[cpu].state改为 TLBSTATE_LAZY(采用lazy模式) 如果后进程的mm不为0(已分配页),但尚未激活,换页。切换mm(switch_mm)。如果前进程的mm 为0(已失效),将其激活记录置空,将mm结构引用数减一,删除该页。 */
prepare_to_switch();
{
struct mm_struct *mm = next->mm;
struct mm_struct *oldmm = prev->active_mm;
if (!mm) {
BUG_ON(next->active_mm);
next->active_mm = oldmm;
atomic_inc(&oldmm->mm_count);
enter_lazy_tlb(oldmm, next, this_cpu);
} else {
BUG_ON(next->active_mm != mm);
switch_mm(oldmm, mm, next, this_cpu);
}
if (!prev->mm) {
prev->active_mm = NULL;
mmdrop(oldmm);
}
}
/*切换到后进程,调度过程结束*/
switch_to(prev, next, prev);
__schedule_tail(prev);
same_process:
reacquire_kernel_lock(current);
if (current->need_resched)
goto need_resched_back;
return;
}
3. 进程上下文切换(摘自中国Linux论坛一片文章)
首先进程切换需要做什么?它做的事只是保留正在运行进程的"环境",并把将要运行的进程的"环境"加载上来,这个环境也叫上下文。它包括各个进程"公用"的东西,比如寄存器。下一个问题,旧的进程环境保存在那,新的进程环境从那来,在i386上,有个tss段,是专用来保存进程运行环境的。在Linux来说,在结构task_struct中有个类型为struct thread_struct的成员叫tss,如下:
struct task_struct {
。。。
/* tss for this task */
struct thread_struct tss;
。。。
};
它是专用来存放进程环境的,这个结构体因CPU而异,你看它就能知道有那些寄存器是需要保存的了。
最后的问题就是切换了,虽然在i386上CPU可以自动根据tss去进行上下文的切换,但是Linux的程序员们更愿意自己做它,原因是这样能得到更有效的控制,而且作者说这和硬件切换的速度差不多,这可是真的够伟大的。
好了,现在来看源码,进程切换是使用switch_to这个宏来做的,当进入时prev即是现在运行的进程,next是接下来要切换到的进程,
#define switch_to(prev,next,last) do {
asm volatile(
"pushl %%esint"
"pushl %%edint"
"pushl %%ebpnt"
// 首先它切换堆栈指针,prev->tss.esp = %esp;%esp = next->tss.esp,这以后的堆栈已经是next的堆栈了。
"movl %%esp,%0nt" /* save ESP */
"movl %3,%%espnt" /* restore ESP */
// 然后使进程prev的指针保存为标号为1的那一个指针,这样下次进程prev可以运行时,它第一个执行的就是pop指令。
"movl $1f,%1nt" /* save EIP */
// 把进程next保存的指针推进堆栈中,这句作用是,从__switch_to返回时,下一个要执行的指令将会是这个指针所指向的指令了。
"pushl %4nt" /* restore EIP */
// 使用jump跳到__switch_to函数的结果是:调用switch_to函数但不象call那样要压栈,但是ret返回时,仍是要弹出堆栈的,也就是上条指令中推进去的指令指针。这样,堆栈和指令都换了,进程也就被"切换"了。
"jmp __switch_ton"
// 由于上面所说的原因,__switch_to返回后并不会执行下面的语句,要执行到这,只有等进程prev重新被调度了。
"1:t"
"popl %%ebpnt"
"popl %%edint"
"popl %%esint"
:"=m" (prev->tss.esp),"=m" (prev->tss.eip),
"=b" (last)
:"m" (next->tss.esp),"m" (next->tss.eip),
"a" (prev), "d" (next),
"b" (prev));
} while (0)
最后是__switch_to函数,它虽然是c形式,但内容还都是嵌入汇编。
// 这句跟fpu有关,我并不感兴趣,就略过了。
unlazy_fpu(prev);
// 这句可能需要你去记起前面第二章中所描述的gdt表的结构了,它的作用是把进程next的tss描述符的type中的第二位清0,这位表示这个描述符是不是当前正在用的描述符,作者说如果不清0就把它load进tss段寄存器的话,系统会报异常(我可没试过)。
gdt_table[next->tss.tr >> 3].b &= 0xfffffdff;
// 把进程next的tss段load到tss段存器中。
asm volatile("ltr %0": :"g" (*(unsigned short *)&next->tss.tr));
// 保存进程prev的fs和gs段寄存器
asm volatile("movl %%fs,%0":"=m" (*(int *)&prev->tss.fs));
asm volatile("movl %%gs,%0":"=m" (*(int *)&prev->tss.gs));
然后下面就是load进程next的ldt,页表,fs,gs,debug寄存器。
因为Linux一般并不使用ldt,所以它们一般会指向一个共同的空的ldt段描述符,这样就可能不需要切换ldt了,如果进程next和prev是共享内存的话,那么页表的转换也就不必要了(这一般发生在clone时)。
二、2.6版内核对进程调度的优化
1. 新调度算法简介
2.6版本的Linux内核使用了新的调度器算法,称为O(1)算法,它在高负载的情况下执行得非常出色,并在有多个处理器时能够很好地扩展。
2.4版本的调度器中,时间片重算算法要求在所有的进程都用尽它们的时间片后,新时间片才会被重新计算。在一个多处理器系统中,当进程用完它们的时间片后不得不等待重算,以得到新的时间片,从而导致大部分处理器处于空闲状态,影响SMP的效率。此外,当空闲处理器开始执行那些时间片尚未用尽的、处于等待状态的进程时,会导致进程开始在处理器之间“跳跃”。当一个高优先级进程或交互式进程发生跳跃时,整个系统的性能就会受到影响。
新调度器解决上述问题的方法是,基于每个CPU来分布时间片,并取消全局同步和重算循环。调度器使用了两个优先级数组,即活动数组和过期数组,可以通过指针来访问它们。活动数组中包含所有映射到某个CPU且时间片尚未用尽的任务。过期数组中包含时间片已经用尽的所有任务的有序列表。如果所有活动任务的时间片都已用尽,那么指向这两个数组的指针互换,包含准备运行任务的过期数组成为活动数组,而空的活动数组成为包含过期任务的新数组。数组的索引存储在一个64位的位图中,所以很容易找到最高优先级的任务。
新调度器的主要优点包括:
◆ SMP效率如果有工作需要完成,所有处理器都会工作。
◆ 等待进程没有进程需要长时间地等待处理器,也没有进程会无端地占用大量的CPU时间。
摘自http://blog.csdn.net/joshua_yu/archive/2006/02/02/591038.aspx