现在的操作系统都是多任务的,就是能同时并发地交互执行多个进程。为了能让多个任务能同时在系统上更好的运行,需要一个管理程序来管理计算机上同时运行的各个任务,这个管理程序就是调度程序。
调度程序负责决定将哪个进程投入运行,何时运行以及运行多长时间。只有通过调度程序的合理调度,系统资源才能最大限度地发挥作用,多进程才会有并发执行的效果。调度是一个平衡的过程,一方面,它要保证各个运行的进程能够最大限度的使用CPU,就是尽量少的切换进程;另一方面,保证各个进程能公平的使用CPU资源,就是防止某个进程长时间独占CPU的情况。
调度实现原理
决定哪个进程运行以及运行多长时间和进程的优先级和时间片有关。
进程优先级
Linux 采用了两种不同的优先级范围。一种是用 nice 值,一种是实时优先级。
nice 值得范围是从 -20 ~ +19,默认值为0,越大的 nice 值意味着更低的优先级。
实时优先级的范围是从 0 ~ 99,与 nice 值得定义相反,越高的实时优先级数值意味着进程优先级越高。
一个进程不可能有两个优先级,此外实时优先级优先于 nice 值。
时间片
有了优先级,可以决定谁先运行。但是调度程序还须知道隔多久进行下次调度,也就是该进程运行多长时间。
于是有了时间片的概念。时间片是一个数值,它表明进程被抢占前所能持续运行的时间。就是进程从开始运行到被抢占的时间,时间片过长会导致系统对交互的响应表现欠佳,调度周期长,系统响应变慢,让人觉得系统无法并行执行应用程序,时间片过短则会明显增大进程切换带来的处理器耗时,就会有相当一部分系统时间用在进程切换上。
Linux 的CFS调度器(完全公平调度算法,在 2.6.23 内核版本中替代了 O(1) 调度算法)并没有直接分配时间片到进程,它是将处理器的使用比划分给了进程。这样一来,进程所获得的处理器时间其实是和系统负载密切相关的。这个比例进一步还会受到进程 nice 值得影响,nice 值作为权重将调整进程所使用的处理器时间比。具有更高nice值(更低优先级)的进程将被赋予低权重,从而丧失一小部分的处理器使用比;而具有更小nice值(更高优先级)的进程则会被赋予高权重,从而抢的更多的处理器使用比。Linux 中使用的新的 CFS 调度器,其抢占时机取决于新的可运行程序消耗了多少处理器时间比。如果消耗的使用比比当前进程小,则新进程立刻投入运行,抢占当前进程。否则,将推迟其运行。这也体现的CFS的承诺:尽量让所有进程能公平分享处理器。
公平调度
CFS 的出发点基于一个简单的理念:进程调度的效果应如同系统具备一个理想中的完美多任务处理器。这只是一个理想模型,为了尽可能的靠近这个模型,CFS 的做法是允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给每个进程时间片的做法,也就是说 CFS 完全摒弃时间片而是分配给进程一个处理器的比重,通过这种方式,CFS确保了进程调度中能有恒定的公平性,而将切换频率置于不断变动中。
CFS 在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠nice值来计算时间片。nice值在CFS中被作为进程获得的处理器运行比的权重。值得注意的是绝对的nice值并不会影响调整决策,只有相对差值才会影响处理器时间的分配比。就是任何进程所获得的处理器时间是由它自己和其他所有可运行进程nice值的相对差值决定的。
比如,不同nice值的两个进程,一个是默认nice值(0),另一个具有的nice值为5,其相对差值是5;如果两个进程一个的nice值为10,另一个为15,这两者的相对差值也为5。那么这两种情况下,两个进程分配的时间片是一样的。所以nice值对时间片的作用不再是算数加权,而是几何加权。
Linux 调度的实现
从这里开始探索CFS是如何得以实现的。其相关代码位于文件 kernel/sched_fair.c 中。
虚拟实时 vruntime
前面简单的介绍了CFS调度算法的思想,当一个进程占用CPU时,其他进程就必须等待,CFS为了实现公平,必须惩罚当前正在运行的进程,以使那些正在等待的进程下次被调度。即尽可能让所有进程公平的分享处理器。
具体实现时,CFS则是通过每个进程的虚拟运行时间(vruntime)来衡量哪个进程最值得被调度。虚拟运行时间(花在运行上的时间和)的计算是经过了所有可运行进程总数的标准化(被加权的)。同前面说的,CFS调度器中,弱化了进程优先级,强调进程的权重,一个进程的权重越大,则说明这个进程更需要运行,因此它的虚拟运行时间就越小,这样被调度的机会就越大。
内核给每个进程维护一个虚拟运行时间vruntime,每个进程运行一段时间后,虚拟运行时间增加。但是运行同样的实际时间每个进程增加的虚拟运行时间的数值是不一样的,比如nice为0的进程运行实际时间10,其虚拟运行时间增加10,nice值为19的进程运行10,其虚拟运行时间增加了1000。进程在其生命周期中,其虚拟运行时间一直都是在增加的,内核把虚拟运行时间看作是实际运行时间,CFS为了实现公平调度,就选择运行时间短的进程进行运行,所以内核在带哦度中总是选择虚拟运行时间最小的进程。
前面同样运行10,每个进程增加的vruntime是不一样的,这个增加的值与进程的优先级nice值有关,每个nice数值对应一个权重数值。
//kernel/sched.c
static const int prio_to_weight[40] = {
/* -20 */ 88761, 71755, 56483, 46273, 36291,
/* -15 */ 29154, 23254, 18705, 14949, 11916,
/* -10 */ 9548, 7620, 6100, 4904, 3906,
/* -5 */ 3121, 2501, 1991, 1586, 1277,
/* 0 */ 1024, 820, 655, 526, 423,
/* 5 */ 335, 272, 215, 172, 137,
/* 10 */ 110, 87, 70, 56, 45,
/* 15 */ 36, 29, 23, 18, 15,
};
每个进程的虚拟运行时间增加的时间量(增幅)和(NICE_0_LOAD/nice_n_weight)成正比:
进程的虚拟运行时间 = 实际运行时间 * (NICE_0_LOAD / nice_n_weight)
其中NICE_0_LOAD为1024,nice值为0的权重,nice_n_weight为nice值为n的进程的权重。结合前面可以看出nice值低(高优先级)的权重大,其虚拟运行时间增加的幅度小,也就是说实际运行同样时间,其虚拟运行时间增幅小,这样就需要更多的实际时间来累计其虚拟运行时间的增加,也就是实际运行时间就越长(被调度器所调度的机会就越大)。另一方面nice值高的,其虚拟运行时间增幅就越大,这样实际运行时间就比较短,但也有运行机会。
由于内核把虚拟运行时间看作是实际运行时间,nice值高的(低优先级)的进程在内核看来运行了较长时间,CFS将惩罚该进程,使其他进程能够在下次调度时尽可能的取代该进程,最终实现所有进程的公平调度。
时间记账
所有调度器都必须对进程运行时间记账,因为需要确保每个进程只在公平分配给它的处理器时间内运行。CFS使用调度器实体结构(struct_sched_entity)来追踪进程运行进账
//kernel/sched.c
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;
u64 last_wakeup;
u64 avg_overlap;
u64 nr_migrations;
u64 start_runtime;
u64 avg_wakeup;
u64 avg_running;
……
};
该实体结构作为一个名为 se 的成员变量,嵌入在进程描述符 struct task_struct 内。
CFS 使用 vruntime 变量来记录一个进程到底运行了多长时间以及它还应该再运行多久。下面的 update_curr() 函数来实现记账功能:
//kernel/sched_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()
__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);
}
}
/*
* Update the current task's runtime statistics. Skip current tasks that
* are not in our scheduling class.
* defined in kernel/sched_fair.c
*/
static inline void
__update_curr(struct cfs_rq *cfs_rq, struct sched_entity *curr, unsigned long delta_exec)
{
unsigned long delta_exec_weighted;
schedstat_set(curr->exec_max, max((u64)delta_exec, curr->exec_max));
//当前进程已经运行的总时间,消耗的CPU
curr->sum_exec_runtime += delta_exec;
//将delta_exec加到队列结构的exec_clock中
schedstat_add(cfs_rq, exec_clock, delta_exec);
//delta_exec对应的虚拟时间的计算(公式见前面)
delta_exec_weighted = calc_delta_fair(delta_exec, curr);
curr->vruntime += delta_exec_weighted; //更新当前进程的 vruntime
update_min_vruntime(cfs_rq); //更新最小的 vruntime
}
值得注意的是,update_curr() 是由系统定时器周期性调用的,不是由某个进程调用的。无论是在进程处于可运行态,还是被堵塞处于不可运行态,根据这种方式,vruntime 可以准确地测量给定进程的运行时间,而且可知道谁应该是下一个被运行的进程。进程选择
前面已经介绍过:选择最小 vruntime 的任务。CFS 是使用红黑树(节点键值便是 vruntime)来组织可运行进程队列,并利用其迅速找到最小 vruntime 的进程。
//获取最小 vruntime 的任务
static struct sched_entity *__pick_next_entity(struct cfs_rq *cfs_rq)
{
//最左叶子节点已经缓存在 rb_leftmost 字段中
struct rb_node *left = cfs_rq->rb_leftmost;
if (!left)
return NULL;
//位移获得 sched_entity* (内核链表)
return rb_entry(left, struct sched_entity, run_node);
}
进程调度的主要入口点是 schedule() 函数,该函数会调用 pick_next_task(),选择最高优先级的进程:
/*
* 以优先级为序,选择最高优先级的进程
*/
static inline struct task_struct * pick_next_task(struct rq *rq)
{
const struct sched_class *class;
struct task_struct *p;
/*
* Optimization: we know that if all tasks are in
* the fair class we can call that function directly:
*/
if (likely(rq->nr_running == rq->cfs.nr_running)) {
p = fair_sched_class.pick_next_task(rq);
if (likely(p))
return p;
}
/*从最高优先级类开始遍历每个调度器类,从第一个非NULL值的类中选择下一个可运行进程*/
class = sched_class_highest;
for (;;) {
p = class->pick_next_task(rq);
if (p)
return p;
/*
* Will never be NULL as the idle class always
* returns a non-NULL p:
*/
class = class->next;
}
}
上面:pick_next_task() -> pick_next_entity() -> __pick_next_entity()。可运行进程的添加与删除
由于系统中所有的可运行进程都存储在一个红黑树结构中,所以可运行态进程的添加和删除等同于红黑树节点的添加与删除,不同的是这里还需要额外更新 rb_leftmost 缓存。这里的添加是指进程变为可运行状态(被唤醒)以及通过 fork() 调用第一次创建进程时。删除是指进程堵塞(变为不可运行态)或者终止时(结束进程)。
参考资料:《Linux 内核设计与实现》