四、Linux调度器分析
1.Linux2.6调度器的特性
2.6
调度系统从设计之初就把开发重点放在更好满足实时性和多处理机并行性上,并且基本实现了它的设计目标。新调度系统的特性概括为如下几点:
- 继承和发扬 2.4 版调度器的特点:
- 交互式作业优先
- 轻载条件下调度/唤醒的高性能
- 公平共享
- 基于优先级调度
- 高 CPU 使用率
- SMP 高效亲和
- 实时调度和 cpu 绑定等调度手段
- 在此基础之上的新特性:
- O(1)调度算法,调度器开销恒定(与当前系统负载无关),实时性能更好
- 高可扩展性,锁粒度大幅度减小
- 新设计的 SMP 亲和方法
- 优化计算密集型的批处理作业的调度
- 重载条件下调度器工作更平滑
- 子进程先于父进程运行等其他改进
2.新的数据结构 runqueue
2.4
的就绪队列是一个简单的以
runqueue_head
为头的双向链表,在
2.6
中,就绪队列定义为一个复杂得多的数据结构
struct runqueue
,并且,尤为关键的是,每一个
CPU
都将维护一个自己的就绪队列,
--
这将大大减小竞争。
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 数组示例
在新的
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)
宏上。
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
需要进行迁移的进程列表(见
"
调度器相关的负载平衡
"
)。
3.改进后的 task_struct
2.6
版的内核仍然用
task_struct
来表征进程,尽管对线程进行了优化,但线程的内核表示仍然与进程相同。随着调度器的改进,
task_struct
的内容也有了改进,交互式进程优先支持、内核抢占支持等新特性,在
task_struct
中都有所体现。在
task_struct
中,有的属性是新增加的,有的属性的值的含义发生了变化,而有的属性仅仅是改了一下名字。
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
。
2) time_slice
的变化
进程的
time_slice
值代表进程的运行时间片剩余大小,在进程创建时与父进程平分时间片,在运行过程中递减,一旦归
0
,则按
static_prio
值重新赋予上述基准值,并请求调度。时间片的递减和重置在时钟中断中进行(
sched_tick()
),除此之外,
time_slice
值的变化主要在创建进程和进程退出过程中:
a)
进程创建
为了防止进程通过反复
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
时,该宏返回恒假);
·
该进程已经耗掉的时间片(时间片基准值减去剩余时间片)正好是运行粒度的整数倍;
·
剩余时间片不小于运行粒度
5.优化了的优先级计算方法
在
2.4
内核中,优先级的计算和候选进程的选择集中在调度器中进行,无法保证调度器的执行时间,这一点在前面介绍
runqueue
数据结构的时候已经提及。
2.6
内核中候选进程是直接从已按算法排序的优先级队列数组中选取出来的,而优先级的计算则分散到多处进行。这一节分成两个部分对这种新的优先级计算方法进行描述,一部分是优先级计算过程,一部分是优先级计算(以及进程入队)的时机。
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()
是其中复杂度最高的,它通过计算进程的等待时间(或者是在休眠中等待,或者是在就绪队列中等待)对优先级的影响来重置优先级。
7.更精确的交互式进程优先
交互式进程优先策略的实际效果在
2.4
内核中受到广泛批评,在
2.6
内核中,这一点得到了很大改进,总体来说,内核有四处对交互式进程的优先考虑:
1) sleep_avg
2) interactive_credit
3) TASK_INTERACTIVE()
4) 就绪等待时间的奖励
8.调度器
有了以上的准备工作之后,现在我们可以看看调度器的主流程了。
2.6
的
schedule()
函数要更加简单一些,减少了锁操作,优先级计算也拿到调度器外进行了。为减少进程在
cpu
间跳跃,
2.4
中将被切换下来的进程重新调度到另一个
cpu
上的动作也省略了。调度器的基本流程仍然可以概括为相同的五步:
·
清理当前运行中的进程(
prev
)
·
选择下一个投入运行的进程(
next
)
·
设置新进程的运行环境
·
执行进程上下文切换
·
后期整理
2.6
的调度器工作流程保留了很多
2.4
系统中的动作,进程切换的细节也与
2.4
基本相同(由
context_switch()
开始)。
按照调度器对各个数据结构的影响来分析调度器的工作流程,同时,
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
中。
现在,无论是返回用户态还是返回核心态,都有可能检查
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
的负载情况取当前真实负载和上一次执行负载平衡时的负载的较小值,平滑负载峰值;
·
对源、目的两个就绪队列加锁之后,再确认一次源就绪队列负载没有减小,否则取消负载平衡动作;
·
源就绪队列中以下三类进程参与负载情况计算,但不做实际迁移:
o
正在运行的进程
o
不允许迁移到本
cpu
的进程(根据
cpu_allowed
属性)
o
进程所在
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.调度器初始化分析sched_init()
sched_init()
在CPU初始化、启动内存初始化完成之后,在中断、定时器等初始化之前由启动CPU调用。sched_init()的工作是:
1)
初始化每个
CPU
的运行队列
rq
的数据,
SMP
调度相关的数据
;
2)
调用
set_load_weight()
设置初始
Task
的负载;
3)
如果配置了
CONFIG_SMP
,则开始调度均衡的中断
SCHED_SOFTIRQ
;
4)
增加
init_mm
的引用计数;
5)
调用
enter_lazy_tlb()
,对所有
CPU
,只是简单的将
cpu_tlbstate
状态设为
TLBSTATE_LAZY
;
6)
调用
init_idle()
初始化
idle
内核线程,
idle
进程
0
即使系统所有进程的父进程;
对于SMP系统在init()—>smp_init()之后,再调用sched_init_smp()初始化CPU本身相关的调度域
和组等数据。