Linux 2.6 调度系统分析
内容:
1. 前言
2. 新的数据结构 runqueue
3. 改进后的 task_struct
4. 新的运行时间片表现
5. 优化了的优先级计算方法
6. 进程平均等待时间 sleep_avg
7. 更精确的交互式进程优先
8. 调度器
9. 调度器对内核抢占运行的支持
10. 调度器相关的负载平衡
11. NUMA 结构下的调度
12. 调度器的实时性能
13. 后记:从调度器看 Linux 发展
参考资料
关于作者
相关内容:
Linux 2.4 调度系统分析
developerWorks Toolbox subscription
在 Linux 专区还有:
教程
工具与产品
代码与组件
文章
在 2.4 之上进步

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

本文从 Linux 2.4 调度系统的缺陷入手,详细分析了 Linux 2.6 调度系统的原理和实现细节,并对与调度系统相关的负载平衡、NUMA 结构以及实时性能进行了分析和评价。文末,作者从调度系统的发展和实现出发,对 Linux 的发展特点和方向提出了自己的看法。

1. 前言

Linux 的市场非常广阔,从桌面工作站到低端服务器,它都是任何商用操作系统的有力竞争对手。目前,Linux 正全力进军嵌入式系统和高端服务器系统领域,但它的技术缺陷限制了它的竞争力:缺乏对实时任务的支持,多处理机可扩展性差。在 2.4 内核中,造成这两个弱项的关键原因之一就是调度器设计上的缺陷。

2.6 调度系统从设计之初就把开发重点放在更好满足实时性和多处理机并行性上,并且基本实现了它的设计目标。主要设计者,传奇式人物 Ingo Molnar 将新调度系统的特性概括为如下几点:

  • 继承和发扬 2.4 版调度器的特点:
    • 交互式作业优先
    • 轻载条件下调度/唤醒的高性能
    • 公平共享
    • 基于优先级调度
    • 高 CPU 使用率
    • SMP 高效亲和
    • 实时调度和 cpu 绑定等调度手段
  • 在此基础之上的新特性:
    • O(1)调度算法,调度器开销恒定(与当前系统负载无关),实时性能更好
    • 高可扩展性,锁粒度大幅度减小
    • 新设计的 SMP 亲和方法
    • 优化计算密集型的批处理作业的调度
    • 重载条件下调度器工作更平滑
    • 子进程先于父进程运行等其他改进

在 2.5.x 的试验版本中,新的调度器的开发一直受到广泛关注,实测证明它的确使系统性能得到很大改善。本文就从新设计的数据结构开始,围绕 2.6 对于 2.4 所作的改进,对 2.6 调度系统的原理和实现细节进行分析。2.6 调度器设计相当复杂,文中还存在很多需要继续研究的地方,特别是各个调度参数的设定,随着核心版本的升级,可能还会继续修正。

2. 新的数据结构 runqueue

我们知道,在 2.4 内核中,就绪进程队列是一个全局数据结构,调度器对它的所有操作都会因全局自旋锁而导致系统各个处理机之间的等待,使得就绪队列成为一个明显的瓶颈。

2.4 的就绪队列是一个简单的以 runqueue_head 为头的双向链表,在 2.6 中,就绪队列定义为一个复杂得多的数据结构 struct runqueue,并且,尤为关键的是,每一个 CPU 都将维护一个自己的就绪队列,--这将大大减小竞争。

O(1)算法中很多关键技术都与 runqueue 有关,所以,我们对调度器的分析就先从 runqueue 结构开始。

1) prio_array_t *active, *expired, arrays[2]

runqueue 中最关键的数据结构。每个 CPU 的就绪队列按时间片是否用完分为两部分,分别通过 active 指针和 expired 指针访问,active 指向时间片没用完、当前可被调度的就绪进程,expired 指向时间片已用完的就绪进程。每一类就绪进程都用一个 struct prio_array 的结构表示:

struct prio_array {
		int nr_active;		/* 本进程组中的进程数 */
		struct list_head queue[MAX_PRIO];
							/* 以优先级为索引的 HASH 表,见下 */
		unsigned long bitmap[BITMAP_SIZE];
							/* 加速以上 HASH 表访问的位图,见下 */
};

 
图1:active、expired 数组示例

图中的 task 并不是 task_struct 结构指针,而是 task_struct::run_list,这是一个小技巧,详见下文 run_list 的解释。

在 2.4 版的内核里,查找最佳候选就绪进程的过程是在调度器 schedule() 中进行的,每一次调度都要进行一次(在 for 循环中调用 goodness()),这种查找过程与当前就绪进程的个数相关,因此,查找所耗费的时间是 O(n) 级的,n 是当前就绪进程个数。正因为如此,调度动作的执行时间就和当前系统负载相关,无法给定一个上限,这与实时性的要求相违背。

在新的 O(1) 调度中,这一查找过程分解为 n 步,每一步所耗费的时间都是 O(1) 量级的。

prio_array 中包含一个就绪队列数组,数组的索引是进程的优先级(共 140 级,详见下 "static_prio" 属性的说明),相同优先级的进程放置在相应数组元素的链表 queue 中。调度时直接给出就绪队列 active 中具有最高优先级的链表中的第一项作为候选进程(参见"调度器"),而优先级的计算过程则分布到各个进程的执行过程中进行(见"优化了的优先级计算方法")。

为了加速寻找存在就绪进程的链表,2.6 核心又建立了一个位映射数组来对应每一个优先级链表,如果该优先级链表非空,则对应位为 1,否则为 0。核心还要求每个体系结构都构造一个 sched_find_first_bit() 函数来执行这一搜索操作,快速定位第一个非空的就绪进程链表。

采用这种将集中计算过程分散进行的算法,保证了调度器运行的时间上限,同时在内存中保留更加丰富的信息的做法也加速了候选进程的定位过程。这一变化简单而又高效,是 2.6 内核中的亮点之一。

arrays 二元数组是两类就绪队列的容器,active 和 expired 分别指向其中一个。active 中的进程一旦用完了自己的时间片,就被转移到 expired 中,并设置好新的初始时间片;而当 active 为空时,则表示当前所有进程的时间片都消耗完了,此时,active 和 expired 进行一次对调,重新开始下一轮的时间片递减过程(参见"调度器")。

回忆一下 2.4 调度系统,进程时间片的计算是比较耗时的,在早期内核版本中,一旦时间片耗尽,就在时钟中断中重新计算时间片,后来为了提高效率,减小时钟中断的处理时间,2.4 调度系统在所有就绪进程的时间片都耗完以后在调度器中一次性重算。这又是一个 O(n) 量级的过程。为了保证 O(1) 的调度器执行时间,2.6 的时间片计算在各个进程耗尽时间片时单独进行,而通过以上所述简单的对调来完成时间片的轮转(参见"调度器")。这又是 2.6 调度系统的一个亮点。

2) spinlock_t lock

runqueue 的自旋锁,当需要对 runqueue 进行操作时,仍然应该锁定,但这个锁定操作只影响一个 CPU 上的就绪队列,因此,竞争发生的概率要小多了。

3) task_t *curr

本 CPU 正在运行的进程。

4) tast_t *idle

指向本 CPU 的 idle 进程,相当于 2.4 中 init_tasks[this_cpu()] 的作用。

5) int best_expired_prio

记录 expired 就绪进程组中的最高优先级(数值最小)。该变量在进程进入 expired 队列的时候保存(schedule_tick()),用途见 "expired_timestamp"的解释)。

6) unsigned long expired_timestamp

当新一轮的时间片递减开始后,这一变量记录着最早发生的进程耗完时间片事件的时间(jiffies 的绝对值,在 schedule_tick() 中赋),它用来表征 expired 中就绪进程的最长等待时间。它的使用体现在 EXPIRED_STARVING(rq) 宏上。

上面已经提到,每个 CPU 上维护了两个就绪队列,active 和 expired。一般情况下,时间片结束的进程应该从 active 队列转移到 expired 队列中(schedule_tick()),但如果该进程是交互式进程,调度器就会让其保持在 active 队列上以提高它的响应速度。这种措施不应该让其他就绪进程等待过长时间,也就是说,如果 expired 队列中的进程已经等待了足够长时间了,即使是交互式进程也应该转移到 expired 队列上来,排空 active。这个阀值就体现在EXPIRED_STARVING(rq) 上:在 expired_timestamp 和 STARVATION_LIMIT 都不等于 0 的前提下,如果以下两个条件都满足,则 EXPIRED_STARVING() 返回真:

  • (当前绝对时间 - expired_timestamp) >= (STARVATION_LIMIT * 队列中所有就绪进程总数 + 1),也就是说 expired 队列中至少有一个进程已经等待了足够长的时间;
  • 正在运行的进程的静态优先级比 expired 队列中最高优先级要低(best_expired_prio,数值要大),此时当然应该尽快排空 active 切换到expired 上来。

7) struct mm_struct *prev_mm

保存进程切换后被调度下来的进程(称之为 prev)的 active_mm 结构指针。因为在 2.6 中 prev 的 active_mm 是在进程切换完成之后释放的(mmdrop()),而此时 prev 的 active_mm 项可能为 NULL,所以有必要在 runqueue 中预先保留。

8) unsigned long nr_running

本 CPU 上的就绪进程数,该数值是 active 和 expired 两个队列中进程数的总和,是说明本 CPU 负载情况的重要参数(详见"调度器相关的负载平衡")。

9) unsigned long nr_switches

记录了本 CPU 上自调度器运行以来发生的进程切换的次数。

10) unsigned long nr_uninterruptible

记录本 CPU 尚处于 TASK_UNINTERRUPTIBLE 状态的进程数,和负载信息有关。

11) atomic_t nr_iowait

记录本 CPU 因等待 IO 而处于休眠状态的进程数。

12) unsigned long timestamp_last_tick

本就绪队列最近一次发生调度事件的时间,在负载平衡的时候会用到(见"调度器相关的负载平衡")。

13) int prev_cpu_load[NR_CPUS]

记录进行负载平衡时各个 CPU 上的负载状态(此时就绪队列中的 nr_running 值),以便分析负载情况(见"调度器相关的负载平衡")。

14) atomic_t *node_nr_running; int prev_node_load[MAX_NUMNODES]

这两个属性仅在 NUMA 体系结构下有效,记录各个 NUMA 节点上的就绪进程数和上一次负载平衡操作时的负载情况(见"NUMA 结构下的调度")。

15) task_t *migration_thread

指向本 CPU 的迁移进程。每个 CPU 都有一个核心线程用于执行进程迁移操作(见"调度器相关的负载平衡")。

16) struct list_head migration_queue

需要进行迁移的进程列表(见"调度器相关的负载平衡")。

调度系统代码结构 绝大多数调度系统的实现代码,包括 runqueue 结构的定义,都在[kernel/sched.c]文件中,这样做的目的是将所有调度系统的代码集中起来,便于更新和替换。除非特别注明,本文所引代码和函数实现均位于[kernel/sched.c]中。

3. 改进后的 task_struct

2.6 版的内核仍然用 task_struct 来表征进程,尽管对线程进行了优化,但线程的内核表示仍然与进程相同。随着调度器的改进,task_struct 的内容也有了改进,交互式进程优先支持、内核抢占支持等新特性,在 task_struct 中都有所体现。在 task_struct 中,有的属性是新增加的,有的属性的值的含义发生了变化,而有的属性仅仅是改了一下名字。

1) state

进程的状态仍然用 state 表示,不同的是,2.6 里的状态常量重新定义了,以方便位操作:

/* 节选自[include/linux/sched.h] */
#define TASK_RUNNING		0
#define TASK_INTERRUPTIBLE	1
#define TASK_UNINTERRUPTIBLE	2
#define TASK_STOPPED		4
#define TASK_ZOMBIE		8
#define TASK_DEAD		16

新增加的TASK_DEAD指的是已经退出且不需要父进程来回收的进程。

2) timestamp

进程发生调度事件的时间(单位是 nanosecond,见下)。包括以下几类:

  • 被唤醒的时间(在 activate_task() 中设置);
  • 被切换下来的时间(schedule());
  • 被切换上去的时间(schedule());
  • 负载平衡相关的赋值(见"调度器相关的负载平衡")。

从这个值与当前时间的差值中可以分别获得"在就绪队列中等待运行的时长"、"运行时长"等与优先级计算相关的信息(见"优化了的优先级计算方法")。

两种时间单位 系统的时间是以 nanosecond(十亿分之一秒)为单位的,但这一数值粒度过细,大部分核心应用仅能取得它的绝对值,感知不到它的精度。
时间相关的核心应用通常围绕时钟中断进行,在 Linux 2.6 中,系统时钟每 1 毫秒中断一次(时钟频率,用 HZ 宏表示,定义为 1000,即每秒中断 1000 次,--2.4 中定义为 100,很多应用程序也仍然沿用 100 的时钟频率),这个时间单位称为一个 jiffie。很多核心应用都是以 jiffies 作为时间单位,例如进程的运行时间片。
jiffies 与绝对时间之间的转换公式如下:
nanosecond=jiffies*1000000
核心用两个宏来完成两种时间单位的互换:JIFFIES_TO_NS()、NS_TO_JIFFIES(),很多时间宏也有两种形式,例如 NS_MAX_SLEEP_AVG 和 MAX_SLEEP_AVG。

3) prio

优先级,相当于 2.4 中 goodness() 的计算结果,在 0~MAX_PRIO-1 之间取值(MAX_PRIO 定义为 140),其中 0~MAX_RT_PRIO-1 (MAX_RT_PRIO 定义为100)属于实时进程范围,MAX_RT_PRIO~MX_PRIO-1 属于非实时进程。数值越大,表示进程优先级越小。

2.6 中,动态优先级不再统一在调度器中计算和比较,而是独立计算,并存储在进程的 task_struct 中,再通过上面描述的 priority_array 结构自动排序。

prio 的计算和很多因素相关,在"优化了的优先级计算方法"中会详细讨论。

4) static_prio

静态优先级,与 2.4 的 nice 值意义相同,但转换到与 prio 相同的取值区间。

nice 值沿用 Linux 的传统,在 -20 到 19 之间变动,数值越大,进程的优先级越小。nice 是用户可维护的,但仅影响非实时进程的优先级。2.6 内核中不再存储 nice 值,而代之以 static_prio。进程初始时间片的大小仅决定于进程的静态优先级,这一点不论是实时进程还是非实时进程都一样,不过实时进程的static_prio 不参与优先级计算。

nice 与 static_prio 之间的关系如下:
static_prio = MAX_RT_PRIO + nice + 20

 

内核定义了两个宏用来完成这一转换:PRIO_TO_NICE()、NICE_TO_PRIO()。

5) activated

表示进程因什么原因进入就绪态,这一原因会影响到调度优先级的计算。activated 有四个值:

  • -1,进程从 TASK_UNINTERRUPTIBLE 状态被唤醒;
  • 0,缺省值,进程原本就处于就绪态;
  • 1,进程从 TASK_INTERRUPTIBLE 状态被唤醒,且不在中断上下文中;
  • 2,进程从 TASK_INTERRUPTIBLE 状态被唤醒,且在中断上下文中。
    activated 初值为 0,在两个地方修改,一是在 schedule() 中,被恢复为 0,另一个就是 activate_task(),这个函数由 try_to_wake_up() 函数调用,用于激活休眠进程:
  • 如果是中断服务程序调用的 activate_task(),也就是说进程由中断激活,则该进程最有可能是交互式的,因此,置 activated=2;否则置activated=1。
  • 如果进程是从 TASK_UNINTERRUPTIBLE 状态中被唤醒的,则 activated=-1(在try_to_wake_up()函数中)。
    activated 变量的具体含义和使用见"优化了的优先级计算方式"。

6) sleep_avg

进程的平均等待时间(以 nanosecond 为单位),在 0 到 NS_MAX_SLEEP_AVG 之间取值,初值为 0,相当于进程等待时间与运行时间的差值。sleep_avg 所代表的含义比较丰富,既可用于评价该进程的"交互程度",又可用于表示该进程需要运行的紧迫性。这个值是动态优先级计算的关键因子,sleep_avg 越大,计算出来的进程优先级也越高(数值越小)。在下文"进程平均等待时间 sleep_avg" 中会详细分析 sleep_avg 的变化过程。

7) interactive_credit

这个变量记录了本进程的"交互程度",在 -CREDIT_LIMIT 到 CREDIT_LIMIT+1 之间取值。进程被创建出来时,初值为 0,而后根据不同的条件加 1 减 1,一旦超过 CREDIT_LIMIT(只可能等于 CREDIT_LIMIT+1),它就不会再降下来,表示进程已经通过了"交互式"测试,被认为是交互式进程了。interactive_credit具体的变化过程在"更精确的交互式进程优先"中会详细描述。

8) nvcsw/nivcsw/cnvcsw/cnivcsw

进程切换计数。

9) time_slice

进程的时间片余额,相当于 2.4 的 counter,但不再直接影响进程的动态优先级。在"新的运行时间片表现"中专门分析了 time_slice 的行为。

10) first_time_slice

0 或 1,表示是否是第一次拥有时间片(刚创建的进程)。这一变量用来判断进程结束时是否应当将自己的剩余时间片返还给父进程(见"新的运行时间片表现")。

11) run_list

前面提到,优先级数组 prio_array 结构中按顺序排列了各个优先级下的所有进程,但实际上数组中每一个元素都是 list_head 结构,以它为表头的链表中的每一个元素也是 list_head,其中链接的就是 task_struct 中的 run_list 成员。这是一个节省空间、加速访问的小技巧:调度器在 prio_array 中找到相应的 run_list,然后通过 run_list 在 task_struct 中的固定偏移量找到对应的 task_struct(参见 enqueue_task()、dequeue_task() 和 list.h 中的操作)。

12) array

记录当前 CPU 的活跃就绪队列(runqueue::active)。

13) thread_info

当前进程的一些运行环境信息,其中有两个结构成员与调度关系紧密:

  • preempt_count:初值为 0 的非负计数器,大于 0 表示核心不宜被抢占;
  • flags:其中有一个 TIF_NEED_RESCHED 位,相当于 2.4 中的 need_resched 属性,如果当前运行中的进程此位为 1,则表示应该尽快启动调度器。

在 2.4 中,每个进程的 task_struct 都位于该进程核心栈的顶端(低址部分),内核可以通过栈寄存器 ESP 轻松访问到当前进程的 task_struct。在 2.6 中,仍然需要频繁访问这个名为 current 的数据结构,但现在,进程核心栈顶保存的是其中的 thread_info 属性,而不是完整的 task_struct 了。这样做的好处是仅将最关键的、访问最频繁的运行环境保存在核心栈里(仍然是两个页大小),而将 task_struct 大部分内容通过 thread_info::task 指针保存在栈外,以方便扩充。thread_info 的分配方式和访问方式与 2.4 中的 task_struct 完全相同,现在的 current 需要这样来访问:

/* 节选自[include/asm-i386/current.h] */
static inline struct task_struct * get_current(void)
{
	return current_thread_info()->task;
}
#define current get_current()
其中current_thread_info()定义为:
/* 节选自[include/asm-i386/thread_info.h] */
static inline struct thread_info *current_thread_info(void)
{
	struct thread_info *ti;
	__asm__("andl %%esp,%0; ":"=r" (ti) : "0" (~8191UL));
	return ti;
}

 
图2:现在的current

4. 新的运行时间片表现

2.6 中,time_slice 变量代替了 2.4 中的 counter 变量来表示进程剩余运行时间片。time_slice 尽管拥有和 counter 相同的含义,但在内核中的表现行为已经大相径庭,下面分三个方面讨论新的运行时间片表现:

1) time_slice 基准值

和 counter 类似,进程的缺省时间片与进程的静态优先级(在 2.4 中是 nice 值)相关,使用如下公式得出:

			MIN_TIMESLICE + 	((MAX_TIMESLICE - MIN_TIMESLICE) * 
			(MAX_PRIO-1 - (p)->static_prio) / (MAX_USER_PRIO-1))
			

代入各个宏的值后,结果如图所示:

 

可见,核心将 100~139 的优先级映射到 200ms~10ms 的时间片上去,优先级数值越大,则分配的时间片越小。

和 2.4 中进程的缺省时间片比较,当 nice 为 0 时,2.6 的基准值 100ms 要大于 2.4 的 60ms。

进程的平均时间片
核心定义进程的平均时间片 AVG_TIMESLICE 为 nice 值为 0 的时间片长度,根据上述公式计算所得大约是 102ms。这一数值将作为进程运行时间的一个基准值参与优先级计算。

2) time_slice 的变化

进程的 time_slice 值代表进程的运行时间片剩余大小,在进程创建时与父进程平分时间片,在运行过程中递减,一旦归 0,则按 static_prio 值重新赋予上述基准值,并请求调度。时间片的递减和重置在时钟中断中进行(sched_tick()),除此之外,time_slice 值的变化主要在创建进程和进程退出过程中:

a) 进程创建
和 2.4 类似,为了防止进程通过反复 fork 来偷取时间片,子进程被创建时并不分配自己的时间片,而是与父进程平分父进程的剩余时间片。也就是说,fork 结束后,两者时间片之和与原先父进程的时间片相等。

b) 进程退出
进程退出时(sched_exit()),根据 first_time_slice 的值判断自己是否从未重新分配过时间片,如果是,则将自己的剩余时间片返还给父进程(保证不超过 MAX_TIMESLICE)。这个动作使进程不会因创建短期子进程而受到惩罚(与不至于因创建子进程而受到"奖励"相对应)。如果进程已经用完了从父进程那分得的时间片,就没有必要返还了(这一点在 2.4 中没有考虑)。

3) time_slice 对调度的影响

在 2.4 中,进程剩余时间片是除 nice 值以外对动态优先级影响最大的因素,并且休眠次数多的进程,它的时间片会不断叠加,从而算出的优先级也更大,调度器正是用这种方式来体现对交互式进程的优先策略。但实际上休眠次数多并不表示该进程就是交互式的,只能说明它是 IO 密集型的,因此,这种方法精度很低,有时因为误将频繁访问磁盘的数据库应用当作交互式进程,反而造成真正的用户终端响应迟缓。

2.6 的调度器以时间片是否耗尽为标准将就绪进程分成 active、expired 两大类,分别对应不同的就绪队列,前者相对于后者拥有绝对的调度优先权--仅当active 进程时间片都耗尽,expired 进程才有机会运行。但在 active 中挑选进程时,调度器不再将进程剩余时间片作为影响调度优先级的一个因素,并且为了满足内核可剥夺的要求,时间片太长的非实时交互式进程还会被人为地分成好几段(每一段称为一个运行粒度,定义见下)运行,每一段运行结束后,它都从 cpu 上被剥夺下来,放置到对应的 active 就绪队列的末尾,为其他具有同等优先级的进程提供运行的机会。

这一操作在 schedule_tick() 对时间片递减之后进行。此时,即使进程的时间片没耗完,只要该进程同时满足以下四个条件,它就会被强制从 cpu 上剥夺下来,重新入队等候下一次调度:

  • 进程当前在 active 就绪队列中;
  • 该进程是交互式进程(TASK_INTERACTIVE()返回真,见"更精确的交互式进程优先",nice 大于 12 时,该宏返回恒假);
  • 该进程已经耗掉的时间片(时间片基准值减去剩余时间片)正好是运行粒度的整数倍;
  • 剩余时间片不小于运行粒度
运行粒度的定义运行粒度 TIMESLICE_GRANULARITY 被定义为与进程的 sleep_avg 和系统总 CPU 数相关的宏。因为 sleep_avg 实际上代表着进程的非运行时间与运行时间的差值,与交互程度判断关系密切,所以,运行粒度的定义说明了内核的以下两个调度策略:
  • 进程交互程度越高,运行粒度越小,这是交互式进程的运行特点所允许的;与之对应,CPU-bound 的进程为了避免 Cache 刷新,不应该分片;
  • 系统 CPU 数越多,运行粒度越大。

5. 优化了的优先级计算方法

在 2.4 内核中,优先级的计算和候选进程的选择集中在调度器中进行,无法保证调度器的执行时间,这一点在前面介绍 runqueue 数据结构的时候已经提及。2.6 内核中候选进程是直接从已按算法排序的优先级队列数组中选取出来的,而优先级的计算则分散到多处进行。这一节分成两个部分对这种新的优先级计算方法进行描述,一部分是优先级计算过程,一部分是优先级计算(以及进程入队)的时机。

1) 优先级计算过程

动态优先级的计算主要由 effect_prio() 函数完成,该函数实现相当简单,从中可见非实时进程的优先级仅决定于静态优先级(static_prio)和进程的sleep_avg 值两个因素,而实时进程的优先级实际上是在 setscheduler() 中设置的(详见"调度系统的实时性能",以下仅考虑非实时进程),且一经设定就不再改变。相比较而言,2.4 的 goodness() 函数甚至要更加复杂,它考虑的 CPU Cache 失效开销和内存切换的开销这里都已经不再考虑。

2.6 的动态优先级算法的实现关键在 sleep_avg 变量上,在 effective_prio() 中,sleep_avg 的范围是 0~MAX_SLEEP_AVG,经过以下公式转换后变成-MAX_BONUS/2~MAX_BONUS/2 之间的 bonus:

(NS_TO_JIFFIES((p)->sleep_avg) * MAX_BONUS / MAX_SLEEP_AVG) - MAX_BONUS/2

如下图所示:

 

再用这个 bonus 去减静态优先级就得到进程的动态优先级(并限制在 MAX_RT_PRIO和MAX_PRIO 之间),bonus 越小,动态优先级数值越大,优先级越低。也就是说,sleep_avg 越大,优先级也越高。

MAX_BONUS 定义为 MAX_USER_PRIO*PRIO_BONUS_RATIO/100,也就是说,sleep_avg 对动态优先级的影响仅在静态优先级的用户优先级区(100~140)的1/4区间(±5)之内,相对而言,静态优先级,也就是用户指定的 nice 值在优先级计算的比重要大得多。这也是 2.6 调度系统中变化比较大的一个地方,调度器倾向于更多地由用户自行设计进程的执行优先级。

sleep_avg 反映了调度系统的两个策略:交互式进程优先和分时系统的公平共享,在下一节中我们还要专门分析。

2) 优先级计算时机

优先级的计算不再集中在调度器选择候选进程的时候进行了,只要进程状态发生改变,核心就有可能计算并设置进程的动态优先级:

a) 创建进程

在wake_up_forked_process()中,子进程继承了父进程的动态优先级,并添加到父进程所在的就绪队列中。

如果父进程不在任何就绪队列中(例如它是 IDLE 进程),那么就通过 effect_prio() 函数计算出子进程的优先级,而后根据计算结果将子进程放置到相应的就绪队列中。

b) 唤醒休眠进程

核心调用 recalc_task_prio() 设置从休眠状态中醒来的进程的动态优先级,再根据优先级放置到相应就绪队列中。

c) 调度到从 TASK_INTERRUPTIBLE 状态中被唤醒的进程

实际上此时调度器已经选定了候选进程,但考虑到这一类型的进程很有可能是交互式进程,因此此时仍然调用 recalc_task_prio() 对该进程的优先级进行修正(详见"进程平均等待时间 sleep_avg"),修正的结果将在下一次调度时体现。

d) 进程因时间片相关的原因被剥夺 cpu

在 schedule_tick() 中(由时钟中断启动),进程可能因两种原因被剥夺 cpu,一是时间片耗尽,一是因时间片过长而分段。这两种情况都会调用effect_prio() 重新计算优先级,重新入队。

e) 其它时机

这些其它时机包括 IDLE 进程初始化(init_idle())、负载平衡(move_task_away(),详见"调度器相关的负载平衡")以及修改 nice 值(set_user_nice())、修改调度策略(setscheduler())等主动要求改变优先级的情况。

由上可见,2.6 中动态优先级的计算过程在各个进程运行过程中进行,避免了类似 2.4 系统中就绪进程很多时计算过程耗时过长,从而无法预计进程的响应时间的问题。同时,影响动态优先级的因素集中反映在 sleep_avg 变量上。

6. 进程平均等待时间 sleep_avg

进程的 sleep_avg 值是决定进程动态优先级的关键,也是进程交互程度评价的关键,它的设计是 2.6 调度系统中最为复杂的一个环节,可以说,2.6 调度系统的性能改进,很大一部分应该归功于 sleep_avg 的设计。这一节,我们将专门针对 sleep_avg 的变化和它对调度的影响进行分析。

内核中主要有四个地方会对 sleep_avg 进行修改:休眠进程被唤醒时(activate_task()调用 recalc_task_prio() 函数)、TASK_INTERRUPTIBLE 状态的进程被唤醒后第一次调度到(schedule()中调用 recalc_task_prio())、进程从 CPU 上被剥夺下来(schedule()函数中)、进程创建和进程退出,其中recalc_task_prio() 是其中复杂度最高的,它通过计算进程的等待时间(或者是在休眠中等待,或者是在就绪队列中等待)对优先级的影响来重置优先级。

1) 休眠进程被唤醒时

此时 activate_task() 以唤醒的时间作为参数调用 recalc_task_prio(),计算休眠等待的时间对优先级的影响。

在 recalc_task_prio() 中,sleep_avg 可能有四种赋值,并最终都限制在 NS_MAX_SLEEP_AVG 以内:

a) 不变

从 TASK_UNINTERRUPTIBLE 状态中被唤醒(activated==-1)、交互程度不够高(!HIGH_CREDIT(p))的用户进程(p->mm!=NULL)),如果它的 sleep_avg 已经不小于 INTERACTIVE_SLEEP(p) 了,则它的 sleep_avg 不会因本次等待而改变。

b) INTERACTIVE_SLEEP(p)

从 TASK_UNINTERRUPTIBLE 状态中被唤醒(activated==-1)、交互程度不够高(!HIGH_CREDIT(p))的用户进程(p->mm!=NULL)),如果它的 sleep_avg 没有达到 INTERACTIVE_SLEEP(p),但如果加上本次休眠时间 sleep_time 就达到了,则它的 sleep_avg 就等于 INTERACTIVE_SLEEP(p)。

c) MAX_SLEEP_AVG-AVG_TIMESLICE

用户进程(p->mm!=NULL),如果不是从 TASK_UNINTERRUPTIBLE 休眠中被唤醒的(p->activated!=-1),且本次等待的时间(sleep_time)已经超过了 INTERACTIVE_SLEEP(p),则它的 sleep_avg 置为 JIFFIES_TO_NS(MAX_SLEEP_AVG-AVG_TIMESLICE)。

d) sleep_avg+sleep_time

如果不满足上面所有情况,则将 sleep_time 叠加到 sleep_avg 上。此时,sleep_time 要经过两次修正:

i. 根据 sleep_avg 的值进行修正,sleep_avg 越大,修正后的 sleep_time 越小:

		sleep_time = 
sleep_time * MAX_BONUS * (1-NS_TO_JIFFIES(sleep_avg)/MAX_SLEEP_AVG)

ii. 如果进程交互程度很低(LOW_CREDIT()返回真,见"更精确的交互式进程优先"),则将 sleep_time 限制在进程的基准时间片以内,以此作为对 cpu-bound 的进程的优先级惩罚。

总的来说,本次等待时间越长,sleep_avg 就应该更大,但核心排除了两种情况:

  • 从 TASK_UNINTERRUPTIBLE 状态醒来的进程,尤其是那些休眠时间比较长的进程,很有可能是等待某种资源而休眠,它们不应该受到等待时间的优先级奖励,它的 sleep_avg 被限制在 INTERACTIVE_SLEEP(p) 范围内(或不超过太多)(INTERACTIVE_SLEEP(p) 的含义见后),--这一限制对已经认定为是交互式的进程无效。
  • 不是从 TASK_UNITERRUPTIBLE 状态中醒来的进程,如果本次等待时间过长,也不应该受到等待时间的优先级奖励。
INTERACTIVE_SLEEP(p) 与 nice 的关系
NS_TO_JIFFIES(INTERACTIVE_SLEEP(p))=
TASK_NICE(p)*MAX_SLEEP_AVG/40+ 
(INTERACTIVE_DELTA+1+MAX_BONUS/2)*MAX_SLEEP_AVG/MAX_BONUS -1
这个宏与进程 nice 值成正比,说明优先级越低的进程,允许它在"交互式休眠"状态下的时间越长。

2) TASK_INTERRUPTIBLE 状态的进程被唤醒后第一次被调度到时

调度器挑选出候选进程之后,如果发现它是从 TASK_INTERRUPTIBLE 休眠中醒来后第一次被调度到(activated>0),调度器将根据它在就绪队列上等待的时长调用 recalc_task_prio() 重新调整进程的 sleep_avg。

recalc_task_prio() 调整的过程与"休眠进程被唤醒时"的情况是一模一样的,所不同的是,此时作为等待时间 sleep_time 参与计算的不是进程的休眠时间,而是进程在就绪队列上等待调度的时间。并且,如果进程不是被中断唤醒的(activated==1),sleep_time 还将受到约束(delta=delta*ON_RUNQUEUE_WEIGHT/100),因为此时该进程不是交互式进程的可能性很大。从上面对 recalc_task_prio() 的分析可知,sleep_time 减小一般来说就意味着优先级会相应降低,所以,这一奖励说明调度器在进一步减小进程的响应时间,尤其是交互式进程。

3) 被切换下来的进程

前面说过,sleep_avg 是进程的"平均"等待时间,recalc_task_prio() 计算了等待时间,在 schedule() 中,被切换下来的进程的 sleep_avg 需要减去进程本次运行的时间 run_time(并保证结果不小于 0),这就是对"平均"的体现:等待得越久,sleep_avg 越大,进程越容易被调度到;而运行得越久,sleep_avg 越小,进程越不容易调度到。

run_time 可以用系统当前时间与进程 timestamp(上一次被调度运行的时间)的差值表示,但不能超过 NS_MAX_SLEEP_AVG。对于交互式进程(HIGHT_CREDIT(p) 为真,见"更精确的交互式进程优先"),run_time 还要根据 sleep_avg 的值进行调整:

run_time = run_time / (sleep_avg*MAX_BONUS/MAX_SLEEP_AVG)

这样调整的结果是交互式进程的 run_time 小于实际运行时间,sleep_avg 越大,则 run_time 减小得越多,因此被切换下来的进程最后计算所得的 sleep_avg 也就越大,动态优先级也随之变大。交互式进程可以借此获得更多被执行的机会。

4) fork 后

在 wake_up_forked_process() 中,父进程的 sleep_avg 要乘以 PARENT_PENALTY/100,而子进程的 sleep_avg 则乘以 CHILD_PENALTY/100。实际上PARENT_PENALTY 为 100,CHILD_PENALTY 等于 95,也就是说父进程的 sleep_avg 不会变,而子进程从父进程处继承过来的 sleep_avg 会减小 5%,因此子进程最后的优先级会比父进程稍低(但子进程仍然会置于与父进程相同的就绪队列上,位置在父进程之前--也就是"前言"所说"子进程先于父进程运行")。

5) 进程退出时

一个进程结束运行时,如果它的交互程度比父进程低(sleep_avg 较小),那么核心将在 sched_exit() 中对其父进程的 sleep_avg 进行调整,调整公式如下(以 child_sleep_avg 表示子进程的 sleep_avg):

sleep_avg= 
sleep_avg*EXIT_WEIGHT/(EXIT_WEIGHT+1) + child_sleep_avg/(EXIT_WEIGHT+1)

其中 EXIT_WEIGHT 等于 3,所以父进程的 sleep_avg 将减少自身 sleep_avg 的 1/4,再补偿子进程 sleep_avg 的 1/4,优先级也将随之有所下降,子进程的交互程度与父进程相差越大,则优先级的惩罚也越明显。

利用进程平均等待时间来衡量进程的优先级,使得宏观上相同静态优先级的所有进程的等待时间和运行时间的比值趋向一致,反映了 Linux 要求各进程分时共享 cpu 的公平性。另一方面,sleep_avg 还是进程交互式程度的衡量标准。

7. 更精确的交互式进程优先

交互式进程优先策略的实际效果在 2.4 内核中受到广泛批评,在 2.6 内核中,这一点得到了很大改进,总体来说,内核有四处对交互式进程的优先考虑:

1) sleep_avg

上文已经详细分析了 sleep_avg 对进程优先级的影响,从中可以看出,交互式进程因为休眠次数多、时间长,它们的 sleep_avg 也会相应地更大一些,所以计算出来的优先级也会相应高一些。

2) interactive_credit

系统引入了一个 interactive_credit 的进程属性(见"改进后的 task_struct"),用来表征该进程是否是交互式进程:只要 interactive_credit 超过了 CREDIT_LIMIT 的阀值(HIGH_CREDIT()返回真),该进程就被认为是交互式进程。

interactive_credit 的初始值为 0,在两种情况下它会加 1,这两种场合都在 recalc_task_prio() 函数中:

  • 用户进程(p->mm!=NULL),如果不是从 TASK_UNINTERRUPTIBLE 休眠中被唤醒的(p->activated!=-1),且等待的时间(包括在休眠中等待和在就绪队列中等待,)超过了一定限度(sleep_time>INTERACTIVE_SLEEP(p));
  • 除以上情况外,sleep_avg 经过 sleep_time 调整后,如果大于 NS_MAX_SLEEP_AVG。

无论哪种情况,一旦 interactive_credit 超过(大于)CREDIT_LIMIT 了,它都不再增加,因此 interactive_credit 最大值就是 CREDIT_LIMIT+1。

interactive_credit 的递减发生在 schedule() 函数中。当调度器用运行时间修正被切换下来的进程的 sleep_avg 之后,如果 sleep_avg 小于等于 0,且interactive_credit 在 -CREDIT_LIMIT 和 CREDIT_LIMIT 之间(-100<=interactive_credit<=100),则 interactive_credit 减 1。可见interactive_credit 最小值为 -101,且一旦它达到 CREDIT_LIMIT+1 的最大值就不会再被减下来--它将保持在 CREDIT_LIMIT+1 的高值上。

这就是说,只有进程多次休眠,且休眠的时间足够长(长于运行的时间,长于"交互式休眠"时间),进程才有可能被列为交互式进程;而一旦被认为是交互式进程,则永远按交互式进程对待。

采用 HIGH_CREDIT() 标准断言的交互式进程主要在以下两处得到优先级计算上的奖励:

  • 当进程从 cpu 上调度下来的时侯,如果是交互式进程,则它参与优先级计算的运行时间会比实际运行时间小,以此获得较高的优先级(见"进程平均等待时间 sleep_avg");
  • 交互式进程处于 TASK_UNINTERRUPTIBLE 状态下的休眠时间也会叠加到 sleep_avg 上,从而获得优先级奖励(见"进程平均等待时间sleep_avg");

3) TASK_INTERACTIVE()

核心另有一处不采用 HIGH_CREDIT() 这种累积方式来判断的交互式进程优先机制,那里使用的是 TASK_INTERACTIVE() 宏(布尔值):

prio <= static_prio-DELTA(p)

当进程时间片耗尽时,如果该宏返回真(同时 expired 队列没有等待过长的时间,见"新的数据结构 runqueue""expired_timestamp"条),则该进程不进入 expired 队列而是保留在 active 队列中,以便尽快调度到这一交互式进程。动态优先级在调度到该进程时在 effective_prio() 中算出:prio=static_prio-bonus(sleep_avg)(bonus(sleep_avg) 表示 bonus 是关于 sleep_avg 的函数,见"优先级计算过程"),而 DELTA(p) 与进程的 nice 值有关,可表示为delta(nice)。bonus 与 sleep_avg 的关系在"优先级计算过程"一节中已经用图说明了,delta 和 nice 之间的关系见下图:

 

nice 值的范围是 -20~19,DELTA(p)将其转换到 -5~+5 再加上一个INTERACTIVE_DELTA常量的范围内:TASK_NICE(p) * MAX_BONUS/40 + INTERACTIVE_DELTA,其中 INTERACTIVE_DELTA 等于 2。

经过转换,TASK_INTERACTIVE(p) 变为 "delta(nice) 是否不大于 bonus(sleep_avg)"。将 sleep_avg 表示为 JIFFIES 的形式,并代入常数,delta(nice)<=bonus(sleep_avg) 可以表示为:

nice/4+2 <= bonus,-5<=bonus<=+5

从中可以看出,nice 大于 12 时,此不等式恒假,也就是说此时进程永远不会被当作交互式进程看待;而进程静态优先级越高,要被当作交互式进程所需要的 sleep_avg 上限也越低,即静态优先级高的进程获得这种奖励的机会更大。

4) 就绪等待时间的奖励

因为经常处于 TASK_INTERRUPTIBLE 状态的进程最有可能是交互式的,因此,这一类进程从休眠中醒来后在就绪队列上等待调度的时间长短也将影响进程的动态优先级。

这一工作在调度器(schedule())选择上这一类型的进程之后进行,并且考虑到交互式进程通常都是在中断中被唤醒的,所以核心还记录了这一信息,对不由中断唤醒的进程实行奖励约束(详见"进程平均等待时间sleep_avg")。

8. 调度器

有了以上的准备工作之后,现在我们可以看看调度器的主流程了。

和 2.4 的调度器相比,2.6 的 schedule()函数要更加简单一些,减少了锁操作,优先级计算也拿到调度器外进行了。为减少进程在 cpu 间跳跃,2.4 中将被切换下来的进程重新调度到另一个 cpu 上的动作也省略了。调度器的基本流程仍然可以概括为相同的五步:

  • 清理当前运行中的进程(prev)
  • 选择下一个投入运行的进程(next)
  • 设置新进程的运行环境
  • 执行进程上下文切换
  • 后期整理

2.6 的调度器工作流程保留了很多 2.4 系统中的动作,进程切换的细节也与 2.4 基本相同(由 context_switch() 开始)。为了不与 2.4 系统的调度器分析重复,我们按照调度器对各个数据结构的影响来分析调度器的工作流程,重点在与 2.4 调度器不同的部分,与之相同或相似的部分相信读者结合代码和上文的技术分析很容易理解。同时,2.6 的调度器中还增加了对负载平衡和内核抢占运行的支持,因为内容比较独立,我们也将其放在单独的章节中。

1) 相关锁

主要是因为就绪队列分布到各个 cpu 上了,2.6 调度器中仅涉及两个锁的操作:就绪队列锁 runqueue::lock,全局核心锁 kernel_flag。对就绪队列锁的操作保证了就绪队列的操作唯一性,核心锁的意义与 2.4 中相同:调度器在执行切换之前应将核心锁解开(release_kernel_lock()),完成调度后恢复锁状态(reacquire_kernel_lock())。进程的锁状态依然保存在task_struct::lock_depth属性中。

因为调度器中没有任何全局的锁操作,2.6 调度器本身的运行障碍几乎不存在了。

2) prev

调度器主要影响 prev 进程的两个属性:

  • sleep_avg 减去了本进程的运行时间(详见"进程平均等待时间 sleep_avg"的"被切换下来的进程");
  • timestamp 更新为当前时间,记录被切换下去的时间,用于计算进程等待时间。

prev被切换下来后,即使修改了 sleep_avg,它在就绪队列中的位置也不会改变,它将一直以此优先级参加调度直至发生状态改变(比如休眠)。

3) next

在前面介绍 runqueue 数据结构的时候,我们已经分析了 active/expired 两个按优先级排序的就绪进程队列的功能,2.6 的调度器对候选进程的定位有三种可能:

  • active 就绪队列中优先级最高且等待时间最久的进程;
  • 当前 runqueue 中没有就绪进程了,则启动负载平衡从别的 cpu 上转移进程,再进行挑选(详见"调度器相关的负载平衡");
  • 如果仍然没有就绪进程,则将本 cpu 的 IDLE 进程设为候选。

在挑选出 next 之后,如果发现 next 是从 TASK_INTERRUPTIBLE 休眠中醒来后第一次被调度到(activated>0),调度器将根据 next 在就绪队列上等待的时长重新调整进程的优先级(并存入就绪队列中新的位置,详见"进程平均等待时间 sleep_avg")。

除了 sleep_avg 和 prio 的更新外,next 的 timestamp 也更新为当前时间,用于下一次被切换下来时计算运行时长。

4) 外环境

这里说的外环境指的是调度器对除参与调度的进程以及所在就绪队列以外的环境的影响,主要包括切换计数处理和 cpu 状态的更新(qsctr)。

9. 调度器对内核抢占运行的支持

在2.4 系统中,在核心态运行的任何进程,只有当它调用 schedule() 主动放弃控制时,调度器才有机会选择其他进程运行,因此我们说 Linux 2.4 的内核是不可抢占运行的。缺乏这一支持,核心就无法保证实时任务的及时响应,因此也就满足不了实时系统(即使是软实时)的要求。

2.6 内核实现了抢占运行,没有锁保护的任何代码段都有可能被中断,它的实现,对于调度技术来说,主要就是增加了调度器运行的时机。我们知道,在 2.4 内核里,调度器有两种启动方式:主动式和被动式,其中被动方式启动调度器只能是在控制从核心态返回用户态的时候,因此才有内核不可抢占的特点。2.6 中,调度器的启动方式仍然可分为主动式和被动式两种,所不同的是被动启动调度器的条件放宽了很多。它的修改主要在 entry.S 中:

……
ret_from_exception:			#从异常中返回的入口
	preempt_stop				#解释为 cli,关中断,即从异常中返回过程中不允许抢占
ret_from_intr:				#从中断返回的入口
	GET_THREAD_INFO(%ebp)	#取task_struct的thread_info信息
	movl EFLAGS(%esp), %eax
	movb CS(%esp), %al
	testl $(VM_MASK | 3), %eax
	jz resume_kernel			#"返回用户态"和"在核心态中返回"的分路口
ENTRY(resume_userspace)
 	cli
movl TI_FLAGS(%ebp), %ecx
	andl $_TIF_WORK_MASK, %ecx	#(_TIF_NOTIFY_RESUME | _TIF_SIGPENDING			 							
	#  | _TIF_NEED_RESCHED)
	jne work_pending
	jmp restore_all
ENTRY(resume_kernel)
	cmpl $0,TI_PRE_COUNT(%ebp)
	jnz restore_all				#如果preempt_count非0,则不允许抢占
need_resched:
	movl TI_FLAGS(%ebp), %ecx
	testb $_TIF_NEED_RESCHED, %cl
	jz restore_all				#如果没有置NEED_RESCHED位,则不需要调度
	testl $IF_MASK,EFLAGS(%esp)
	jz restore_all				#如果关中断了,则不允许调度
	movl $PREEMPT_ACTIVE,TI_PRE_COUNT(%ebp)		#preempt_count 设为 PREEMPT_ACTIVE,通知调度器目前这次调度正处在一次抢
                                                        #占调度中
	sti							
	call schedule
	movl $0,TI_PRE_COUNT(%ebp)	#preemmpt_count清0
	cli
	jmp need_resched
……
work_pending:					#这也是从系统调用中返回时的resched入口
	testb $_TIF_NEED_RESCHED, %cl
	jz work_notifysig			#不需要调度,那么肯定是因为有信号需要处理才进入work_pending的
work_resched:
	call schedule
	cli				
	movl TI_FLAGS(%ebp), %ecx
	andl $_TIF_WORK_MASK, %ecx	
	jz restore_all				#没有work要做了,也不需要resched
	testb $_TIF_NEED_RESCHED, %cl
	jnz work_resched				#或者是需要调度,或者是有信号要处理
work_notifysig:
……

现在,无论是返回用户态还是返回核心态,都有可能检查 NEED_RESCHED 的状态;返回核心态时,只要 preempt_count 为 0,即当前进程目前允许抢占,就会根据 NEED_RESCHED 状态选择调用 schedule()。在核心中,因为至少时钟中断是不断发生的,因此,只要有进程设置了当前进程的 NEED_RESCHED 标志,当前进程马上就有可能被抢占,而无论它是否愿意放弃 cpu,即使是核心进程也是如此。

调度器的工作时机
除核心应用主动调用调度器之外,核心还在应用不完全感知的情况下在以下三种时机中启动调度器工作:
  • 从中断或系统调用中返回;
  • 进程重新允许抢占(preempt_enable()调用preempt_schedule());
  • 主动进入休眠(例如wait_event_interruptible()接口)

10. 调度器相关的负载平衡

在 2.4 内核中,进程p被切换下来之后,如果还有 cpu 空闲,或者该 cpu 上运行的进程优先级比自己低,那么 p 就会被调度到那个 cpu 上运行,核心正是用这种办法来实现负载的平衡。

简单是这种负载平衡方式最大的优点,但它的缺点也比较明显:进程迁移比较频繁,交互式进程(或高优先级的进程)可能还会在 cpu 之间不断"跳跃"。即使是在 SMP 的环境中,进程迁移也是有代价的,2.4 系统的使用经验表明,这种负载平衡方式弊大于利,解决这一"SMP亲和"的问题是 2.6 系统设计的目标之一。

2.6 调度系统采用相对集中的负载平衡方案,分为"推"和"拉"两类操作:

1) "拉"

当某个 cpu 负载过轻而另一个 cpu 负载较重时,系统会从重载 cpu 上"拉"进程过来,这个"拉"的负载平衡操作实现在 load_balance() 函数中。

load_balance() 有两种调用方式,分别用于当前 cpu 不空闲和空闲两种状态,我们称之为"忙平衡"和"空闲平衡":

a) 忙平衡

无论当前 cpu 是否繁忙或空闲,时钟中断(rebalance_tick()函数中)每隔一段时间(BUSY_REBALANCE_TICK)都会启动一次 load_balance() 平衡负载,这种平衡称为"忙平衡"。

Linux 2.6 倾向于尽可能不做负载平衡,因此在判断是否应该"拉"的时候做了很多限制:

  • 系统最繁忙的 cpu 的负载超过当前 cpu 负载的 25% 时才进行负载平衡;
  • 当前 cpu 的负载取当前真实负载和上一次执行负载平衡时的负载的较大值,平滑负载凹值;
  • 各 cpu 的负载情况取当前真实负载和上一次执行负载平衡时的负载的较小值,平滑负载峰值;
  • 对源、目的两个就绪队列加锁之后,再确认一次源就绪队列负载没有减小,否则取消负载平衡动作;
  • 源就绪队列中以下三类进程参与负载情况计算,但不做实际迁移:
    • 正在运行的进程
    • 不允许迁移到本 cpu 的进程(根据 cpu_allowed 属性)
    • 进程所在 cpu 上一次调度事件发生的时间(runqueue::timestamp_last_tick,在时钟中断中取值)与进程被切换下来的时间(task_struct::timestamp)之差小于某个阀值(cache_decay_ticks的nanosecond值),--该进程还比较活跃,cache 中的信息还不够凉。
负载的历史信息 为了避免竞争,调度器将全系统各个 CPU 进行负载平衡时的负载情况(就绪进程个数)保存在本 cpu 就绪队列的 prev_cpu_load 数组的对应元素中,在计算当前负载时会参考这一历史信息。

找到最繁忙的 cpu(源 cpu)之后,确定需要迁移的进程数为源 cpu 负载与本 cpu 负载之差的一半(都经过了上述历史信息平滑),然后按照从 expired 队列到 active 队列、从低优先级进程到高优先级进程的顺序进行迁移。但实际上真正执行迁移的进程往往少于计划迁移的进程,因为上述三类"不做实际迁移"的情况的进程不参与迁移。

b) 空闲平衡

空闲状态下的负载平衡有两个调用时机:

  • 在调度器中,本 cpu 的就绪队列为空;
  • 在时钟中断中,本 cpu 的就绪队列为空,且当前绝对时间(jiffies 值)是 IDLE_REBALANCE_TICK 的倍数(也就是说每隔 IDLE_REBALANCE_TICK 执行一次)。

此时 load_balance() 的动作比较简单:寻找当前真实负载最大的 cpu(runqueue::nr_running 最大),将其中"最适合"(见下)的一个就绪进程迁移到当前 cpu 上来。

"空闲平衡"的候选进程的标准和"忙平衡"类似,但因为空闲平衡仅"拉"一个进程过来,动作要小得多,且执行频率相对较高(IDLE_REBALANCE_TICK 是BUSY_REBALANCE_TICK 的 200 倍),所以没有考虑负载的历史情况和负载差,候选的迁移进程也没有考虑 Cache 活跃程度。

计算最繁忙 cpu 算法中的问题
实际上有可能成为平衡源的 cpu 的负载至少应该比当前 cpu 的负载要大,因此 find_busiest_queue() 函数中 max_load 的初值如果是 nr_running,且同时保证 load 最少为 1,那么计算会稍少一点。

c) pull_task()

"拉"进程的具体动作在这个函数中实现。进程从源就绪队列迁移到目的就绪队列之后,pull_task() 更新了进程的 timestamp 属性,使其能继续说明进程相对于本 cpu 的被切换下来的时间。如果被拉来的进程的优先级比本 cpu 上正在运行的进程优先级要高,就置当前进程的 NEED_RESCHED 位等待调度。

2) "推"

a) migration_thread()

与"拉"相对应,2.6 的负载平衡系统还有一个"推"的过程,执行"推"的主体是一个名为 migration_thread() 的核心进程。该进程在系统启动时自动加载(每个 cpu 一个),并将自己设为 SCHED_FIFO 的实时进程,然后检查 runqueue::migration_queue 中是否有请求等待处理,如果没有,就在 TASK_INTERRUPTIBLE 中休眠,直至被唤醒后再次检查。

migration_queue 仅在 set_cpu_allowed() 中添加,当进程(比如通过 APM 关闭某 CPU 时)调用 set_cpu_allowed() 改变当前可用 cpu,从而使某进程不适于继续在当前 cpu 上运行时,就会构造一个迁移请求数据结构 migration_req_t,将其植入进程所在 cpu 就绪队列的 migration_queue 中,然后唤醒该就绪队列的迁移 daemon(记录在 runqueue::migration_thread 属性中),将该进程迁移到合适的cpu上去(参见"新的数据结构 runqueue")。

在目前的实现中,目的 cpu 的选择和负载无关,而是"any_online_cpu(req->task->cpus_allowed)",也就是按 CPU 编号顺序的第一个 allowed 的CPU。所以,和 load_balance() 与调度器、负载平衡策略密切相关不同,migration_thread() 应该说仅仅是一个 CPU 绑定以及 CPU 电源管理等功能的一个接口。

b) move_task_away()

实际迁移的动作在 move_task_away() 函数中实现,进程进入目的就绪队列之后,它的 timestamp 被更新为目的 cpu 就绪队列的 timestamp_last_tick,说明本进程是刚开始(在目的 cpu 上)等待。因为"推"的操作是在本地读远地写(与 pull_task() 正相反),因此,在启动远地 cpu 的调度时需要与远地的操作同步,还可能要通过 IPI(Inter-Processor Interrupt)通知目的 cpu,所有这些操作实现在 resched_task()函数中。

两个 runqueue 的锁同步
在迁移进程时会牵涉到两个 cpu 上的就绪队列,通常在操作之前需要对两个就绪队列都加锁,为了避免死锁,内核提供了一套保证加锁顺序的double_rq_lock()/double_rq_unlock() 函数。这套函数并没有操作 IRQ,因此开关中断的动作需要用户自己做。
这套函数在 move_task_away() 中采用了,而 pull_task() 中使用的是 double_lock_balance(),但原理与 double_rq_lock()/double_rq_unlock() 相同。

11. NUMA结构下的调度

在 Linux 调度器看来,NUMA 与 SMP 之间主要的不同在于 NUMA 下的 cpu 被组织到一个个节点中了。不同的体系结构,每个节点所包含的 cpu 数是不同的,例如 2.6 的 i386 平台下,NUMAQ 结构每个节点上可配置 16 个 cpu,SUMMIT 结构可配置 32 个 cpu。 NUMA 结构正式体现在 Linux 内核中是从 2.6 开始的,在此之前,Linux 利用已有的"不连续内存"(Discontiguous memory,CONFIG_DISCONTIGMEM)体系结构来支持 NUMA。除了内存分配上的特殊处理以外,以往的内核在调度系统中是等同于 SMP 看待的。2.6 的调度器除了单个 cpu 的负载,还考虑了 NUMA 下各个节点的负载情况。

NUMA 结构在新内核中有两处特殊处理,一处是在做负载平衡时对各NUMA节点进行均衡,另一处是在系统执行新程序(do_execve())时从负载最轻的节点中选择执行cpu:

1) balance_node()

节点间的平衡作为 rebalance_tick() 函数中的一部分在 load_balance() 之前启动(此时的 load_balance() 的工作集是节点内的 cpu,也就是说,NUMA下不是单纯平衡全系统的 cpu 负载,而是先平衡节点间负载,再平衡节点内负载),同样分为"忙平衡"和"空闲平衡"两步,执行间隔分别为IDLE_NODE_REBALANCE_TICK(当前实现中是 IDLE_REBALANCE_TICK 的 5 倍)和 BUSY_NODE_REBALANCE_TICK(实现为 BUSY_NODE_REBALANCE_TICK 的 2 倍)。

balance_node() 先调用 find_busiest_node() 找到系统中最繁忙的节点,然后在该节点和本 cpu 组成的 cpu 集合中进行 load_balance()。寻找最繁忙节点的算法涉及到几个数据结构:

  • node_nr_running[MAX_NUMNODES],以节点号为索引记录了每个节点上的就绪进程个数,也就是那个节点上的实时负载。这个数组是一个全局数据结构,需要通过 atomic 系列函数访问。
  • runqueue::prev_node_load[MAX_NUMNODES],就绪队列数据结构中记录的系统各个节点上一次负载平衡操作时的负载情况,它按照以下公式修正:
    当前负载=上一次的负载/2 + 10*当前实时负载/节点cpu数
    采用这种计算方式可以平滑负载峰值,也可以考虑到节点cpu数不一致的情况。
  • NODE_THRESHOLD,负载的权值,定义为 125,被选中的最繁忙的节点的负载必须超过当前节点负载的 125/100,也就是负载差超过 25%。

2) sched_balance_exec()

当 execve() 系统调用加载另一个程序投入运行时,核心将在全系统中寻找负载最轻的一个节点中负载最轻的一个 cpu(sched_best_cpu()),然后调用sched_migrate_task() 将这个进程迁移到选定的 cpu 上去。这一操作通过 do_execve() 调用 sched_balance_exec() 来实现。

sched_best_cpu() 的选择标准如下:

  • 如果当前cpu就绪进程个数不超过2,则不做迁移;
  • 计算节点负载时,使用(10*当前实时负载/节点cpu数)的算法,不考虑负载的历史情况;
  • 计算节点内cpu的负载时,使用就绪进程的实际个数作为负载指标,不考虑负载的历史情况。

和"忙平衡"与"空闲平衡"采用不同负载评价标准一样,sched_balance_exec() 采用了与 balance_node() 不一样的(更简单的)评价标准。

sched_migrate_task() 借用了 migration_thread 服务进程来完成迁移,实际操作时将进程的 cpu_allowed 暂设为仅能在目的 cpu 上运行,唤醒migration_thread 将进程迁移到目的 cpu 之后再恢复 cpu_allowed 属性。

12. 调度器的实时性能

1) 2.6 对于实时应用的加强

2.6 内核调度系统有两点新特性对实时应用至关重要:内核抢占和 O(1) 调度,这两点都保证实时进程能在可预计的时间内得到响应。这种"限时响应"的特点符合软实时(soft realtime)的要求,离"立即响应"的硬实时(hard realtime)还有一定距离。并且,2.6 调度系统仍然没有提供除 cpu 以外的其他资源的剥夺运行,因此,它的实时性并没有得到根本改观。

2) 实时进程的优先级

2.4 系统中,实时进程的优先级通过 rt_priority 属性表示,与非实时进程不同。2.6 在静态优先级之外引入了动态优先级属性,并用它同时表示实时进程和非实时进程的优先级。

从上面的分析我们看到,进程的静态优先级是计算进程初始时间片的基础,动态优先级则决定了进程的实际调度优先顺序。无论是实时进程还是非实时进程,静态优先级都通过 set_user_nice() 来设置和改变,缺省值都是 120(MAX_PRIO-20),也就是说,实时进程的时间片和非实时进程在一个量程内。

可区分实时进程和非实时进程的地方有两处:调度策略 policy(SCHED_RR或SCHED_FIFO)和动态优先级 prio(小于 MAX_USER_RT_PRIO),实际使用上后者作为检验标准。实时进程的动态优先级在 setscheduler() 中设置(相当于 rt_priority),并且不随进程的运行而改变,所以实时进程总是严格按照设置的优先级进行排序,这一点和非实时进程动态优先级含义不同。可以认为,实时进程的静态优先级仅用于计算时间片,而动态优先级则相当于静态优先级。

3) 实时调度

2.4中SCHED_RR和SCHED_FIFO两种实时调度策略在2.6中未作改变,两类实时进程都会保持在active就绪队列中运行,只是因为2.6内核是可抢占的,实时进程(特别是核心级的实时进程)能更迅速地对环境的变化(比如出现更高优先级进程)做出反应。

13. 后记:从调度器看 Linux 发展

近年来,Linux 对于桌面系统、低端服务器、高端服务器以及嵌入式系统都表现出越来越强的兴趣和竞争力,对于一个仍然处于"集市式"开放开发模式的操作系统来说,能做到这一点简直就是一个奇迹。

但从调度系统的实现上我感觉,Linux 的长项仍然在桌面系统上,它仍然保持着早年开发时"利己主义"的特点,即自由软件的开发者的开发动力,很大程度上来自于改变现有系统对自己"不好用"的现状。尽管出于种种动机和动力,Linux 表现出与 Windows 等商用操作系统竞争的强势,但从开发者角度来看,这种愿望与自由软件的开发特点是有矛盾的。

Ingo Monar 在接受采访时说,他设计的 O(1) 调度算法,基本上来自于个人的创意,没有参考市面上以及研究领域中已有的调度算法。从调度器设计上可以看出,2.6 调度系统考虑了很多细节,但总体上并没有清晰的主线,且无法(或者也无意于)在理论上对 O(1) 模型进行性能分析。从 2.6 的开发过程中我们也能看到,各种调度相关的权值在不同的版本中一直在微调,可以认为,2.6 调度系统的性能优化主要是实测得来的。

这就是典型的 Linux 开发模式--充满激情、缺乏规划。

对于 Linux 的市场来说,最紧迫、最活跃的需要在于嵌入式系统,但至少从调度系统来看,2.6 并没有在这方面下很大功夫,也许开发者本人对此并无多大感受和兴趣。可以肯定,虽然 Linux 在市场上很火,但它的开发仍然不是市场驱动的。这或许会影响 Linux 的竞争力,但或许也因此能保持 Linux 开发的活力。

就在今年(2004年)3月1日,著名的开源网络安全项目 FreeS/WAN 宣布停止开发,其原因主要是开发者的意图和用户的需求不吻合。对于网络安全系统,用户更多考虑的是系统功能的完整、强大,而不是它可预知的先进性,因此,FreeS/WAN 新版本中主打推出的 Opportunistic Encryption (OE) 没有吸引到足够数量的用户测试。鉴于此,投资者停止了对 FreeS/WAN 项目的资助,这一为开源系统提供了强大的网络安全支持的系统也许会再次转入地下。

至今为止,还没有听到 Linux 的开发依赖于某种商业基金的报道,因此相对而言,Linux 的开发更具自由和随意性,推广 Linux 的人与开发 Linux 的人基本上独立运作着,Linux 的受益者和 Linux 的开发者也没有紧密结合。这对于 Linux 或许是福而不是祸。

14. 参考资料

[1][Linus Torvalds,2004]
Linux 内核源码 v2.6.2,www.kernel.org

[2][pubb@163.net,2004]
Linux 2.4调度系统分析,IBM Developerworks

[3][Ingo Molnar,2002]
Goals, Design and Implementation of the new ultra-scalable O(1) scheduler, Linux Documentation,sched-design.txt

[4][Anand K Santhanam (asanthan@in.ibm.com),2003]
走向 Linux 2.6,IBM Developerworks

[5][Robert Love,2003]
Linux Kernel Development,SAMS

[6][ bx_bird@sohu.com,2003]
2.5.62 SMP笔记,www.linux-forum.net内核技术版

[7][ Vinayak Hegde,2003]
The Linux Kernel,Linux Gazette 2003年4月号第89篇

[8][ Rick Fujiyama,2003]
Analyzing The Linux Scheduler's Tunables,kerneltrap.org

关于作者:

杨沙洲,目前在国防科技大学计算机学院攻读软件方向博士学位。对文中存在的技术问题,欢迎向pubb@163.net质疑,谢谢。

Logo

更多推荐