进程调度
1、Linux调度算法
2.6.23内核版本开始采用以“公平调度” 为概念引入的调度程序——“完全公平调度算法”,简称CFS。
2、I/O消耗型和处理器消耗型的进程
I/O消耗型进程 大多用于交互,类似鼠标、键盘、用户界面应用程序之类的。
处理器消耗型 进程会把大多时间用于执行程序上。
还有一些程序可以同时处于I/O消耗型和处理器消耗型 ,例如sshkeygen和MATLAB。
调度策略通常要在两种矛盾的目标中间寻找平衡,Linux为了保证交互式应用和桌面系统的性能,会更倾向于优先调度I/O消耗型进程。
3、进程优先级
Linux采用了两种不同的优先级范围。一种是nice值,它的范围是从-20到19,默认值为0。数值越大,优先级越低。使用命令 “ps -el” ,在NL项可以查看进程的nice值。
第二种范围是实时优先级,可配置,默认情况下的变化范围从0~99,越高的实时优先级数值意味着进程的优先级更高。使用 “ps -eo state,uid,pid,ppid,rtprio,time,comm” ,RTPRIO项可以查看进程的实时优先级值,如果有进程显示 - ,则表示该进程不是实时进程。
4、时间片
时间片表明进程在被抢占前所能持续运行的时间。但是Linux的CFS调度器并没有直接分配时间片到进程,它是将处理器的使用比 划分给了进程。这样一来,进程所获得的处理器时间其实是和系统负载密切相关的。这个比例还会受进程nice值的影响,nice值将作为权重调整进程所使用的处理器时间使用比。具有更高nice的进程将被赋予低权重,从而丧失一小部分的处理器使用比;而具有更低nice值的进程则会被赋予高权重,从而抢得更多的处理器使用比。
Linux系统是抢占式的,新的CFS调度器,其抢占时机取决于新的可运行程序消耗了多少处理器使用比。如果小号的使用比比当前进程小,则新进程立刻投入运行,抢占当前进程。否则,推迟运行。
5、调度器类
Linux调度器是以模块方式提供的,这种模块化结构被称为调度器类(scheduler classes),允许多种不同的可动态添加的调度算法并存。每个调度器都有一个优先级,基础的调度器代码定义在kernle/sched.c文件中,它会按照优先级顺序遍历调度类,拥有一个可执行进程的最高优先级的调度器类胜出,然后选择需要执行的那一个程序。
完全公平调度(CFS)是一个针对普通进程的调度类,Linux中称为SCHED_NORMAL ,CFS算法定义在kernel/sched_fair.c 中。
6、Linux调度的实现
CFS调度算法的相关代码位于kernel/sched_fair.c 中。特别关注其四个组成部分:
- 时间记账
- 进程选择
- 调度器入口
- 睡眠和唤醒
6.1、时间记账
所有调度器都必须对进程运行时间做记账。
6.1.1调度器实体结构
CFS不再有时间片的概念,但是它必须维护每个进程运行的时间记账,因为需要确保每个进程只在公平分配给它的处理器时间内运行。
CFS使用调度器实体结构(定义在文件<linux.sched.h>的struct_sched_entity中)来追踪进程运行记账:
struct sched_entity {
struct load_weight load; /* for load-balancing */
struct rb_node run_node;
unsigned int on_rq;
u64 exec_start;
u64 sum_exec_runtime;
u64 vruntime;
u64 prev_sum_exec_runtime;
#ifdef CONFIG_SCHEDSTATS
u64 wait_start;
u64 wait_max;
u64 sleep_start;
u64 sleep_max;
s64 sum_sleep_runtime;
u64 block_start;
u64 block_max;
u64 exec_max;
u64 slice_max;
u64 nr_migrations;
u64 nr_migrations_cold;
u64 nr_failed_migrations_affine;
u64 nr_failed_migrations_running;
u64 nr_failed_migrations_hot;
u64 nr_forced_migrations;
u64 nr_forced2_migrations;
u64 nr_wakeups;
u64 nr_wakeups_sync;
u64 nr_wakeups_migrate;
u64 nr_wakeups_local;
u64 nr_wakeups_remote;
u64 nr_wakeups_affine;
u64 nr_wakeups_affine_attempts;
u64 nr_wakeups_passive;
u64 nr_wakeups_idle;
#endif
#ifdef CONFIG_FAIR_GROUP_SCHED
struct sched_entity *parent;
/* rq on which this entity is (to be) queued: */
struct cfs_rq *cfs_rq;
/* rq "owned" by this entity/group: */
struct cfs_rq *my_q;
#endif
};
调度器实体结构是一个名为se的成员变量,嵌入在进程描述符task_struct 内。
6.1.2、虚拟实时
vruntime变量存放进程的虚拟运行时间,该运行时间(花在运行上的时间和)的计算是进过了所有可运行进程总数的标准化(或者说是被加权的)。虚拟时间是以ns为单位的,所以vruntime和定时器节拍不再相关。
定义在kernel/sched_fair.c 文件中的update_curr() 函数实现了记账功能:
static void update_curr(struct cfs_rq *cfs_rq)
{
struct sched_entity *curr = cfs_rq->curr;
u64 now = rq_of(cfs_rq)->clock;
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);
__update_curr(cfs_rq, curr, delta_exec);
curr->exec_start = now;
if (entity_is_task(curr)) {
struct task_struct *curtask = task_of(curr);
cpuacct_charge(curtask, delta_exec);
update_curr() 中计算了当前进程的执行时间,并且将其存放在变量delta_exec中。然后又将执行时间传递给了 __update_curr() ,后者再根据当前可运行进程中总数对运行时间进行加权计算。最终将上述的权重值与当前运行进程的vruntime相加。
__update_curr() 函数如下所示:
/*
* Update the current task's runtime statistics. Skip current tasks that
* are not in our scheduling class.
*/
static inline void
__update_curr(struct cfs_rq *cfs_rq, struct sched_entity *curr,
unsigned long delta_exec)
{
unsigned long delta_exec_weighted;
u64 vruntime;
schedstat_set(curr->exec_max, max((u64)delta_exec, curr->exec_max));
curr->sum_exec_runtime += delta_exec;
schedstat_add(cfs_rq, exec_clock, delta_exec);
delta_exec_weighted = delta_exec;
if (unlikely(curr->load.weight != NICE_0_LOAD)) {
delta_exec_weighted = calc_delta_fair(delta_exec_weighted,
&curr->load);
}
curr->vruntime += delta_exec_weighted;
/*
* maintain cfs_rq->min_vruntime to be a monotonic increasing
* value tracking the leftmost vruntime in the tree.
*/
if (first_fair(cfs_rq)) {
vruntime = min_vruntime(curr->vruntime,
__pick_next_entity(cfs_rq)->vruntime);
} else
vruntime = curr->vruntime;
cfs_rq->min_vruntime =
max_vruntime(cfs_rq->min_vruntime, vruntime);
}
update_curr()是由系统定时器周期性调用的,无论是在进程处于可运行态,还是被阻塞处于不可运行态。vruntime可以准确地测量给定进程的运行时间,而且知道谁应该试下一个被运行的进程。
6.2、进程选择
CFS模型所追求的是“理想多任务处理器”,其中所有可运行进程的vruntime值将一致。为了更好地做到这一点,CFS试图利用一个简单地规则去均衡进程的虚拟运行时间,这也是CFS调度算法的核心:选择具有最小vruntime的任务。
CFS使用红黑树来组织可运行进程队列,并利用其迅速找到最小vrumtime值的进程。 红黑树是一种以树节点形式存储的数据,这些数据都会对应一个键值。可以通过这些键值来快速检索节点上的数据。(通过键值检索到对应节点的速度与整个树的节点规模成指数比关系)
6.2.1、挑选下一个任务
假设红黑树存储了系统中所有的可运行进程,节点的键值是可运行进程的虚拟运行时间。那么所有进程中vruntime最小的那个就在树的最左侧的叶子结点。因此,CFS的进程选择算法可以简单的理解为:运行rbtree树中最左边叶子结点所代表的那个进程。
代码中使用 __pick_next_entity() 函数实现,定义在kernel/sched_fair.c中:
static inline struct rb_node *first_fair(struct cfs_rq *cfs_rq)
{
return cfs_rq->rb_leftmost;
}
static struct sched_entity *__pick_next_entity(struct cfs_rq *cfs_rq)
{
return rb_entry(first_fair(cfs_rq), struct sched_entity, run_node);
}
6.2.2、向树中加入进程
在进程变为可运行态(被唤醒)或者通过fork()第一次创建进程时,CFS将进程加入rbtree中,以及缓存最左叶子结点。enqueue_entity() 函数实现了这一功能:
static void
enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int wakeup)
{
/*
* Update run-time statistics of the 'current'.
*/
update_curr(cfs_rq);
if (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);
account_entity_enqueue(cfs_rq, se);
}
更新运行时间和其他一些统计数据后,调用 __enqueue_entity() 进行繁重的插入操作:
/*
* Enqueue an entity into the rb-tree:
*/
static void __enqueue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
struct rb_node **link = &cfs_rq->tasks_timeline.rb_node;
struct rb_node *parent = NULL;
struct sched_entity *entry;
s64 key = entity_key(cfs_rq, se);
int leftmost = 1;
/*
* Find the right place in the rbtree:
*/
while (*link) {
parent = *link;
entry = rb_entry(parent, struct sched_entity, run_node);
/*
* We dont care about collisions. Nodes with
* the same key stay together.
*/
if (key < entity_key(cfs_rq, entry)) {
link = &parent->rb_left;
} else {
link = &parent->rb_right;
leftmost = 0;
}
}
/*
* Maintain a cache of leftmost tree entries (it is frequently
* used):
*/
if (leftmost)
cfs_rq->rb_leftmost = &se->run_node;
rb_link_node(&se->run_node, parent, link);
rb_insert_color(&se->run_node, &cfs_rq->tasks_timeline);
}
6.2.3、从树中删除进程
删除动作发生在进程堵塞(变为不可运行态)或者终止时(结束运行),代码中使用 dequeue_entity() 函数实现:
static void
dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se, int sleep)
{
/*
* Update run-time statistics of the 'current'.
*/
update_curr(cfs_rq);
update_stats_dequeue(cfs_rq, se);
if (sleep) {
#ifdef CONFIG_SCHEDSTATS
if (entity_is_task(se)) {
struct task_struct *tsk = task_of(se);
if (tsk->state & TASK_INTERRUPTIBLE)
se->sleep_start = rq_of(cfs_rq)->clock;
if (tsk->state & TASK_UNINTERRUPTIBLE)
se->block_start = rq_of(cfs_rq)->clock;
}
#endif
}
if (se != cfs_rq->curr)
__dequeue_entity(cfs_rq, se);
account_entity_dequeue(cfs_rq, se);
}
代码中主要调用了 __dequeue_entity() 函数完成删操作:
static void __dequeue_entity(struct cfs_rq *cfs_rq, struct sched_entity *se)
{
if (cfs_rq->rb_leftmost == &se->run_node)
cfs_rq->rb_leftmost = rb_next(&se->run_node);
rb_erase(&se->run_node, &cfs_rq->tasks_timeline);
}
6.3、 调度器入口
进程调度的主要入口点是函数 schedule() ,它定义在 kernel/sched.c文件中。schedule()会找到一个最高优先级的调度类,调度类需要有自己的可运行队列,然后schedule()函数才能知道下一个该运行的进程是哪个。
schedule() 函数唯一重要的事就是调用了 pick_next_task() ,pick_next_task() 会以优先级为序,从高到低,依次检查每一个调度类,并且从最高优先级的调度类中选择出最高优先级的进程:
asmlinkage void __sched schedule(void)
{
...
next = pick_next_task(rq, prev);
...
}
asmlinkage 作用是表明 schedule() 函数用堆栈来传递参数,不用寄存器传递。asmlinkage.
__sched 作用是把带有__sched的函数放到.sched.text段。.sched.text段中的函数貌似不会让函数在waiting channel中显示出来。
kernel有个waiting channel,如果用户空间的进程睡眠了,可以查到是停在内核空间哪个函数中等待的:
cat “/proc/<pid>/wchan”
/*
* Pick up the highest-prio task:
*/
static inline struct task_struct *
pick_next_task(struct rq *rq, struct task_struct *prev)
{
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;
}
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() 函数开头的语句rq->nr_running == rq->cfs.nr_running,用来加速选择CFS提供的进程,如果所有可运行进程数量等于CFS类对应的可运行进程数(说明所有的可运行进程都是CFS类的,CFS是普通进程的调度类)。
该函数的核心是for()循环,它以优先级为序,从最高的优先级类开始,遍历每一个调度类,每一个调度类都实现了 pick_next_task()函数,它会返回指向下一个可运行进程的指针,或者没有时返回NULL。
我们会从第一个返回非NULL值的类中选择下一个可运行的进程。如果是CFS的类,那么就会调用CFS(CFS的代码在kernel/sched_fair.c 中)的pick_next_task()函数, pick_next_task()会调用 pick_next_entity() ,pick_next_entity() 会再调用上面讲到的函数 __pick_next_entity 去选择下一个进程。
6.4、睡眠和等待
休眠时内核的操作如下:进程把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。
唤醒时的过程:进程被设置为可执行状态,然后再从等待队列中移到可执行红黑树中。
休眠有两种相关的进程状态:TASK_INTERRUPTIBLE和TASK_UNINTERRUPTIBLE。他们的唯一区别在于TASK_UNINTERRUPTIBLE的进程会忽略信号,而处于TASK_INTERRUPTIBLE的进程如果接收到一个信号,会被提前唤醒并相应该信号。两种状态灯的进程位于同一个等待队列上。
6.4.1、等待队列
等待队列是由等待某些事件发生的进程组成的简单链表。内核用 wake_queue_head_t 来代表等待队列。等待队列可以通过 DECLARE_WAITQUEUE() 静态创建,也可以由 init_waitqueue_head() 动态创建。
进程通过执行下面几个步骤将自己加入到一个等待队列中:
- 1)调用宏DEFINE_WAIT()创建一个等待队列的项/元素。
- 2)调用add_wait_queue()把自己加入到队列中。该队列会在进程等待的条件满足时唤醒它。在事件发生时,对等待队列执行wake_up()操作。
- 3)调用prepare_to_wait()方法将进程的状态更改为TASK_INTERRUPTIBLE或者TASK_UNINTERRUPTIBLE。
- 4)如果状态被设置为TASK_INTERRUPTIBLE,则信号唤醒进程。这列是伪唤醒,因此检查并处理信号。
- 5)当进程被唤醒的时候,在建检查’condition’是否为真。如果是,退出;如果不是,使用shcedule()进行调度。
- 6)当条件满足后,进程将自己设置为TASK_RUNNING并调用finish_wait()方法把自己移出等待队列。
6.4.2、唤醒
通过 wake_up() 可以唤醒指定的等待队列上的所有进程。 wake_up() 调用 try_to_wake_up(),该函数负责将进程设置为TASK_RUNNING状态。调用enqueue_task() 将进程放回红黑树中,如果被唤醒的进程优先级比当前正在执行的进程的优先级高,还需设置need_resched 标志。
内核需要知道在什么时候调用schedule() 。内核定义了一个 need_resched 标志来表明是否需要重新执行一次调度。
当某个进程需要被抢占时,scheduler_tick() 会设置这个标志;当一个优先级高的进程进入一个可执行状态的时候,try_to_wake_up() 也会设置这个标志。内核检查该标志,确认被设置,则会调用schedule() 来切换到一个新的进程。
在返回用户空间和中断返回的时候,内核也会检查 need_resched 标志,如果已经被置位,内核也会执行调度程序。
每个进程都包含一个 need_resched 标志,在2.6内核中,它被存放在 thread_info 结构中,用一个特别的标识变量中的一位来表示。
7、抢占和上下文切换
7.1、上下文切换
上下文切换由定义在 kernel/sched.c 中的 context_switch() 函数负责处理。每当一个新的进程被选出来准备投入运行的时候,schedule() 函数就会调用该函数。context_switch() 函数主要完成了以下两项工作:
- 调用声明在 <asm/mmu_context.h>中的switch_mm() 函数,该函数负责把虚拟内存从上一个进程映射切换到新进程中。
- 调用声明在 <asm/system.h>的switch_to() 函数,该函数负责从上一个进程的处理器状态切换到新进程的处理器状态。这包括保存、恢复栈信息和寄存器信息,还有其他和体系结构相关的状态信息,都必须以每个进程为对象进行管理和保存。
7.2、用户抢占
用户抢占发生在以下情况:
- 1)从系统调用返回用户空间时;
- 2)从中断处理函数返回用户空间时;
在这两种情况下,如果设置了need_resched 标志,就会发生抢占调度。
中断处理程序和系统调用返回的返回路径都是和体系结构相关的,在entry.S文件中通过汇编语言实现。此文件包含了内核入口部分代码和内核退出部分代码。
7.3、内核抢占
在2.6版本的内核中,只要重新调度是安全的,内核就可以在任一时间抢占正在执行的任务。只要没有持有锁,内核就认为是安全的,可以抢占。
在thread_info结构中引入了preempt_count计数器,计数器初始值为0,每使用一次锁+1,释放一次锁-1。当数值为0的时候,内核就可以抢占。
如果need_resched标志被设置,并且preempt_count不等于0,内核会继续执行该进程,直到持有的锁被释放,preempt_count 被设置为0。此时释放锁的代码会再次检查need_resched标志,如果标志被设置,则调用调度程序。
有些内核的代码需要允许或禁用内核抢占。
如果内核中的进程被阻塞,或者显示地调用了schedule() 函数,内核抢占也会显示执行。
内核抢占发生在:
- 1)中断处理程序正在执行,且返回内核空间之前;
- 2)内核代码再一次具有可抢占性的时候;
- 3)内核中的任务显示地调用了schedule() 函数;
- 4)内核中的任务阻塞。
8、实时调度策略
Linux提供了两种实时调度策略:SCHED_FIFO和SCHED_RR,普通调度策略是SCHED_NORMAL。实时调度策略不被“完全公平调度器管理”,而是被一个特殊的实时调度器管理,顶一个在kernel/sched_rt.c 中。
SCHED_FIFO 是一种简单的先入先出算法,不使用时间片。处于可执行状态的SCHED_FIFO进程会比SCHED_NORMAL进程优先得到调度。.SCHED_FIFO进程在执行时除非阻塞或者显示地释放处理器,不然会一直执行。高优先级的SCHED_FIFO进程或者SCHED_RR进程才可以抢占低优先级的SCHED_FIFO进程。
SCHED_RR进程是带有时间片的SCHED_FIFO进程。高优先级的SCHED_FIFO进程或者SCHED_RR进程才可以抢占低优先级的SCHED_RR进程。但是都优先级进程不能抢占SCHED_RR进程,及时其时间片已经耗尽。
两种实时调度算法都是静态优先级。保证高优先级能抢占低优先级。实时优先级范围为0~MAX_RT_PRIO减1,默认情况下MAX_RT_PRIO为100。SCHED_NORMAL 的nice值对应实时优先级的MAX_RT_PRIO~MAX_RT_PRIO+40。也就是nice值 -20 ~ +19对应100 ~ 140 的实时优先级。
9、与调度相关的系统调用
9.1、调度策略的系统调用
sched_setscheduler()和sched_getshceduler() 用于设置和获取进程的调度策略。主要工作在于读取和改写task_struct的policy和rt_priority的值。
9.2、优先级相关的系统调用
sched_setparam()和sched_getparam() 函数用户设置和获取实时优先级。主要是获取sched_param结构体(定义在<linux/sched.h>中)中的sched_priority成员。
nice() 函数可以将给定进程的静态优先级增加一定的量,只有超级用户才能使用负值,从而提高优先级。nice()函数会设置进程的task_struct结构的static_prio值和prio值。
9.3、与处理器绑定有关的系统调用
Linux调度程序提供强制的处理器绑定(processor affinity)机制。这种强制性的绑定保存在进程task_struct 的cpus_allowed 这个位掩码标志中。
9.4、放弃处理器时间
Linux通过sched_yield() 系统调用,提供了让进程主动放弃处理器时间。对于普通进程,在调用sched_yield() 后,不仅会将进程移动到优先级队列的最后面,还会将进程移动到过期队列中。而对于实时进程,只会讲进程移动到优先级队列的最后面。