Linux内核之 进程调度
上一篇我们提到过进程状态,而进程调度主要是针对TASK_RUNNING运行状态进行调度,因为其他状态是不可执行比如睡眠,不需要调度。
1、进程调度概念
进程调度程序,简称调度程序,它是确保进程能有效工作的一个内核子系统。调度程序负责决定哪个进程投入运行,何时运行以及运行多长时间。
多任务
多任务操作系统是指能同时并发执行多个进程的操作系统。
多任务系统划分为两类:非抢占式多任务(cooperative multitasking)和抢占式多任务(preemptive multitasking)。
非抢占式是一种协作的方式,一个进程一直执行直到任务结束或者主动退出才切换到下一个进程。
抢占式是大部分操作系统采用的方式,是指给每个进程分配一个时间片(time slice),当时运行时间达到规定的时间时则会切换到下一个进程。
2、调度策略
上面提到的时间片策略是比较传统的方式,后面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内核设计与实现》原书第三版