我们提到过进程状态,而进程调度主要是针对TASK_RUNNING运行状态进行调度,因为其他状态是不可执行比如睡眠,不需要调度。
1、进程调度概念
进程调度程序,简称调度程序,它是确保进程能有效工作的一个内核子系统。调度程序负责决定哪个进程投入运行,何时运行以及运行多长时间。
多任务
多任务系统划分为两类:非抢占式多任务(cooperative multitasking)和抢占式多任务(preemptive multitasking)。
非抢占式是一种协作的方式,一个进程一直执行直到任务结束或者主动退出才切换到下一个进程。
抢占式是大部分操作系统采用的方式,是指给每个进程分配一个时间片(time slice),当时运行时间达到规定的时间时则会切换到下一个进程。
2、调度策略
进程管理需要内核的各种动作和任务。它需要进程创建、执行线程、优先进程、多任务、调度进程、终止进程等。所有这些都是进程管理策略的一部分。
内核与进程相关的最重要部分是决定每个进程的持续时间,何时切换到下一个进程(Mauerer,2008)。
此外,当内核从一个进程切换到另一个进程时,它必须以某种方式确保另一个进程的执行环境与它上次退出进程资源时的环境相同。
进程调度器(稍后解释)负责根据调度器策略确定 CPU 如何分配时间。它与任务切换机制完全不同,任务切换机制只是任务之间的简单切换(Love,2010)。
上面提到的时间片策略是比较传统的方式,后面Linux系统进行了多次改进,比如O(1)算法和CFS等。那么改进的动机和依据是什么呢,我们来看看。
2.1 I/O 消耗型和 CPU 消耗型
进程根据资源使用可以分为这两大类。
I/O 消耗型:进程的大部分时间用来进行 I/O 的请求或者等待,比如键盘。这种类型的进程经常处于可以运行的状态,但是都只是运行一点点时间,绝大多数的时间都在处于阻塞(睡眠)的状态。
CPU 消耗型:进程的大部分时间用在执行代码上即CPU运算,比如开启 Matlab 做一个大型的运算。除非被抢占,否则它们可以一直运行,所以它们没有太多的 I/O 需求。调度策略往往是尽量降低他们的调度频率,而延长其运行时间。
当然这种划分不是绝对的,一般的应用程序同时包含两种行为。
所以调度策略通常需要在两个矛盾的目标中寻求平衡:进程响应迅速(响应时间短)和最大系统利用率(高吞吐量)。
Linux 系统为了提升响应的速度,倾向于优先调度 I/O 消耗型。
2.2 进程优先级
调度算法中最基本的一类就是基于优先级的调度,根据进程的价值(重要性)和对处理器时间的需求来对进程分级的想法。简单的说是优先级高的先运行,低的后运行。
Linux采用了两种不同的优先级范围。
(1)nice值
它的范围从-20到+19,默认值0。越大的nice值优先级越低,19优先级最低,-20优先级最高。ps -ef命令中,NI标记就是进程对应的nice值。
这是普通进程的优先级。
(2)实时优先级
范围是 0~99,与 nice 值相反,值越大优先级越高。
这是实时进程的优先级,相对普通进程的,所以任何实时进程的优先级都高于普通进程的优先级。
时间片是一个数值,它表明在抢占前所能持续运行的时间。调度策略必须规定一个默认的时间片,这并非易事。因为时间片过长I/O消耗型的线程得不到及时响应,而太短CPU消耗型的需要频繁被切换,吞吐量会下降。而最新的Linux调度策略CFS不采用固定的时间片,而是采用了处理器的使用比。我们接下来详细介绍。
3、调度算法
Linux调度器是以分类(模块化)的方式提供的,即对不同类型的进程进行分组并且分别选择相应的算法。
这种调度结构被称为调度器类(scheduler classes),它允许不同的可动态添加的调度算法并存,调度属于自己范畴的进程。
如下图Linux调度器包含了多种调度器类。
这些调度器类的优先级顺序为: Stop_Task > Real_Time > Fair > Idle_Task。
开发者可以根据己的设计需求把所属的Task配置到不同的scheduler classes中。其中的Real_Time和Fair是最常用的,也对应了我们上面提到的实时进程和普通进程。
3.1 完全公平调度
Fair调度使用的完全公平调度器(Completely Fair Scheduler,CFS)。
这是一个针对普通进程的调度类,在Linux中称为SCHED_NORMAL(在POSIX中称为SCHED_OTHER)。
传统的时间片方式是每个进程固定一个时间,那么当进程个数变化时,整个调度周期顺延。时间片还会跟着系统定时器节拍随时改变,那么整个周期再次跟着变化。那么优先级低的进程可能迟迟得不到调度。
而CFS把整个调度周期的时间固定,该周期叫目标延迟(target latency),也不再采用时间片,而是根据每个进程的nice值得到的权重再计算得到处理器比例,进而得到进程自己的时间。该时间和节拍没有任何关系,也可以精确到ns。例如“目标延迟”设置为20ms,2个进程各10毫秒,如果4个进程则是各5毫秒。如果100个进程呢,是不是就是0.2毫秒呢?
不一定,CFS引入了一个关键特性:最小粒度。即每个进程获得时间片的最小值,默认是1毫秒。
为了公平起见,CFS总是选择运行最少(vruntime)的进程作为下一个运行进程。所以这样照顾了I/O消耗型短时间处理的需求,也将更多时间留给了CPU消耗型的程序。确实解决了多进程环境下因延迟带来的不公平性。
vruntime虚拟实时
在 CFS 中,给每一个进程安排了一个虚拟时钟vruntime(virtual runtime),这个变量并非直接等于他的绝对运行时间,而是根据运行时间放大或者缩小一个比例,CFS使用这个vruntime 来代表一个进程的运行时间。如果一个进程得以执行,那么他的vruntime将不断增大,直到它没有执行。没有执行的进程的vruntime不变。调度器为了体现绝对的完全公平的调度原则,总是选择vruntime最小的进程,让其投入执行。他们被维护到一个以vruntime为顺序的红黑树rbtree中,每次去取最小的vruntime的进程(最左侧的叶子节点)来投入运行。实际运行时间到vruntime的计算公式为:
[ vruntime = 实际运行时间 * 1024 / 进程权重 ]
这里的1024代表nice值为0的进程权重。所有的进程都以nice为0的权重1024作为基准,计算自己的vruntime。
挑选的进程进行运行了,它运行多久?进程运行的时间是根据进程的权重进行分配。
[ 分配给进程的运行时间 = 调度周期 *(进程权重 / 所有进程权重之和) ]
虚拟运行时间是通过进程的实际运行时间和进程权重(weight)计算出来的。在CFS调度器中,将进程优先级这个概念弱化,而是强调进程的权重。一个进程的权重越大,则说明这个进程更需要运行,因此它的虚拟运行时间就越小,这样被调度的机会就越大。
关于nice和进程权重以及vruntime之间的计算方式非常复杂。有兴趣的可以在网上搜索或者看源码。
总之,nice对时间片的作用不再是算数加权,而是几何加权。
3.2 实时调度策略
实时调度策略分为两种:SCHED_FIFO 和 SCHED_RR。
这两种实时进程都比任何普通进程的优先级更高(SCHED_NORMAL),都会比他们更先得到调度。
SCHED_FIFO:一个这种类型的进程出于可执行的状态,就会一直执行,直到它自己被阻塞或者主动放弃 CPU;它不基于时间片,可以一直执行下去,只有更高优先级的SCHED_FIFO或者SCHED_RR才能抢占它的任务,如果有两个同样优先级的SCHED_FIFO任务,它们会轮流执行,其他低优先级的只有等它们变为不可执行状态,才有机会执行。
SCHED_RR:与SCHED_FIFO大致相同,只是SCHED_RR级的进程在耗尽事先分配给它的时间后就不能再执行了。所以SCHED_RR是带有时间片的SCHED_FIFO:一种实时轮流调度(Realtime Robin)算法。
上述两种实时算法实现的都是静态优先级。内核不为实时进程计算动态优先级,保证给定的优先级的实时进程总能够抢占比他优先级低的进程。
4、调度的实现
进程调度的主要入口点是函数schedule(),即实现进程切换的功能:选择哪个进程可以运行,何时投入运行。
该函数的核心是for()循环,它以优先级为序,从最高的优先级调度类开始,遍历所有的调度类。
进程状态可以分为可执行和不可执行,分别放入不同的结构中。可执行的进程放在红黑树中,而不可执行的放在等待队列。
一个进程可能在两种结构中不断移动。
比如读文件操作,在执行工作时,处在红黑树中,当读完时可能需要等待磁盘,这时会把自己标记成休眠状态,从红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。而当磁盘作业完成时,又会被唤醒,进程再次设置为可执行状态,然后从等待队列中移到红黑树中。
4.1 抢占与上下文切换
上下文切换,就是从一个可执行进程切换到另一个可执行进程,由context_switch()函数处理。每一个新的进程被选出来准备投入运行的时候,schedule()就会调用该函数。
自愿切换意味着进程需要等待某种资源,强制切换则与抢占(Preemption)有关。
抢占(Preemption)是指内核强行切换正在CPU上运行的进程,在抢占的过程中并不需要得到进程的配合,在随后的某个时刻被抢占的进程还可以恢复运行。发生抢占的原因主要有:进程的时间片用完了,或者优先级更高的进程来争夺CPU了。
抢占的过程分两步,第一步触发抢占,第二步执行抢占,这两步中间不一定是连续的,有些特殊情况下甚至会间隔相当长的时间:
- 触发抢占:给正在CPU上运行的当前进程设置一个请求重新调度的标志(TIF_NEED_RESCHED),仅此而已,此时进程并没有切换。
- 执行抢占:在随后的某个时刻,内核会检查TIF_NEED_RESCHED标志并调用schedule()执行抢占。
抢占只在某些特定的时机发生,这是内核的代码决定的。
(1)触发抢占的时机
每个进程都包含一个TIF_NEED_RESCHED标志,内核根据这个标志判断该进程是否应该被抢占,设置TIF_NEED_RESCHED标志就意味着触发抢占。
直接设置TIF_NEED_RESCHED标志的函数是set_tsk_need_resched();
触发抢占的函数是resched_task()。
TIF_NEED_RESCHED标志什么时候被设置呢?在以下时刻:
- 周期性的时钟中断
时钟中断处理函数会调用scheduler_tick(),这是调度器核心层(scheduler core)的函数,它通过调度类(scheduling class)的task_tick方法检查进程的时间片是否耗尽,如果耗尽则触发抢占。
- 唤醒进程的时候
当进程被唤醒的时候,如果优先级高于CPU上的当前进程,就会触发抢占。相应的内核代码中,try_to_wake_up()最终通过check_preempt_curr()检查是否触发抢占。
- 新进程创建的时候
如果新进程的优先级高于CPU上的当前进程,会触发抢占。相应的调度器核心层代码是sched_fork(),它再通过调度类的task_fork方法触发抢占。
- 进程修改nice值的时候
如果进程修改nice值导致优先级高于CPU上的当前进程,也会触发抢占。内核代码参见 set_user_nice()。
- 进行负载均衡的时候
在多CPU的系统上,进程调度器尽量使各个CPU之间的负载保持均衡,而负载均衡操作可能会需要触发抢占。
不同的调度类有不同的负载均衡算法,涉及的核心代码也不一样,比如CFS类在load_balance()中触发抢占;RT类的负载均衡基于overload,如果当前运行队列中的RT进程超过一个,就调用push_rt_task()把进程推给别的CPU,在这里会触发抢占。
(2)执行抢占的时机
触发抢占通过设置进程的TIF_NEED_RESCHED标志告诉调度器需要进行抢占操作了,但是真正执行抢占还要等内核代码发现这个标志才行,而内核代码只在设定的几个点上检查TIF_NEED_RESCHED标志,这也就是执行抢占的时机。
抢占如果发生在进程处于用户态的时候,称为User Preemption(用户态抢占);如果发生在进程处于内核态的时候,则称为Kernel Preemption(内核态抢占)。
执行User Preemption(用户态抢占)的时机
- 从系统调用(syscall)返回用户态时;
- 从中断处理程序返回用户态时;
执行Kernel Preemption(内核态抢占)的时机
Linux在2.6版本之后就支持内核抢占了,但是请注意,具体取决于内核编译时的选项:
- CONFIG_PREEMPT_NONE=y不允许内核抢占。这是SLES的默认选项。
- CONFIG_PREEMPT_VOLUNTARY=y在一些耗时较长的内核代码中主动调用cond_resched()让出CPU。这是RHEL的默认选项。
- CONFIG_PREEMPT=y允许完全内核抢占。
在 CONFIG_PREEMPT=y 的前提下,内核态抢占的时机是:
- 中断处理程序返回内核空间之前会检查TIF_NEED_RESCHED标志,如果置位则调用preempt_schedule_irq()执行抢占。preempt_schedule_irq()是对schedule()的包装。
- 当内核从non-preemptible(禁止抢占)状态变成preemptible(允许抢占)的时候;在preempt_enable()中,会最终调用preempt_schedule()来执行抢占。preempt_schedule()是对schedule()的包装。
“抢占”这一部分来自网上,条理比书上更清晰,但是和书上也稍有差别,大体一致,不影响整体理解。
参考资料:
《Linux内核设计与实现》原书第三版
进程管理和线程是Linux的核心。