《linux内核设计与实现》第四章
调度程序负责决定哪个进程投入运行,何时运行以及运行多长时间。只有通过调度程序合理调度,系统资源才能最大限度发挥作用,多进程才会有并发执行的效果。
最大限度地利用处理器时间的原则是,只要有可以执行的进程,那么就总会有进程正在执行。
1.多任务
多任务系统分两类:非抢占式多任务(cooperative multitasking)和抢占式多任务(preemptive multitasking)。由调度器来决定什么时候停止一个进程运行,这个强制挂起动作叫做抢占(preemption)。
进程在被抢占之前可以运行的时间是预先设定好的,叫做时间片。有效管理时间片能使调度程序从系统全局角度作出调度决定,这样还可以避免个别进程独占系统资源。
现代操作系统多采用动态时间片计算方法和可配置的计算策略。但是Linux独一无二的“公平”调度程序本身并没有采用时间片来达到公平调度。
相反,cooperative multitasking中除非进程自己主动停止运行,否则会一直执行,主动挂起自己的操作叫让步。缺点是,调度器无法管理每个进程具体执行多少时间,更糟的是,一个绝不让步的悬挂进程能使系统崩溃。
2.Linux进程调度
Linux2.4之前的调度程序都相当简陋,让人也很容易理解,但是它在众多可运行进程或多处理器环境下都难以胜任。
正因为如此,Linux2.5内核中引入了新的调度程序--O(1),即大O表示法,简单说,它指不管输入有多大,调度程序都可以在恒定的时间内完成工作。这主要感谢静态时间片算法和正对每一处理器的运行队列。O(1)调度器在拥有数以十计的多处理器的环境下尚能表现近乎完美的性能和可扩展性,但是实践证明对于调度那些响应时间敏感的程序却先天不足(比如交互程序),O(1)对于大服务器的工作负载很理想,但是在很多交互程序要运行的桌面系统上则表现不佳。
在2.6内核开发初期,开发人员为了提高对交互程序调度性能,引入了新调度算法。其中最著名的是“反转楼梯最后期限调度算法”RSDL,该算法吸取了队列理论,将公平调度概念引入Linux调度程序,并且最终在2.6.23版本中替代了O(1)算法,它此刻被称为“完全公平调度算法”,CFS.
3.策略 :决定调度程序在何时让什么进程运行,策略往往就决定系统的整体印象,并且还要负责优化使用处理器时间。所以无论从那个方面看,它都是直观重要的。
(1)I/O消耗型和处理器消耗型的进程
I/O消耗型:指进程的大部分时间用来提交I/O请求或是等待I/O请求,这样的进程经常处于可运行状态,但通常只运行很短时间,在等待I/O时会阻塞。
处理器消耗型:进程大部分时间都在执行代码,除非被抢占,否则一直执行,没太多I/O需求。调度器不应该经常让他们执行,应尽量降低它们的调度频率,而延长其运行时间。
调度策略就是要在这两个矛盾中寻找平衡:进程响应迅速(响应时间短)和最大系统利用率(高吞吐量),为了满足这个需求,调度程序通常采用非常复杂的算法来决定最值得运行的进程投入运行。
(2)进程优先级
是一种根据进程的价值和其对处理器时间的需求来对进程进行分级的方法。通常做法是优先级高的先运行,低的后运行,相同优先级轮转执行,在某些系统中,优先级高的使用时间片也较长。
调度程序总是选择时间片未用尽而且优先级最高的进程运行。用户和系统都可以通过设置进程优先级来影响系统的调度。
Linux采用了两个不同的优先级范围。第一个是nice值(范围从-20~19),默认值是0,越大的nice值表示优先级越低,低nice值的进程可以获得更多的处理器时间。Linux中nice值代表时间片的比例.(ps –el可以查看系统进程,NI一列就是nice值)
第二种是实时优先级,其值可配,从0~99,越高的值表示优先级越高。
ps -eo state,uid,pid,ppid,rtprio,time,comm
(3)时间片
指进程在被抢占前所能持续运行的时间。时间片过长会导致系统对交互响应表现欠佳,时间片太短会明显增加进程切换带来的处理器耗时。IO消耗型不需要长的时间片,而处理器消耗型的进程希望越长越好(提高高速缓存命中率)。
Linux的CFS调度器没有直接分配时间片到进程,而是分配处理器的使用比。这样进程所获得的处理器时间其实是和系统负载密切相关的,这个比例进一步还会受进程nice值影响。具有更小nice值的进程会被赋予高权重,从而有用更多的处理器使用比。
多数系统中,是否将一个进程立刻投入运行,完全由进程优先级和是否拥有时间片决定的。而在Linux的CFS调度器,其抢占时机取决于新的可运行程序消耗了多少处理器使用比,如果消耗的使用比比当前进程小,则新进程立刻投入运行,否则推迟运行。
比如系统仅有文字处理和视频编码两个进程,nice值相同,分给的处理器使用比都是50%。文本编辑器大部分时间用于等待用户输入,因此肯定不会用到处理器的50%,而视频编码是有可能用到50%的。我们关心的是,当IO发生,文本编辑器被唤醒时,CFS发现文本编辑器运行的时间比视频编码器短的多,因为文本编辑器没有消耗掉承诺给它的50%处理器使用比,因此CFS立即让文本编辑器投入运行。
4.Linux调度算法
(1)调度器类
Linux调度器是以模块的方式提供的,这样可以让不同类型的进程可以有针对性地选择调度算法。这种模块化结构称为调度器类。,它允许可动态的添加的调度算法并存,调度属于自己范畴的进程。
每个调度器都有一个优先级,系统会按照优先级顺序遍历调度类,选出最高优先级的调度器类,然后选择下面要执行的进程。
CFS是一个针对普通进程的调度类,SCHED_NORMAL,还有实时调度类。
(2)传统UNIX进程调度
一般采用绝对的优先级和时间片,这会导致以下问题:
①nice值对应到绝对时间片,导致进程切换无法最优化
②相对的nice值,把进程的nice值减1,所带来的效果取决于nice初始值
③时间片会随定时器节拍改变
④对唤醒进程提升优先级,会留下玩弄调度器的后门(可以改变影响优先级)。
(3)CFS原理
CFS基于一个简单的理念:进程调度的效果应如同系统具备一个理想中的多任务处理器。在有n个进程的系统中,每个进程获得1/n处理器时间。
CFS在所有可运行总数基础上计算出一个进程应该运行多久。允许每个进程运行一段时间,循环轮转,选择运行最少的进程作为下一个运行进程。每个进程都按其权重在全部可运行进程中所占比例的“时间片”来运行。CFS为完美多任务中的无限小调度周期设立一个目标—“目标延迟”。每个进程时间片的最小粒度是1ms.
任何处理器进程所获得的处理器时间是由它自己和其他可运行进程的nice相对值决定的。CFS不是完美的公平,但是在几百个进程环境中可以体现出近乎完美的多任务。
5.Linux调度的实现
代码位于kernel/sched/fair.c
(1)时间记账
①所有调度器都必须对进程运行时间做记账,CFS不再有时间片概念,但是为确保每个进程只在公平分配给它的处理器时间运行,也会用以下实体机构来做时间记账,在<linux/sched.h>
struct sched_entity { struct load_weight load; /* for load-balancing */
struct rb_node run_node;
struct list_head group_node;
unsigned int on_rq;
u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime;
u64 prev_sum_exec_runtime;
…
};
这个结构体作为se成员,嵌入在进程描述符struct task_struct内
②虚拟运行时间
Vruntime所有可运行进程总数的被加权后的计算时间,单位是ns.其与定时器节拍无关。CFS用vruntime来记录一个程序到底运行了多长时间以及它还应该再运行多久。
记账功能在fair.c文件实现
static void update_curr(struct cfs_rq *cfs_rq) {
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_of(cfs_rq)->clock_task;
unsigned long delta_exec;
if (unlikely(!curr))
return;
/*
* Get the amount of time the current task was running
* since the last time we changed load (this cannot
* overflow on 32 bits):
*/
delta_exec = (unsigned long)(now - curr->exec_start);
if (!delta_exec)
return;
__update_curr(cfs_rq, curr, delta_exec);
curr->exec_start = now;
if (entity_is_task(curr)) {
struct task_struct *curtask = task_of(curr);
trace_sched_stat_runtime(curtask, delta_exec, curr->vruntime);
cpuacct_charge(curtask, delta_exec);
account_group_exec_runtime(curtask, delta_exec);
}
account_cfs_rq_runtime(cfs_rq, delta_exec);
}
update_curr()由系统定时器周期性调用,它计算了当前进程的执行时间(加权计算的)与vruntime相加。Vruntime可以准确地测量给定进程的运行时间,而且可知道谁应该是下一个被运行的进程。
(2)进程选择
CFS始终选择具有最小vruntime的进程来执行。
CFS用红黑树来组织可运行进程队列,vruntime值作为红黑树的键值,通过键值检索对应节点的速度与整个树的节点规模成指数比关系。
①挑选下一个任务
简单说,CFS运行rbtree树中最左边叶子节点所代表的进程,实现函数是
static struct sched_entity *__pick_next_entity(struct sched_entity *se) {
struct rb_node *next = rb_next(&se->run_node);
if (!next)
return NULL;
return rb_entry(next, struct sched_entity, run_node);
}
函数本身并不会遍历树找到最左边叶子节点,尽管有效查找叶子节点是红黑树的优势O(logn),更容易的做法是把最左侧叶子节点缓存起来。该函数返回值就是下一个运行的进程,若返回NULL,表示没有可运行进程,CFS调用器选择idle任务运行。
②向红黑树中添加进程
enqueue_entity()函数实现添加进程到rbtree,以及缓存最左边叶子节点,在进程变为可运行状态(被唤醒)或者通过fork()调用第一次创建进程时发生。
static void enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
/*
* Update the normalized vruntime before updating min_vruntime
* through callig update_curr().
*/
if (!(flags & ENQUEUE_WAKEUP) || (flags & ENQUEUE_WAKING))
se->vruntime += cfs_rq->min_vruntime;
/*
* Update run-time statistics of the 'current'.
*/
update_curr(cfs_rq);
enqueue_entity_load_avg(cfs_rq, se, flags & ENQUEUE_WAKEUP);
account_entity_enqueue(cfs_rq, se);
update_cfs_shares(cfs_rq);
if (flags & ENQUEUE_WAKEUP) {
place_entity(cfs_rq, se, 0);
enqueue_sleeper(cfs_rq, se);
}
update_stats_enqueue(cfs_rq, se);
check_spread(cfs_rq, se);
if (se != cfs_rq->curr)
__enqueue_entity(cfs_rq, se);
se->on_rq = 1;
if (cfs_rq->nr_running == 1) {
list_add_leaf_cfs_rq(cfs_rq);
check_enqueue_throttle(cfs_rq);
}
}
该函数更新运行时间和其他一些统计数据,然后调用__enqueue_entity()进行繁重的插入操作,把数据项真正插入到rbtree中。
③从树中删除进程
删除发生在进程堵塞(变为不可运行状态)或终止时。
static void dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int flags)
{
/*
* Update run-time statistics of the 'current'.
*/
update_curr(cfs_rq);
dequeue_entity_load_avg(cfs_rq, se, flags & DEQUEUE_SLEEP);
update_stats_dequeue(cfs_rq, se);
if (flags & DEQUEUE_SLEEP) {
#ifdef CONFIG_SCHEDSTATS
if (entity_is_task(se)) {
struct task_struct *tsk = task_of(se);
if (tsk->state & TASK_INTERRUPTIBLE)
se->statistics.sleep_start = rq_of(cfs_rq)->clock;
if (tsk->state & TASK_UNINTERRUPTIBLE)
se->statistics.block_start = rq_of(cfs_rq)->clock;
}
#endif
}
clear_buddies(cfs_rq, se);
if (se != cfs_rq->curr)
__dequeue_entity(cfs_rq, se);
se->on_rq = 0;
account_entity_dequeue(cfs_rq, se);
/*
* Normalize the entity after updating the min_vruntime because the
* update can refer to the ->curr item and we need to reflect this
* movement in our normalized position.
*/
if (!(flags & DEQUEUE_SLEEP))
se->vruntime -= cfs_rq->min_vruntime;
/* return excess runtime on last dequeue */
return_cfs_rq_runtime(cfs_rq);
update_min_vruntime(cfs_rq);
update_cfs_shares(cfs_rq);
}
rb_erase(),然后更新rb_leftmost缓存,如果删除的是最做左节点,要重新找到新的最左节点。
(3)调度器入口
调度器入口点是schedule()函数,调用pick_next_task(),以优先级为序,从最高优先级类开始,每个调度器类都实现了pick_next_task(),从第一个返回的非NULL值的类中选择下一个可运行进程。
(4)睡眠和唤醒
当休眠时,进程把自己标记成休眠状态,从可执行红黑树中移出,当如等待队列,然后调用schedule()选择了一个进程执行。
唤醒过程刚好相反:进程被设置为可执行状态,然后从等待队列中移到可执行红黑树中。
休眠分TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE ,两种状态的进程位于同一个等待队列上,等待某些事件,不能够运行。
①等待队列
休眠通过等待队列处理,等待队列是由等待某些事件发生的进程组成的简单链表.
DEFINE_WAIT(wait); Add_wait_queue(wait);
While(!condition){
Prepare_to_wait(&q,&wait,TASK_INTERRUPTIBLE);
If(signal_pending(current))
Schedule();
}
Finish_wait(&q,&wait);
②唤醒
通过wake_up()进行,唤醒指定等待队列的所有进程,把唤醒进程状态设置为TASK_RUNNING,调用enqueue_task()将此进程放入红黑树中,如果被唤醒进程优先级比当前正执行的优先级高,还要设置need_resched标志。
6.抢占和上下文切换
(1)上下文切换,也就是从一个执行进程切换到另一个可执行进程,在schedule()调用context_swtich()函数完成。
context_swtich()一是调用switch_mm(),把虚拟内存从上一个进程映射切换到新进程中。
二是调用swtich_to()把上一个进程的处理器状态新进程的处理器状态,包括保存、恢复栈信息和寄存器信息,还有其他任何与体系结构相关的信息。
内核提供了一个need_resched标志来表明是否需要重新调度,每个进程都包含一个need_resched。
(2)抢占
用户抢占:内核即将返回用户空间的时候,检查need_resched标志被设置,就会调用schedule()。包括:①从系统调用返回用户空间时,②从中断处理程序返回用户空间时③用户调用sleep()等主动让出。
内核抢占:只要没有持有锁,内核就可以进行抢占(thread_info里的preempt_count=0说明不持有锁)
发生时间点:①中断处理程序正在执行,且返回内核空间之前②内核代码再一次具有可抢占性的时候③内核进程显示的调用schedule()④内核进程阻塞。
7.实时调度策略
Linux提供了两种实时调度策略:SCHED_FIFO和SCHED_RR,普通的,非实时的调度策略是SCHED_NORMAL。实时策略不被CFS管理,由一个特殊的实时调度器管理。
SCHED_FIFO:实现了一个简单的,先入先出的算法,它不使用时间片,处于可运行状态的SCHED_FIFO会比任何SCHED_NORMAL的进程先得到调度,并且它会一直执行,直到执行完,但是有高优先级的SCHED_FIFO或SCHED_RR会立刻抢占。
SCHED_RR与SCHED_FIFO大体相同,是带有时间片的SCHED_FIFO—实时轮流调度算法。
Linux的实时调度算法提供了一种软实时工作方式:内核调度进程,尽力使进程在它的限定时间到来前运行,但内核不保证总能满足这些进程需求。而硬实时系统保证在一定条件下可以保证任何调度的需求。
实时优先级从0~MAX_RT_PRIO-1.默认是0~99.SCHED_NORMAL级进程的nice值共享这个取值空间,MAX_RT_PRIO~MAX_RT_PRIO+40.默认情况下nice从-20~19对应100~139的实时优先级范围。
8.与调度相关的系统调用
Linux提供了一个系统调用族,用于管理与调度程序相关的参数。
(1) 与调度策略有关的
sched_setscheduler()和sched_getscheduler()分别用于设置和获取进程的调度策略和实时优先级。
对于一个普通进程来说,nice()函数可以将给定进程的静态优先级增加一个给定的量。只有超级用户才能使用负值。
(2)与处理器绑定
sched_setaffinity()设置绑定处理器,
(3)放弃处理器
sched_yield()显示的将处理器时间让给其他进程,把自己移到过期队列,这样确保一段时间内它不会再被执行,由于实时进程不会过期,所以属于例外。