进程调度
1. 多任务
多任务系统分为非抢占式多任务和抢占式多任务。抢占式多任务由调度程序来决定什么时候停止一个进程的运行。这个强制的挂起动作叫做抢占(preemption)。进程在被抢占之前能够运行的时间是预先设置好的,叫进程的时间片。 时间片实际上就是分配给每个可运行进程的处理器时间段;在非抢占式多任务模式下,除非进程自己主动停止运行,否则会一直执行。进程主动挂起自己称为让步(yielding)。
2. Linux的进程调度
进程调度程序(简称调度程序)可看做在可运行态进程之间分配有限的处理器时间资源的内核子系统。在Linux 2.5开发系列的内核中采用了O(1)调度程序。该调度程序对于大服务器的工作负载很理想,但是对于调度那些响应时间敏感的交互进程有不足。为了提高对交互程序的调度性能引入反转楼梯最后期限调度算法(RSDL),该算法吸取了队列理论,在2.6.23内核版本中替代了O(1)调度算法,被称为完全公平调度算法,简称CFS。
3. 策略
(1) I/O消耗型和处理器消耗型的进程
-
进程分为I/O消耗型和处理器消耗型。I/O消耗型进程的大部分时间用来提交I/O请求或是等待I/O请求。处理器耗费型进程把时间大多用在执行代码上。除非被抢占,否则通常都一直不停地运行。对于处理器消耗型的进程,调度策略往往是降低调度频率而延长运行时间。
-
进程可以同时展示这两种行为,比如,X Window服务器既是I/O消耗型,也是处理器消耗型。有些进程是I/O消耗型,但属于处理器消耗型活动的范围,比如字处理器。
(2) 进程优先级
调度程序总是选择时间片未用尽而且优先级最高的进程运行。用户和系统都可以通过设置进程的优先级来影响系统的调度。Linux采用了两种不同的优先级范围:
-
第一种是用nice值,它的范围是从-20到+19,默认值为0。越大的nice值意味着更低的优先级。低nice值(高优先级)的进程可以获得更多的处理器时间。通过
ps-el
命令查看系统中的进程列表,标记Nl的一列就是进程对应的nice值。 -
第二种范围是实时优先级,其值是可配置的,默认情况下它的变化范围是从0到99 (包括0和99)。越高的实时优先级数值意味着进程优先级越高。任何实时进程的优先级都高于普通的进程,实时优先级和nice优先级互不相交。通过命令:
ps-eo state,uid,pid,ppid,rtprio,time,comm.
可查看到系统中的进程列表以及它们对应的实时优先级(位于RTPRIO列下),其中如果有进程对应列显示“-”,则说明它不是实时进程。
(3) 时间片
-
时间片是一个数值,它表明进程在被抢占前所能持续运行的时间。调度策略规定一个默认的很短时间片。I/O消耗型不需要长的时间片,而处理器消耗型的进程则越长越好(比如可以让它们的高速缓存命中率更高)。
-
Linux的CFS调度器没有直接分配时间片到进程,它将处理器的使用比划分给了进程,nice值作为权重将调整进程所使用的处理器时间使用比。更小nice值(更高优先级)的进程会被赋予高权重,从而抢得更多的处理器使用比。抢占时机取决于新的可运行程序消耗多少处理器使用比。如果消耗的使用比比当前进程小,则新进程抢占当前进程。
4. Linux调度算法
(1) 调度器类
Linux调度器是以模块方式提供的,这种模块化结构被称为调度器类,它允许多种不同的可动态添加的调度算法并存,调度属于自己范畴的进程。每个调度器都有一个优先级,基础的调度器代码定义在kernel/sched.c文件中,它会按照优先级顺序遍历调度类。CFS是一个针对普通进程的调度类,在Linux中称为SCHED_NORMAL,CFS算法实现定义在文件kernel/sched_fair.c中。
(2) 公平调度
- CFS不采用分配给每个进程时间片的做法,每个进程都按其权重在全部可运行进程中所占比例的“时间片”来运行。
- CFS为完美多任务中的无限小调度周期的近似值设立了一个目标,称作“目标延迟”。CFS引入每个进程获得的时间片底线,称为最小粒度,默认1ms。CFS并非一个完美的公平调度,因为处理器时间片再小也无法突破最小粒度。
- 任何进程所获得的处理器时间是由它自己和其他所有可运行进程nice值的相对差值决定的。nice值对时间片的作用是几何加权。nice值对应的绝对时间是处理器的使用比。
5. Linux调度的实现
(1) 时间记账
所有的调度器都必须对进程运行时间做记账。当每次系统时钟节拍发生时,时间片都会被减少一个节拍周期,减少到0时,它就会被另一个可运行进程抢占。
-
调度器实体结构
CFS使用调度器实体结构(定义在文件<linux/sched.h>的
struct_sched_entity
中)来追踪进程运行记账:
struct sched_entity {
struct load_weight load;
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;
/* 这里省略了很多统计变量,只有在设置了CONFIG_SCHEDSTATS时才启用这些变量 */
};
调度器实体结构作为一个名为se
的成员变量,嵌入在进程描述符struct task_struct
内。
-
虚拟实时
vruntime
变量存放进程的虚拟运行时间,该运行时间是被加权的,虚拟时间以ns为单位,所以vruntime
和定时器节拍不相关。定义在kernel/sched_fair.c文件中的
update_curr()
计算了当前进程的执行时间,并将其存放在变量delta_exec
中,然后它又将运行时间传递给了__update_curr()
,由后者对运行时间进行加权计算,最终将权重值与vruntime
相加。
(2) 进程选择
-
挑选下一个任务
CFS的核心就是选择具有最小
vruntime
的任务。CFS可简单总结为“运行rbtree树中最左边叶子节点所代表的那个进程”。实现函数是__pick_next_entity()
,定义在文件kernel/sched_fair.c中:
static struct sched_entity *__pick_next_entity(struct cfs_rq *cfs_rq)
{
struct rb_node *left = cfs_rq->rb_leftmost;
if (!left)
return NULL;
return rb_entry(left, struct sched_entity, run_node);
}
-
向树中加入进程
将进程加入rbtree中发生在进程变为可运行状态(被唤醒)或者是通过
fork()
调用第一次创建进程时,enqueue_entity()
函数更新运行时间和其他一些统计数据,然后调用__enqueue_entity()
把数据项插入到树中:
/*把一个调度实体插入红黑树中*/
static void __enqueue_entity(struct cfs_rq *cfs_xq, 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;
/*在红黑树中查找合适的位置*/
while (*link) {
parent = *link;
entry = rb_entry(parent, struct sched_entity, run_node);
/*
*我们并不关心冲突。具有相同键值的节点呆在一起
*/
if (key < entity_key(cfs_rq, entry)) {
link = &parent->rb_left;
} else {
link = &parent->rb_right;
leftmost = 0;
}
}
/*
*维护一个缓存,其中存放树最左叶子节点(也就是最常使用的)
*/
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);
)
函数中遍历树寻找vruntime
;leftmost
维持1说明有个新的最左节点,可以更新缓存——设置rb_leftmost
指向被插入的进程;退出循环后在父节点上调用rtb_link_node()
使得新插入的进程成为其子节点;最后rb_insert_color()
更新树的自平衡相关属性。
-
从树中删除进程
删除发生在进程堵塞或者终止时,由辅助函数
__dequeue_entity()
完成。该函数调用rb_erase()
删除进程,更新rb_leftmost
缓存。如果要删除进程的最左节点,就调用rb_next()
按顺序遍历,找到新的最左节点。
(3) 调度器入口
进程调度的主要入口点是函数schedule()
,定义在kernel/sched.c中,它调用pick_next_task
()(也定义在kerne/sched.c中)。pick_next_task()
以优先级从高到低依次检查每一个调度类,并从最高优先级的调度类中,选择最高优先级的进程:
/*
* 挑选最高优先级的任务
*/
static inline struct task_struct *
pick_next_task(struct rq *rq)
{
const struct sched_class *class;
struct task_struct *p;
/*
*优化:我们知道如果所有任务都在公平类中,那么我们就可以直接调用那个函数
*/
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;
/*
*永不会为NULL,因为idle类总会返回非NULL的P
*/
class = class->next;
}
}
CFS是普通进程的调度类,而系统运行的绝大多数进程都是普通进程,因此该函数开始部分做了优化,用来加速选择下一个CFS提供的进程,前提是所有可运行进程数量等于CFS类对应的可运行进程数,即所有的可运行进程都是CFS类的。
(4) 睡眠和唤醒
休眠有两种进程状态: TASK_INTERRUPTIBLE
和TASK_UNINTERRUPTIBLE
,两种状态的进程位于同一个等待队列上。进程把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()
选择和执行一个其他进程;唤醒过程相反:进程被设置为可执行状态,然后从等待队列中移到可执行红黑树中。
-
等待队列
等待队列是由等待某些事件发生的进程组成的简单链表。内核用
wake_queue_head_t
来代表等待队列。等待队列可以通过DECLARE_WAITQUEUE()
静态创建,也可以由init_waitqueue_head()
动态创建。
/* 'q' 是我们希望休眠的等待队列 */
DEFINE_WAIT(wait);
add_wait_queue(q, &wait);
while (!condition) { /* 'condition' 是我们在等待的事件 */
prepare_to_wait(&q, &wait, TASK_INTERRUPTIBLE);
if(signal_pending(current))
/*处理信号*/
schedule();
}
finish_wait(&q, &wait);
进程通过执行下面几个步骤将自己加入到一个等待队列中:
a. 调用宏DEFINE_WAIT()
创建一个等待队列的项。
b. 调用add_wait_queue()
把自己加入到队列中。
c. 调用prepare_to_wait()
变更进程状态,有必要的话该函数会将进程加回到等待队列。
d. 如果状态被设置为TASK_INTERRUPTIBLE
,则信号唤醒进程,检查并处理信号。这是伪唤醒(唤醒不是因为事件的发生)。
e. 进程被唤醒的时,它会再次检查条件是否为真。如果是就退出循环;不是就再次调用schedule()
并重复这步操作。
f. 当条件满足后,进程将自己设置为TASK_RUNNING
并调用finish_wait()
方法把自己移出等待队列。
-
唤醒
唤醒通过函数
wake_up()
进行,它唤醒指定的等待队列上的所有进程。它调用函数try_to_wake_up()
,该函数将进程设置为TASK_RUNNING
状态,调用enqueue_task()
将此进程放入红黑树中。如果被唤醒的进程优先级比当前正在执行的进程的优先级高,要设置need_resched
标志。需要注意的是,存在虚假的唤醒,有时候进程被唤醒并不是因为它所等待的条件达成了才需要用一个循环处理来保证它等待的条件真正达成。
6. 抢占和上下文切换
-
上下文切换就是从一个可执行进程切换到另一个可执行进程,由定义在kernel/sched.c中的
context_switch()
处理。每当一个新的进程被选出来准备投入运行的时候,schedule()
就会调用该函数,它完成两项基本工作:a. 调用声明在<asm/mmu_context.h> 中的
switch_mm()
,该函数负责把虚拟内存从上一个进程映射切换到新进程中。b. 调用声明在<asm/system.h>中的
switch_to()
,该函数负责从上一个进程的处理器状态切换到新进程的处理器状态。包括保存、恢复栈信息和寄存器信息,还有其他与体系结构相关的状态信息。 -
内核提供了一个
need_resched
标志来表明是否需要重新执行一次调度(见表4-1)。当某个进程应该被抢占时,scheduler_tick()
就会设置这个标志;当一个优先级高的进程进入可执行状态的时候,try_to_wake_up()
会设置这个标志,内核检查该标志确认其被设置后,调用schedule()
切换到一个新的进程。该标志表示有其他进程应当被运行了,要尽快调用调度程序。再返回用户空间以及从中断返回的时候,内核也会检查need_resched
标志,如果已被设置,内核会在继续执行之前调用调度程序。 -
每个进程都包含一个
need_resched
标志,因为访问进程描述符内的数值要比访问一个全局变量快(因为current
宏速度很快并且描述符通常都在高速缓存中)。
(1) 用户抢占
用户抢占发生在从系统调用或者从中断处理程序返回用户空间时。在中断处理程序或者在系统调用后返回时,内核会检查need_resched
标志。如果它被设置了,内核会选择一个其他进程投入运行。从中断处理程序或系统调用返回的返回路径都是跟体系结构相关的,在entry.S中实现,此文件不仅包含内核入口部分的程序,还包含内核退出部分的相关代码。
(2) 内核抢占
-
与其他大部分操作系统不同,Linux支持内核抢占,只要重新调度是安全的,内核就可以抢占正在执行的任务。锁是非抢占区域的标志,如果没有持有锁,正在执行的代码就是可重新导入的,也就是可以抢占的。
-
每个进程的
thread_info
都有一个preempt_count
计数器,初始值为0,每当使用锁的时候加1,释放锁的时候减1。当值为0的时候,内核就可执行抢占。 -
从中断返回内核空间时,内核会检查
need_resched
和preempt_count
的值。如果need_resched
被设置且preempt_count
为0,调度程序就会被调用;如果preempt_count
不为0,内核就会直接返回当前执行进程。如果当前进程持有的锁都被释放了,preempt_count
重新为0,此时,释放锁的代码会检查need_resched
是否被设置,若是,就调用调度程序。如果内核中的进程被阻塞了,或它显式地调用了schedule()
,内核抢占也会显式地发生。有些内核代码需要允许或禁止内核抢占。
7. 实时调度策略
-
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_RR任务耗尽它的时间片时,在同一优先级的其他实时进程被轮流调度,时间片只用来重新调度同一优先级的进程。
-
实时优先级范围从0到MAX_RT_PRIO减1。默认MAX_RT_PRIO为100。SCHED_NORMAL级进程的nice值共享了这个取值空间;取值范围从MAX_RT_PRIO到(MAX_RT_PRIO+40),就是说nice值从-20到+19直接对应的是从100到139的实时优先级范围。
8. 与调度相关的系统调用
Linux提供了一个系统调用族,用于管理与调度程序相关的参数。这些系统调用可以用来操作和处理进程优先级、调度策略及处理器绑定,同时还提供了显式地将处理器交给其他进程的机制。
(1) 与调度策略和优先级相关的系统调用
-
sched_setscheduler()
和sched_getscheduler()
分别用于设置和获取进程的调度策略和实时优先级,最重要的工作在于读取或改写进程tast_struct
的policy
和rt_priority
的值;sched_setparam()
和sched_getparam()
分别用于设置和获取进程的实时优先级。这两个系统调用获取封装在sched_param
特殊结构体的rt_priority
中。sched_get_priority_max()
和sched_get_priority_min()
分别用于返回给定调度策略的最大和最小优先级。实时调度策略的最大优先级是MAX_USER_RT_PRIO减1,最小优先级等于1。 -
对于普通进程,
nice()
函数可以将给定进程的静态优先级增加一个给定的量。只有超级用户才能在调用它时使用负值来提高进程的优先级。nice()
函数会调用内核的set_user_nice()
函数,这个函数会设置进程的task_struct
的static_prio
和prio
值。
(2) 与处理器绑定有关的系统调用
-
Linux调度程序提供强制的处理器绑定机制。这种强制的亲和性保存在进程
task_struct
的cpus_allowed
这个位掩码标志中。该掩码标志的每一位对应一个系统可用的处理器。默认情况下,所有的位都被设置,进程可以在系统中所有可用的处理器上执行。用户可以通过sched_setafnity()
设置不同的位掩码,调用sched_getainity()
则返回当前的cpus_allowed
位掩码。 -
当处理进行第一次创建时, 它继承了其父进程的相关掩码,由于父进程运行在指定处理器上,子进程也运行在相应处理器上;当处理器绑定关系改变时,内核会采用“移植线程"把任务推到合法的处理器上;最后,加载平衡器只把任务拉到允许的处理器上,进程只运行在指定处理器上,对处理器的指定是由该进程描述符的
cpus_allowed
域设置的。
(3) 放弃处理器时间
-
Linux通过
sched_yield()
系统调用提供了一种让进程显式地将处理器时间让给其他等待执行进程的机制。它是通过将进程从活动队列中移到过期队列中实现的。由此产生的效果不仅抢占了该进程并将其放入优先级队列的最后面,还将其放入过期队列中(实时进程例外,它不会过期)。 -
内核代码为了方便,可以直接调用
yield()
,先要确定给定进程确实处于可执行状态,然后再调用sched_yield()
;用户空间的应用程序可以直接使用sched_yield()
系统调用。