http://www.ibm.com/developerworks/cn/linux/kernel/l-k24sch/index.html

杨沙洲 (pubb@163.net)国防科技大学计算机学院

 

简介: 本文详尽地分析了Linux 2.4内核中调度系统的工作原理,特别是i386体系结构下SMP系统的调度表现。通过对2.4调度系统实现原理及其细节的分析,文章在文末指出了2.4调度系统在功能上、实时性上以及多处理机系统表现上存在的不足,为后继的2.6系统的分析作铺垫。

一. 前言

在开源操作系统中,Linux的发展最为显著,到目前为止,它在低端服务器市场已经占据了相当大的份额。从最新的Linux 2.6系统来看,Linux的发展方向主要有两个:嵌入式系统和高端计算领域。

调度系统对于操作系统的整体性能有着非常重要的影响,嵌入式系统、桌面系统和高端服务器对于调度器的要求是很不一样的。Linux调度器的特点主要有两个:

  • 核心不可抢占;
  • 调度算法简单有效。

由于Linux适用于多种平台,本文所指缺省为i386下的SMP系统。

 

二. 相关数据结构

在Linux中,进程用task_struct表示,所有进程被组织到以init_task为表头的双向链表中(见[include/linux/sched.h]SET_LINKS()宏),该链表是全系统唯一的。所有CPU被组织到以schedule_data(对界后)为元素的数组之中。进程与所运行的CPU之间可以相互访问(详见下)。

所有处于运行态的进程(TASK_RUNNING)被组织到以runqueue_head为表头的双向链表之中,调度器总是从中寻找最适合调度的进程。runqueue_head也是全系统唯一的。

下面分别介绍这些与调度器工作相关的数据结构。

1. init_tss

TSS,Task State Segment,80x86平台特有的进程运行环境,尽管Linux并不使用TSS,但将TSS所需要描述的信息保存在以cpu号为索引的tss_struct数组init_tss中,进程切换时,其中的值将获得更新。

2. task_struct

在Linux中,线程、进程使用的是相同的核心数据结构,可以说,在2.4的内核里只有进程,其中包含轻量进程。一个进程在核心中使用一个task_struct结构来表示,包含了大量描述该进程的信息,其中与调度器相关的信息主要包括以下几个:

i. state

Linux的进程状态主要分为三类:可运行的(TASK_RUNNING,相当于运行态和就绪态);被挂起的(TASK_INTERRUPTIBLE、TASK_UNINTERRUPTIBLE和TASK_STOPPED);不可运行的(TASK_ZOMBIE),调度器主要处理的是可运行和被挂起两种状态下的进程,其中TASK_STOPPED又专门用于SIGSTP等IPC信号的响应,而TASK_ZOMBIE指的是已退出而暂时没有被父进程收回资源的"僵尸"进程。

ii. need_resched

布尔值,在调度器中用于表示该进程需要申请调度(详见"调度器工作流程")。

iii. policy

在Linux 2.4中,进程的调度策略可以有三种选择:SCHED_FIFO(先进先出式调度,除非有更高优先级进程申请运行,否则该进程将保持运行至退出才让出CPU)、SCHED_RR(轮转式调度,该进程被调度下来后将被置于运行队列的末尾,以保证其他实时进程有机会运行)、SCHED_OTHER(常规的分时调度策略)。另外,policy中还包含了一个SCHED_YIELD位,置位时表示主动放弃CPU。

iv. rt_priority

用于表征实时进程的优先级,从1-99取值,非实时进程该项应该为0。这一属性将用于调度时的权值计算(详见"就绪进程选择算法")。

v. counter

该属性记录的是当前时间片内该进程还允许运行的时间(以CPU时钟tick值为单位,每个进程的counter初值与nice值有关,nice越小则counter越大,即优先级越高的进程所允许获得的CPU时间也相对越多),并参与"就绪进程选择算法"。在Linux 2.4中,每个(非SCHED_FIFO实时)进程都不允许运行大于某一时间片的时间,一旦超时,调度器将强制选择另一进程运行(详见"调度器工作流程")

vi. nice

用户可支配的进程优先级,将参与"就绪进程选择算法",同时该值也决定了该进程的时间片长度(详见下)。

vii. cpus_allowed

以位向量的形式表示可用于该进程运行的CPU(见"调度器工作流程")。

viii. cpus_runnable

以位向量的形式表示当前运行该进程的CPU(相应位为1)。如果不在任何CPU上运行,则为全1。这一属性和cpus_allowed属性结合,可以迅速判断该进程是否能调度到某一CPU上运行(位"与")。

ix. processor

本进程当前(或最近)所在CPU编号。

x. thread

用于保存进程执行环境(各个寄存器的值以及IO操作许可权映射表),内容与TSS相近。因为TSS以CPU id为索引,而Linux无法预测被替换下来的进程下一次将在哪个CPU上运行,所以这些信息不能保存在TSS中。

3. current

核心经常需要获知当前在某CPU上运行的进程的task_struct,在Linux中用current指针指向这一描述符。current的实现采用了一个小技巧以获得高效的访问速度,这个小技巧与Linux进程task_struct的存储方式有关。

在Linux中,进程在核心级运行时所使用的栈不同于在用户级所分配和使用的栈。因为这个栈使用率不高,因此仅在创建进程时分配了两个页(8KB),并且将该进程的task_struct安排在栈顶。(实际上这两个页是在分配task_struct时申请的,初始化完task_struct后即将esp预设为页尾作为进程的核心栈栈底,往task_struct方向延伸。)

因此,要访问本进程的task_struct,只需要执行以下简单操作:

__asm__("andl %%esp,%0; ":"=r" (current) : "0" (~8191UL));

此句将esp与0x0ffffe0作"与"运算,获得核心栈的首页基址,此即为task_struct的地址。

4. schedule_data

task_struct是用于描述进程的数据结构,其中包含了指向所运行CPU的属性。在Linux中,另有一个数据结构对应于CPU,可以利用它访问到某CPU上运行的进程,这个数据结构定义为schedule_data结构,包含两个属性:curr指针,指向当前运行于该CPU上的进程的task_struct,通常用cpu_curr(cpu)宏来访问;last_schedule时间戳,记录了上一次该CPU上进程切换的时间,通常用last_schedule(cpu)宏来访问。

为了使该数据结构的访问能与CPU的Cache line大小相一致,schedule_data被组织到以SMP_CACHE_BYTES为单位的aligned_data联合数组中,系统中每个CPU对应数组上的一个元素。

5. init_tasks

调度器并不直接使用init_task为表头的进程链表,而仅使用其中的"idle_task"。该进程在引导完系统后即处于cpu_idle()循环中(详见"其他核心应用的调度相关部分"之"IDLE进程")。SMP系统中,每个CPU都分别对应了一个idle_task,它们的task_struct指针被组织到init_tasks[NR_CPUS]数组中,调度器通过idle_task(cpu)宏来访问这些"idle"进程(详见"调度器工作流程")。


 

6. runqueue_head

以runqueue_head为表头的链表记录了所有处于就绪态的进程(当前正在运行的进程也在其中,但idle_task除外),调度器总是从中选取最适合调度的进程投入运行。

 

三. 进程切换过程

从一个进程的上下文切换到另一个进程的上下文,因为其发生频率很高,所以通常都是调度器效率高低的关键。在Linux中,这一功能是以一段经典的汇编代码实现的,此处就着力描述这段代码。

这段名为switch_to()的代码段在schedule()过程中调用,以一个宏实现:

/* 节选自[include/asm-i386/system.h] */
#define switch_to(prev,next,last) do {          \
  asm volatile("pushl %%esi\n\t"  \
         "pushl %%edi\n\t"    \
         "pushl %%ebp\n\t"  \保存esi、edi、ebp寄存器
         "movl %%esp,%0\n\t"  \esp保存到prev->thread.esp中
         "movl %3,%%esp\n\t"  \从next->thread.esp恢复esp
         "movl $1f,%1\n\t"  
   \在prev->thread.eip中保存"1:"的跳转地址,
   \当prev被再次切换到的时候将从那里开始执行
         "pushl %4\n\t"    
      \在栈上保存next->thread.eip,__switch_to()返回时将转到那里执行,
    \即进入next进程的上下文
         "jmp __switch_to\n"  \跳转到__switch_to(),进一步处理(见下)
         "1:\t"        \
         "popl %%ebp\n\t"    \
         "popl %%edi\n\t"    \
         "popl %%esi\n\t"    \先恢复上次被切换走时保存的寄存器值,
       \再从switch_to()中返回。
         :"=m" (prev->thread.esp),  \%0
                      "=m" (prev->thread.eip),\%1
          "=b" (last) \ebx,
\因为进程切换后,恢复的栈上的prev信息不是刚被切换走的进程描述符,
\因此此处使用ebx寄存器传递该值给prev
         :"m" (next->thread.esp),  \%3
                       "m" (next->thread.eip),  \%4
            "a" (prev), "d" (next),    \eax,edx
            "b" (prev));        \ebx
} while (0)

 

进程切换过程可以分成两个阶段,上面这段汇编代码可以看作第一阶段,它保存一些关键的寄存器,并在栈上设置好跳转到新进程的地址。第二阶段在switch_to()中启动,实现在__switch_to()函数中,主要用于保存和更新不是非常关键的一些寄存器(以及IO操作许可权映射表ioperm)的值:

  • unlazy_fpu(),如果老进程在task_struct的flags中设置了PF_USEDFPU位,表明它使用了FPU,unlazy_fpu()就会将FPU内容保存在task_struct::thread中;
  • 用新进程的esp0(task_struct::thread中)更新init_tss中相应位置的esp0;
  • 在老进程的task_struct::thread中保存当前的fs和gs寄存器,然后从新进程的task_struct::thread中恢复fs和gs寄存器;
  • 从新进程的task_struct::thread中恢复六个调试寄存器的值;
  • 用next中的ioperm更新init_tss中的相应内容

switch_to()函数正常返回,栈上的返回地址是新进程的task_struct::thread::eip,即新进程上一次被挂起时设置的继续运行的位置(上一次执行switch_to()时的标号"1:"位置)。至此转入新进程的上下文中运行。

在以前的Linux内核中,进程的切换使用的是far jmp指令,2.4采用如上所示的手控跳转,所做的动作以及所用的时间均与far jmp差不多,但更利于优化和控制。

 

四. 就绪进程选择算法

Linux schedule()函数将遍历就绪队列中的所有进程,调用goodness()函数计算每一个进程的权值weight,从中选择权值最大的进程投入运行。

进程调度权值的计算分为实时进程和非实时进程两类,对于非实时进程(SCHED_OTHER),影响权值的因素主要有以下几个:

1. 进程当前时间片内所剩的tick数,即task_struct的counter值,相当于counter越大的进程获得CPU的机会也越大,因为counter的初值与(-nice)相关,因此这一因素一方面代表了进程的优先级,另一方面也代表了进程的"欠运行程度";(weight = p->counter;)

2. 进程上次运行的CPU是否就是当前CPU,如果是,则权值增加一个常量,表示优先考虑不迁移CPU的调度,因为此时Cache信息还有效;(weight += PROC_CHANGE_PENALTY;)

3. 此次切换是否需要切换内存,如果不需要(或者是同一进程的两个线程间的切换,或者是没有mm属性的核心线程),则权值加1,表示(稍微)优先考虑不切换内存的进程;(weight += 1;)

4. 进程的用户可见的优先级nice,nice越小则权值越大。(Linux中的nice值在-20到+19之间选择,缺省值为0,nice()系统调用可以用来修改优先级。)(weight += 20 - p->nice;) 对于实时进程(SCHED_FIFO、SCHED_RR),权值大小仅由该进程的rt_priority值决定(weight = 1000 + p->rt_priority;),1000的基准量使得实时进程的权值比所有非实时进程都要大,因此只要就绪队列中存在实时进程,调度器都将优先满足它的运行需要。

如果权值相同,则选择就绪队列中位于前列的进程投入运行。

除了以上标准值以外,goodness()还可能返回-1,表示该进程设置了SCHED_YIELD位,此时,仅当不存在其他就绪进程时才会选择它。

如果遍历所有就绪进程后,weight值为0,表示当前时间片已经结束了,此时将重新计算所有进程(不仅仅是就绪进程)的counter值,再重新进行就绪进程选择(详见"调度器工作流程")。

 

五. 调度器

Linux的调度器主要实现在schedule()函数中。

1.调度器工作流程

schedule()函数的基本流程可以概括为四步:

1). 清理当前运行中的进程

2). 选择下一个投入运行的进程

3). 设置新进程的运行环境

4). 执行进程上下文切换

5). 后期整理

其中包含了一些锁操作:就绪队列锁runquque_lock,全局核心锁kernel_flag,全局中断锁global_irq_lock,进程列表锁tasklist_lock。下面先从锁操作开始描述调度器的工作过程。

A. 相关锁

  • runqueue_lock,定义为自旋锁,对就绪队列进行操作之前,必须锁定;
  • kernel_flag,定义为自旋锁,因为很多核心操作(例如驱动中)需要保证当前仅由一个进程执行,所以需要调用lock_kernel()/release_kernel()对核心锁进行操作,它在锁定/解锁kernel_flag的同时还在task_struct::lock_depth上设置了标志,lock_depth小于0表示未加锁。当发生进程切换的时候,不允许被切换走的进程握有kernel_flag锁,所以必须调用release_kernel_lock()强制释放,同时,新进程投入运行时如果lock_depth>0,即表明该进程被切换走之前握有核心锁,必须调用reacquire_kernel_lock()再次锁定;
  • global_irq_lock,定义为全局的内存长整型,使用clear_bit()/set_bit()系列进行操作,它与global_irq_holder配合表示当前哪个cpu握有全局中断锁,该锁挂起全局范围内的中断处理(见irq_enter());
  • tasklist_lock,定义为读写锁,保护以init_task为头的进程列表结构。

B. prev

在schedule中,当前进程(也就是可能被调度走的进程)用prev指针访问。

对于SCHED_RR的实时进程,仅当该进程时间片结束(counter==0)后才会切换到别的进程,此时将根据nice值重置counter,并将该进程置于就绪队列的末尾。当然,如果当前就绪队列中不存在其他实时进程,则根据前面提到的goodness()算法,调度器仍将选择到该进程。

如果处于TASK_INTERRUPTIBLE状态的进程有信号需要处理(这可能发生在进程因等待信号而准备主动放弃CPU,在放弃CPU之前,信号已经发生了的情况),调度器并不立即执行该进程,而是将该进程置为就绪态(该进程还未来得及从就绪队列中删除),参与紧接着的goodness选择。

如果prev不处于就绪态,也不处于上面这种有信号等待处理的挂起态(prev为等待资源而主动调用schedule()放弃CPU),那么它将从就绪队列中删除,此后,除非有唤醒操作将进程重新放回到就绪队列,否则它将不参与调度。

被动方式启动调度器工作时,当前进程的need_resched属性会置位(见下"调度器工作时机")。在schedule()中,该位会被清掉,表示该进程已经在调度器中得到了处理(当然,这一处理并不意味着该进程就一定获得了CPU)。

C. goodness

调度器遍历就绪队列中的所有进程,只要它当前可被调度(cpus_runnable & cpus_allowed & (1 << cpu),表示该进程可在当前运行调度器的CPU上运行,且不在其他CPU上运行),就调用goodness()计算其权值。next指针用来指向权值最大的进程,缺省指向idle_task,如果就绪队列为空,就使用缺省的idle_task作为next。

正如前面所提到的,如果遍历结束后的最大权值为0,则表示当前所有可被调度的就绪进程的时间片都用完了,这时调度器将需要重新设置所有进程(包括就绪的和挂起的)的counter值,未完成时间片的进程(例如当前被挂起的进程或者当前正在其他CPU上运行的进程),其剩下的时间片的一半将叠加到新的时间片中。

将选中的进程设置为在本CPU上运行(task_set_cpu())之后,runqueue_lock就可以解开了,接下来就将对next进行配置。

D. next

选取的新进程可能刚好就是需要替换出去的老进程,此时因为实际上不需要进行进程切换,所以可以跳过配置next以及下面的"switch"和"schedule_tail"两个阶段。

新进程的运行环境实际上主要就是指内存。在task_struct中有两个与调度器相关的内存属性:mm和active_mm,前者指向进程所拥有的内存区域,后者则指向进程所实际使用的内存。对于大多数进程,mm和active_mm是相同的,但核心线程没有自主的内存,它们的mm指针永远为NULL。我们知道,任何进程的虚页表中,核心空间永远映射到了虚存的高端固定位置,所以,核心线程所使用的内存无论对于哪个进程空间都是一样的,所以也就没有必要切换进程的内存。在调度器中,只要判断一下next->mm是否为空就能知道该进程是不是核心线程,如果是,则继续使用prev的active_mm(next->active_mm = prev->active_mm),并通过设置cpu_tlbstate[cpu].state为TLBSTATE_LAZY,告诉内存管理部件不要刷新TLB;否则就调用switch_mm()函数进行内存的切换(具体过程牵涉到内存管理模块的知识,这里就从略了)。实际上,在switch_mm()中还会对prev->active_mm和next->mm判断一次,如果两值相等,说明两个进程是同属于一个"进程"的两个"线程"(实际上是轻量进程),此时也不需要执行内存的切换,但这种情况TLB还是需要刷新的。

设置好next的内存环境以后,就可以调用mmdrop()释放掉prev的内存结构了。所有不在运行中的进程,其active_mm属性都应该为空。

E. switch

进程切换的过程在上文中已经描述得比较详细了。

F. schedule_tail

完成切换后,调度器将调用__schedule_tail()。这一函数对于UP系统基本没什么影响,对于SMP系统,如果被切换下来的进程(用p表示)仍然处于就绪态且未被任何CPU调度到,__schedule_tail()将调用reschedule_idle(),为p挑选一个空闲的(或者是所运行的进程优先级比p低的)CPU,并强迫该CPU重新调度,以便将p重新投入运行。进程从休眠状态中醒来时也同样需要挑选一个合适的CPU运行,这一操作是通过在wake_up_process()函数中调用reschedule_idle()实现的。挑选CPU的原则如下:

  • p上次运行的CPU目前空闲。很显然,这是最佳选择,因为不需要抢占CPU,CPU Cache也最有可能和p吻合。不过,既然p可运行,调度器就不可能调度到idle_task,所以这种情况只会发生在wake_up_process()的时候。
  • 所有空闲的CPU中最近最少活跃(last_schedule(cpu)最小)的一个。该CPU中的Cache信息最有可能是无用的,因此这种选择方式可以尽最大可能减少抢占CPU的开销,同时也尽可能避免频繁抢占。值得注意的是,在使用支持超线程技术的CPU的SMP平台上,一旦发现一个物理CPU的两个逻辑CPU均空闲,则该CPU的其中一个逻辑CPU立即成为p候选的调度CPU,而不需要继续寻找最近最少活跃的CPU。
  • CPU不空闲,但所运行的进程优先级比p的优先级低,且差值最大。计算优先级时使用的是goodness()函数,因为它所包含的信息最多。

找到合适的CPU后,reschedule_idle()就会将目标进程(正在该CPU上运行的进程,可能是idle_task)的need_resched置为1,以便调度器能够工作(见"调度器工作时机")。同时,因为idle_task很多情况下都使cpu处于停机(halt)状态以节电,所以有必要调用smp_send_reschedule(cpu)向cpu发RESCHEDULE_VECTOR中断(通过IPI接口),以唤醒该cpu。

注:对于目标进程是idle_task的情况,还要判断它的need_resched标志位,仅当它为0的时候才会启动调度,因为非0状态的idle_task本身一直都在检查need_resched值,它自己会启动schedule()(见下"IDLE进程")。

G. clear

调度器工作的结果有两种:发生了切换、没有发生切换,但调度器退出前的清理工作是一样的,就是恢复新进程的状态。主要包含两个动作:

  • 清被切换走的进程的SCHED_YIELD位(不管它是否置位);
  • 如果新进程(p)的lock_depth大于等于0,则重新为核心锁kernel_flag加锁(见上"相关锁")。

2. 调度器工作时机

调度器的启动通常有两种方式:

A. 主动式

在核心应用中直接调用schedule()。这通常发生在因等待核心事件而需要将进程置于挂起(休眠)状态的时候--这时应该主动请求调度以方便其他进程使用CPU。下面就是一个主动调度的例子:

/* 节选自[drivers/input/mousedev.c] mousedev_read() */
    add_wait_queue(&list->mousedev->wait, &wait);
    current->state = TASK_INTERRUPTIBLE;
    while (!list->ready) {
      if (file->f_flags & O_NONBLOCK) {
        retval = -EAGAIN;
        break;
      }
      if (signal_pending(current)) {
        retval = -ERESTARTSYS;
        break;
      }
      schedule();
    }
    current->state = TASK_RUNNING;  
/* 这一句实际上可以省略,因为进程的状态在唤醒过程中就已经恢复到TASK_RUNNING了 */
    remove_wait_queue(&list->mousedev->wait, &wait);
		

 

其过程通常可分为四步:

  • 将进程添加到事件等待队列中;
  • 置进程状态为TASK_INTERRUPTIBLE(或TASK_UNINTERRUPTIBLE);
  • 在循环中检查等待条件是否满足,不满足则调用schedule(),满足了就退出循环;
  • 将进程从事件等待队列中删除。

从"调度器工作流程"中我们知道,调度器会将处于休眠状态的进程从就绪队列中删除,而只有就绪队列中的进程才有可能被调度到。将该进程重新放到就绪队列中的动作是在事件发生时的"唤醒"过程中完成的。在以上所示的鼠标驱动中,鼠标中断将调用mousedev_event()函数,该函数的最后就会使用wake_up_interruptible()唤醒等待鼠标事件的所有进程。wake_up_interruptible()将最终调用try_to_wake_up()函数:

/* 节选自[kernel/sched.c] */
static inline int try_to_wake_up(struct task_struct * p, int synchronous)
{
    unsigned long flags;
    int success = 0;
    spin_lock_irqsave(&runqueue_lock, flags);
    p->state = TASK_RUNNING;
    if (task_on_runqueue(p))
      goto out;
    add_to_runqueue(p);  /* 添加到就绪队列中 */
    if (!synchronous || !(p->cpus_allowed & (1 << smp_processor_id())))
      reschedule_idle(p); /* 这种情况下调用wake_up(),synchronous总为0,此时,*/
       /* 如果本CPU不适合运行该进程,则需要调用reschedule_idle()寻找合适的CPU */
    success = 1;
              out:
    spin_unlock_irqrestore(&runqueue_lock, flags);
       return success;
}

 

这时启动schedule()就是被动的了。

B. 被动式

在系统调用执行结束后,控制由核心态返回到用户态之前,Linux都将在ret_from_sys_call入口检查当前进程的need_resched值,如果该值为1,则调用schedule():

				/* 节选自[arch/i386/kernel/entry.S] */
              ENTRY(ret_from_sys_call)
		cli
		cmpl $0,need_resched(%ebx)			#ebx中存放着current指针
		jne reschedule
               ……
               reschedule:
		call SYMBOL_NAME(schedule)    
		jmp ret_from_sys_call				#反复查询need_resched
		

 

因此,只需要设置当前进程(current)的need_resched,就有机会启动调度器。通常有如下几种场合会设置need_resched:

  • update_process_times(),由时钟中断触发,负责管理除0号进程(idle进程)以外的其他各个进程的时间片消耗。如果当前进程(SCHED_FIFO实时进程除外)的时间片用完了(counter==0),则设置need_resched为1;(注意:此时并不计算或重置counter值,这个工作在所有进程的时间片都耗完以后在schedule()中进行)
  • reschedule_idle(),此函数的功能在"调度器工作流程"一节中已经详细描述了,不过,最经常的调用者是在某一事件等待队列上休眠的进程的唤醒过程--wake_up_process()及其他一系列wake_up函数(见上"主动式调度");
  • sched_setscheduler()、sched_yield()系统调用,以及系统初始化(rest_init()中)、创建新进程(do_fork()中)等从语义上就希望启动调度器工作的场合。

由于启动schedule()的时机实际上由当前进程决定,因此设置了need_resched并不意味着就能及时调度,这也是"Linux内核不可抢占"的原因(详见下"Linux 2.4调度系统的一些问题"之"内核不可抢占")。

 

六. 其他核心应用的调度相关部分

系统中很多技术都和调度器相关,这里仅就其中几个稍作展开,并且不涉及该技术的细节,仅就其中与调度器相关的部分进行讨论,假定读者对于该技术有初步的了解。

1. IDLE进程

系统最初的引导进程(init_task)在引导结束后即成为cpu 0上的idle进程。在每个cpu上都有一个idle进程,正如上文所言,这些进程登记在init_tasks[]数组中,并可用idle_task()宏访问(见上"相关数据结构")。idle进程不进入就绪队列,系统稳定后,仅当就绪队列为空的时候idle进程才会被调度到。

init_task的task_struct是静态配置的,定义在[include/linux/sched.h]中的INIT_TASK()宏中,其中与调度相关的几个属性分别是:

  • state:TASK_RUNNING;
  • counter:10*HZ/100;i386上大约100ms
  • nice:0;缺省的优先级
  • policy:SCHED_OTHER;非实时进程
  • cpus_runnable:-1;全1,未在任何cpu上运行
  • cpus_allowed:-1;全1,可在任何cpu上运行

在smp_init()中(实际上是在[arch/i386/kernel/smpboot.c]中的smp_boot_cpus()中),init_task的processor属性被设为0,对应的schedule_data也设置好相应的值。在创建了一个核心线程用于执行init()函数之后([/init/main.c]rest_init()),init_task设置自己的need_resched等于1,然后调用cpu_idle()进入IDLE循环。

在cpu_idle()中,init_task的nice值被设为20(最低优先级),counter为-100(无意义的足够小),然后cpu_idle()进入无限循环:

/* 节选自[arch/i386/kernel/processs.c] cpu_idle() */
while (1) {
		void (*idle)(void) = pm_idle;
		if (!idle)
			idle = default_idle;
		while (!current->need_resched)
			idle();
		schedule();
		check_pgt_cache();
}

 

初始化过程中第一次执行cpu_idle(),因need_resched为1,所以直接启动schedule()进行第一次调度。如上文所述,schedule()会清掉need_resched位,因此,之后本循环都将执行idle()函数,直至need_resched再被设置为非0(比如在reschedule_idle()中,见上"调度器工作时机")。

idle()函数有三种实现可能:

  • default_idle(),执行hlt指令;
  • poll_idle(),如果核心参数上定义了"idle=poll",则pm_idle会指向poll_idle(),它将need_resched设置为特殊的-1,然后反复循环直到need_resched不等于-1。因为poll_idle()采用更高效的指令,所以运行效率比default_idle()要高;
  • 电源管理相关的idle过程,例如APM和ACPI模块中定义的idle过程。

因为仅当就绪队列为空的时候才会调度到idle进程,所以,只有在系统完全空闲时才会执行check_pgt_cache()操作,清理页表缓存。

2. 进程创建

系统中除了init_task是手工创建的以外,其他进程,包括其他CPU上的idle进程都是通过do_fork()创建的,所不同的是,创建idle进程时使用了CLONE_PID标志位。

在do_fork()中,新进程的属性设置为:

  • state:TASK_UNINTERRUPTIBLE
  • pid:如果设置了CLONE_PID则与父进程相同(仅可能为0),否则为下一个合理的pid
  • cpus_runnable:全1;未在任何cpu上运行
  • processor:与父进程的processor相同;子进程在哪里创建就优先在哪里运行
  • counter:父进程counter值加1的一半;同时父进程自己的counter也减半,保证进程不能通过多次fork来偷取更多的运行时间(同样,在子进程结束运行时,它的剩余时间片也将归还给父进程,以免父进程因创建子进程而遭受时间片的损失)
  • 其他值与父进程相同

子进程通过SET_LINKS()链入进程列表,然后调用wake_up_process()唤醒(见上"调度器工作时机")。

3. smp系统初始化

init_task在完成关键数据结构初始化之后,在进行硬件的初始化之前,会调用smp_init()对SMP系统进行初始化。smp_init()调用smp_boot_cpus(),smp_boot_cpus()对每一个CPU都调用一次do_boot_cpu(),完成SMP其他CPU的初始化工作。

/* 节选自[arch/i386/kernel/smpboot.c] do_boot_cpu() */
  if (fork_by_hand() < 0)  
/* do_fork(CLONE_VM|CLONE_PID)创建一个新进程,与init_task一样具有0号pid */
    panic("failed fork for CPU %d", cpu);
                idle = init_task.prev_task; 
          /*在进程列表中,新进程总是位于init_task的左链prev上 */
  if (!idle)
    panic("No idle process for CPU %d", cpu);
        idle->processor = cpu;
  idle->cpus_runnable = 1 << cpu;  /* 在指定CPU上运行 */
  map_cpu_to_boot_apicid(cpu, apicid);
  idle->thread.eip = (unsigned long) start_secondary;  
                    /* 被调度到后的启动地址 */
  del_from_runqueue(idle);  /* idle进程不通过就绪队列调度 */
  unhash_process(idle);
  init_tasks[cpu] = idle; 
               /* 所有idle进程都可通过init_tasks[]数组访问 */
	

 

该进程被调度到时即执行start_secondary(),最终将调用cpu_idle(),成为IDLE进程。

 

七. Linux 2.4调度系统的一些问题

1. 进程时间片

2.4内核中进程缺省时间片是根据以下公式计算的:

/* 节选自[kernel/sched.c] */
#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)
……
schedule()
{
……
p->counter = (p->counter >> 1) + NICE_TO_TICKS(p->nice);
……
}

 

如上所述,时钟中断将不断对当前运行的非IDLE进程进行时间片剩余值减1的操作,直至所有就绪队列中的counter都减为0了,就在schedule()中对每个进程(包括休眠进程)利用上述公式执行时间片的更新。其中在[include/asm-i386/param.h]中定义了HZ为100,而counter通常初值为0,nice缺省为0(nice在-20到19之间选择),所以,i386下counter的缺省值为6,也就是大约60ms(时钟中断大约每10ms一次)。

同时,对于休眠的进程而言,其参与计算的counter非0,因此实际上它的counter是在累加,构成一个等比数列COUNTER=COUNTER/2+k,1<k<=11,其最大值趋近于2*k,也就是说,2.4系统中进程的时间片不会超过230ms。

因为就绪进程选取算法中counter的值占很大比重(见"就绪进程选择算法"),因此,这种对于休眠进程时间片叠加的做法体现了Linux倾向于优先执行休眠次数比较多,也就是IO密集(IO-bound)的进程。

Linux设计者最初是希望因此而提高交互式进程的响应速度,从而方便终端用户,但IO密集的进程并不一定就是交互式进程,例如数据库操作需要频繁地读写磁盘,从而经常处于休眠状态,动态优先级通常较高,但这种应用并不需要用户交互,所以它反而影响了真正的交互动作的响应。

时间片的长度对系统性能影响也很大。如果太短,进程切换就会过于频繁,开销很大;如果太长,系统响应就会太慢,Linux的策略是在系统响应不至于太慢的前提下让时间片尽可能地长。

2. 内核不可抢占

从上面的分析我们可以看到,schedule()是进行进程切换的唯一入口,而它的运行时机很特殊。一旦控制进入核心态,就没有任何办法可以打断它,除非自己放弃cpu。一个最典型的例子就是核心线程中如果出现死循环(只要循环中不调用schedule()),系统就会失去响应,此时各种中断(包括时钟中断)仍然在响应,但却不会发生调度,其他进程(包括核心进程)都没有机会运行。

下面给出的是中断返回的代码:

		
/* 节选自[arch/i386/entry.S] */
ENTRY(ret_from_intr)
    GET_CURRENT(%ebx)      
#将current指针存到ebx寄存器中备用
ret_from_exception:
    movl EFLAGS(%esp),%eax   
#取EFLAGS中的VM_MASK位判断是否处于VM86模式
    movb CS(%esp),%al     
#取CS低两位判断是否处于用户态
    testl $(VM_MASK | 3),%eax    
    jne ret_from_sys_call      
#如果处于VM86模式或者处于用户态,就从ret_from_sys_call入口返回,
#否则直接返回
    jmp restore_all
		

 

这是此时唯一可能调用schedule()的地方(通过ret_from_sys_call,见"调度器工作时机"),但普通的核心线程不属于任何一种要求的状态,它能响应中断,但不能导致调度。

这个特点的表现之一就是,高优先级的进程无法打断正在核内执行系统调用(或者中断服务)的低优先级进程,这对于实时系统来说是致命的,但却简化了核心代码。内核中很多地方都利用了这一特点,能够不做过多保护地访问共享数据,而不用担心其他进程的打扰。

3. 实时性能

Linux 2.4通过就绪进程选择算法的设计区分实时进程和非实时进程,只要有实时进程可运行,非实时进程就不会获得运行机会。Linux又将实时进程分为SCHED_RR和SCHED_FIFO两类。SCHED_RR时间片结束后会发生调度,并将自己置于就绪队列的末尾,从而给其他rt_priority相同(或更高)的实时进程运行机会(见"调度器工作流程"),而SCHED_FIFO不会因时间片结束而放弃CPU(见"调度器工作时机"),或者出现更高优先级的实时进程,或者主动放弃CPU,否则SCHED_FIFO将运行到进程结束。

尽管Linux 2.4中区分了实时进程和非实时进程的调度优先权,但也仅此而已。不支持核心抢占运行的操作系统很难实现真正的实时性,因为实时任务的响应时间无法预测。有两种办法使系统的实时性更好,一种是采用设置类似抢占调度点的做法,一种就是使内核真正具备可抢占性。

即使是内核可抢占的系统,也并不一定满足实时性要求,它仅仅解决了CPU资源的访问优先权问题,其他资源也同样需要"被抢占",例如实时进程应该能够从握有某个共享资源的普通进程手中夺得它所需要的资源,它使用完后再还给普通进程。但实际上,很多系统都无法做到这一点,Linux的调度器更是不具备这种能力。

4. 多处理机系统中的局限性

Linux的调度器原本是针对单处理机系统设计的,在内核发展过程中,不断通过补丁来提高多处理机系统(主要是SMP系统)的执行效率。这种开发方式一直持续到2.4版本,因此在2.4内核中,SMP应用仍然有很多无法突破的障碍,例如全局共享的就绪队列。很多研究团体都在针对Linux调度器的多处理机扩展性作研究,参考文献中列举了其中两个[5][6],但最权威的改进还是在2.6内核中。

对于超线程CPU,Linux调度器的支持有限,它可以区分同一物理CPU上的两个逻辑CPU,在两个逻辑CPU都空闲的时候,调度器可以优先考虑将进程调度到其中一个逻辑CPU上运行(见"调度器工作流程")。从原理上说,超线程CPU是存在两个(或多个)执行现场的单CPU,只有两个使用CPU不同部件(比如定点部件和浮点部件)的线程在其上运行的时候才有正的加速,否则,由于执行部件冲突以及Cache miss,使用超线程技术甚至会带来一定程度上的性能损失。Linux 2.4的调度器并不能区分哪些进程是"类似"的,哪些进程会使用不同的执行部件,因此,实际上无法恰当使用超线程CPU。 对于其他更复杂的多处理机系统,例如目前高端系统中占统治地位的NUMA结构机器,Linux在调度器上基本未作考虑。例如进程(线程)总优先在创建它的CPU上运行(见"其他核心应用的调度相关部分"之"进程创建"),并倾向于保持在该CPU上(见"就绪进程选择算法"),整个CPU选择过程没有做任何局部性优化。

 

八. 后记

调度系统的表现关系到整个系统的性能,Linux的应用目前主要集中在低端服务器系统和桌面系统,将来很可能向高端服务器市场和嵌入式系统发展,这就要求调度系统有大的改动。在新的Linux内核2.6版本中,调度器的改动是最引人注目的,它一方面提供了核心可抢占的支持,另一方面又对多处理机系统上的表现进行了优化。在熟悉了2.4的调度系统之后,我们将分析2.6中调度器的表现。

 

参考资料

[1][Linus Torvalds,2003] 
Linux内核源码v2.4.21

[2][Daniel P. Bovet, Marco Cesati,2002] 
Understanding the Linux Kernel, 2nd Edition,O'Reilly

[3][Moshe Bar,2000] 
Kernel Korner: The Linux Scheduler,Linux Journal

[4][Paul Bemowsky,2003] 
Hyper-Threading Linux,Linux World

[5][Mike Kravetz,2001] 
Enhancing Linux Scheduler Scalability,IBM Linux Tech. Center

[6][Chris King, Scott Lathrop, Steve Molloy, Paul Moore,2001] 
ELSC : Scalable Linux Scheduling on a Symmetric Multi-Processor Machine,University of Michigan

posted on 2014-03-10 19:28  一天不进步,就是退步  阅读(2229)  评论(0编辑  收藏  举报