当前 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 或许是福而不是祸。
参考资料
[1][Linus Torvalds,2004]
Linux 内核源码 v2.6.2,www.kernel.org
[2][[email protected],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 (),2003]
走向 Linux 2.6 ,IBM Developerworks
[5][Robert Love,2003]
Linux Kernel Development,SAMS
[6][ ,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