Linux进程调度-linuxnote02

版权声明:本文为博主原创文章,未经博主允许不得转载。 https://blog.csdn.net/abm1993/article/details/82424317

linuxnote01中学习了Linux中的进程,本篇将要学习系统中调度程序是如何对进程进行调度。在多任务系统中可分为非抢占式多任务(coorperative)和抢占式多任务(preemptive),Linux采用的则是后者,这就意味着进程对CPU的占用时间是由操作系统决定的,具体的说则是由操作系统中的进程调度程序所决定的。对CPU访问的裁决过程被称为调度(scheduling)。(本篇及后续所有的Linux学习都基于”LINUX内核修炼之道任桥伟著“和”Linux内核设计与实现“这两本书,作为一个Linux的一个初学者,非常感激前人留下的宝贵经验。)

调度策略

进程调度策略,就好比古代战争的排兵布阵,对于不同的敌人,采取不一样的队列。因此在Linux进程调度中,我们也会对进程进行分类,对于不同类的进程,就要采取不一样的调度策略。一般将进程分为CPU消耗型和I/O消耗型。前者需要大量的CPU时间用于计算,后者则需要更多的时间等待I/O操作完成。还有一种分类方法把进程分为三种:交互式进程interactive(这类进程需要大量的人机交互,等待键盘和鼠标输入事件将其唤醒,此类进程对系统响应时间要求比较高,否则用户将会感觉到反映迟钝),批处理进程batch(这类进程不要与用户进行交互,因此常常在后台运行),实时进程real-time(该类进程对系统的反映时间有很高的要求,典型的就是音乐播放进程),通常将交互式进程和批处理进程称为普通进程。系统根据不同类型的进程,采取不同调度策略,如下列取了一些基本的调度策略:

策略标志 含义
SCHED_NORMAL 默认调度策略,针对普通进程,如果没有特殊指定,新建的进程都采用该策略
SCHED_FIFO 针对实时进程的先到先出调度,一旦占用CPU则一直运行,直到更高优先级的任务到达或者自己放弃。适用时间性要求比较高,但每次运行所需时间比较短的进程
SCHED_RR 针对实时进程的时间片轮转调度,进程的时间片用完之后,系统将重新分配时间片,并将其置于队列末尾,这样每个进程都可以执行一段时间。用于每次运行时间比较短的进程。
SCHED_BATCH 针对批处理进程
SCHED_IDLE 使用该策略的进程具有最低优先级

进程所采用的调度策略反映在进程描述符中的policy字段,一般进程创建时将会继承父进程的policy值,当然我们也可以通过系统调用sched_setscheduler来进行修改。系统在进行进程调度时会努力实现一些目标:高效,加强交互性能,保证公平,SMP调度(调度程序必须支持多处理系统,这就意味着系统必须追踪那些进程在哪些CPU上运行,确保同一时间任何一个给定的进程没有在多余一个CPU上运行),软实时调度(这就意味着系统能够有效地调度有严格时间要求的进程,实时进程被分配特定的调度策略以及比普通进程高的优先级,对应还有硬实时)。

有了策略之后,我们应该如何去实现调度算法呢?最基本的一类是基于优先级的调度,普通进程具有两种优先级:静态优先级和动态优先级,实时进程对应实时优先级。静态优先级之所以静态,因为它不随着时间改变而改变,内核不会主动去修改它,只能通过系统调用nice(nice值-20~19,一种能影响进程优先级的数字,nice值越小,对应的进程的优先级越大)去修改。静态优先级存储在进程描述符的static_prio域,它与nice的关系是:static_prio = MAX_RT_PRIO + nice + 20。MAX_RT_PRIO = 100。调度程序通过增加或减少进程静态优先级的值来奖励I/O消耗型进程或惩罚CPU消耗型进程,调整后的优先级即称之为动态优先级,存储在进程描述符的prio域,进程的动态优先级在0~MAX_PRIO-1(MAX_PRIO = 140)之间取值,其中0~MAX_RT_PRIO - 1对应实时进程,MAX_RT_PRIO~MAX_PRIO - 1对应普通进程,数值越大表示进程优先级越小。实时优先级只对实时进程有意义,存储在进程描述符的rt_priority域,取值范围0~MAX_RT_PRIO - 1,实时优先级与动态优先级成线性关系:prio = MAX_RT_PRIO - 1 - rt_priority。

最前面介绍Linux是系统是抢占式的,何谓抢占式。当一个进程进入可运行状态,它就允许被投入执行,在多数操作系统中,是否要将一个进程立刻投入运行(就是抢占当前进程),是完全由进程优先级和是否有时间片决定的。时间片就是一个数值,它表明进程在被抢占前所能持续运行的时间。

 调度算法实现-调度器

调度策略中大致概括了Linux中进程调度的原理,并没有真正提及理论运用实际中。Linux的发展过程中,进程调度也在不断的优化。早期Linux2.4内核采用的是最基本的基于优先级的调度器,对运行队列中的所有进程进行比较,选择优先级最高的进程。这种调度器具有如下特点:全局维护一个运行队列,调度器对它的所有操作必须使用自旋锁进行保护,这就导致了各个处理器之间的等待。另外,从运行队列中查找最佳进程与系统中处于TASK_RUNNING状态的进程数目有关,其耗时等级为O(n),可想而知,当运行队列中的进程数量较大时,查找的代价必然增大。时间片影响调度器调度,使用此调度器时,每个进程在创建的时候都会被赋于一个时间片time slice。时钟中断会递减当前进程的时间片,当当前进程时间片用完时,他必须等待重新赋予时间片才能有机会运行。只有当所有处于TASK_RUNNIN状态的进程时间片都被用完之后,才会对所有进程进行重新分配时间片。这样就保证了每个进程都有机会得到执行。进程剩余的时间片是除nice值之外对动态优先级影响最大的因素,对于休眠次数越多的进程,它的剩余时间片会不断增加,因而会获得更高的动态优先级(比如I/O消耗型进程)。再使用这种调度器的内核中,在内核态中运行任何进程,只有当它主动调用schedule()主动停止时,调度器才会有机会选择其他进程,因此这种内核是不支持内核抢占的,内核无法保证实时任务的及时响应。进程被切换下来之后,如果有CPU空闲或者某个CPU上运行的进程优先级比自己低,那么它就会被调度到那个CPU上运行,内核使用此方法实现负载均衡,虽然简单,但缺点也比较明显,进程迁移比较频繁,交互式进程(或者高优先级的进程)可能会在CPU之间不断的跳跃。

Linux2.6,O(1)调度器应运而生,由Ingo Molnar设计实现。相比之前的调度器,O(1)调度器每个CPU都维护一个自己的运行队列,运行队列由一个复杂的数据结构struct runqueue表示,这大大减少了CPU之间的竞争。其队列中包含两个优先级数组,一个活跃优先级数组(active,时间片没用完,可被调度的进程)和一个过期优先级数组(expired,时间片已经用完的进程)。每个优先级数组包含MAX_PRIO(140)个优先级队列,每一种优先级对应一个相应的队列中,数组前100个对应实时进程,后40个对应普通进程。当活跃数组中的一个进程用光时间片时,它就会被移动到过期数组中,移动过程中,会对其进行重新计算时间片。当活跃数组中所有的进程都耗光时间片的时候,active指针就会和expired进行互换。O(1)调度器选择下一个被调度进程的工作非常简单:它在活跃数组里优先级最高的队列中选择一个进程来执行。为了是这个过程更高,内核中使用了一个位图来定义给定优先级队列上是否存在可运行的进程,如果存在,则位图中相应的位置就会被设置为1。这样查找系统中最高优先级就变成了查找位图中被设置为1的第一位。比如查找的地一个被设置的位是位49,及当前所有可运行进程的最高优先级是49(此时优先级0~139),则调度器从active数组中得到优先级为49的优先级的指针,该队列中的第一个进程就是被选中的进程。这个查找过程并不依赖可运行进程的数目,因为优先级数目是一个给定的定值,所以查找时间恒定,复杂度为O(1)。相比之前的调度器,O(1)以时间片耗尽为标准将可运行进程分为活跃和过期两大类,分别对应着不同的可运行队列,前者相对于后者有绝对的调度优先权。在活跃数组中挑选进程时,调度器也不再将进程的剩余时间作为一个影响进程优先级的因素,时间片过长的交互式进程还会被人为成好几段,每一段运行结束之后,它就会从CPU上剥夺下来,放置到活跃数组中相应优先级队列的末尾,为其他相同优先级的进程提供运行的机会。没有自旋锁保护的内核任何代码段都有可能被中断执行,因此使用O(1)调度器的系统支持内核抢占

2004年Con Kolivas提出SD楼梯调度算法,主要用于提升Linux内核对交互式进程的及时响应能力。和O(1)一样,SD算法为每一个优先级维护一个可运行的进程队列,不同的是队列中移除了expired数组。当进程时间片耗尽的时候,由于不存在expired数组,它会被添加到active数组中低一级优先级队列中,当进程的时间片再次用完没就会再次被放入更低一级的优先级队列中,整个过程就像下楼梯,进程每次用完时间片就下降一级楼梯,因此称为楼梯算法。在SD算法中,进程的动态优先级最初被初始为它的静态优先级,并随着楼梯的下降而降级,与它所在的优先级队列的优先级保持一致。如果动态优先级最终降低到最低,如果时间片用完,就会回到初始优先级重复刚刚的过程。前面所说,它的出现主要用于提升Linux内核对交互式进程的及时响应能力,它是如何实现的呢?在SD算法中,存在两种时间片,一个粗粒度,一个细粒度,而粗力度又是由一定数量的细力度组成。由于粗粒度的时间片较长,相比于细粒度时间片,它停留在高优先级上的时间也就较长,但是进程的时间片也不会一次用完,也会像O(1)一样,被分成几段运行。对于交互式进程,当进程处于休眠状态时,与它同等优先级的进程将一步步下降,进入低优先级,当它被再次唤醒时,它仍处于高优先级,因而更快的被调度器选中,加速了响应能力。

Con Kolivas对SD算法又进行了一次优化,RSDL算法诞生(The Rotating Staircase Deadline Time)。expired数组重新被引入,并为每个优先级的进程队列都分配了一个”组时间配额“,标记tg,即优先级上所有可运行进程能够使用的总时间。同时,它为每个进程分配了一个”优先级时间配额“,标记tp,tp不等同时间片,而是小于时间片。当进程用完自己的tp时,就会降级,与SD相似,但是在RSDL中称这个过程为minor rotation。这个优化的好处是,在SD中,低优先级进程必须等待所有的高优先级进程执行完,才能有机会被调度,并且这个过程的等待时间无法确定,加入tg之后,无论对应队列中是否存在进程tp未用完,属于该队列的进程都会被强制进行minor rotation,如此一来,低优先级被调度的时间就能确定,从而进一步的改善公平性。这就是deadline的意义。当一个active组中的进程用光时间片之后,它就会被转移到expired数组。当active数组为空,或者所有的进程都降低到最低优先级时就会触发major rotation:交换active数组和expired数组的指针,所有的进程恢复到初始状态,再一次开始minor rotation过程。

虽然之间的调度器已经很不错,但是Ingo开发的CFS调度器,更加优秀,从2.6.23内核开始最终完全取代了之前的调度器。CFS在真实的硬件上模拟了完美理想的多任务处理器。在完美的多任务处理器下,每个进程都能同时获得CPU的执行时间。当系统中存在两个进程时,CPU的计算时间被分成两份,每个进程各获得50%。然而事实是,当一个进程占用CPU时,其他的进程就必须等待,这就产生了不公平,为此CFS引入了一个vitual time虚拟运行时间的概念。进程的虚拟运行时间和它的实际运行时间(不是实际占用CPU的时间)成正比,和它的权重(weight)成反比。进程的权重由优先级决定,而进程的优先级可以通过nice系统修改,因此我们可以通过修改nice,来修改进程的权重。进程的优先级越高对应的进程的权重就越大,因而高优先级进程和低优先级进程在实际运行时间相同时,高优先级进程的虚拟运行时间增长的就越小。CFS调度时,每次选择的就是虚拟运行时间越小的进程,如此以来,调度器在选择进程时,就没有优先级的参与,只有时间因素参与其中。原来的优先级数组也就不需要了,CFS采用的是红黑数对进程的虚拟运行时间就行排序,树节点的键值就是进程的虚拟运行时间,红黑树的左子树,全部保存,整个树中虚拟运行时间最小的进程。这样,CFS每次就只需要选择红黑树左下角的那个对应的进程就行。

重要数据结构解释

struct rq:纵观Linux调度算法的进化史,都离不开一个重要的数据结构运行队列(struct rq)每一个CPU都会对应存在一个rq,主要存储一些基本的用于调度的信息,包括实时调度的和CFS调度的。

struct sched_class:体现一个规范,提供最基本的调度器代码,Linux中有很多对其补充的调度器,比如sched_class fair_sched_class(CFS调度器),sched_class dl_sched_class(deadline调度器),sched_class idle_sched_class,sched_class rt_sched_class(real-time调度器)。

struct sched_entity:调度实体代表调度对象,该对象可以是一个进程也可以是一个进程组,用于记录调度相关的时间

struct cfs_rq:对应CFS调度器的运行队列

struct rt_rq:对应实时调度器的运行队列

进程抢占与切换

前面所说,Linux是抢占式多任务系统,是否要将一个进程立刻投入运行,就是一个抢占的动作。这个动作在Linux中由调度程序发起。根据抢占的时机,可以分用户抢占和内核抢占。每当抢占发生时,必然也会引起一次schedule()的执行,内核提供一个TIF_NEED_RESCHED标记来表明是否需要调用schedule()执行一次调度。如果当前进程结构thread_info的flag域被设置为了TIF_NEED_RESCHED,就表示需要执行schedule()进行重新调度。(schedule其实就是调度器的入口:它会选择合适的调度器,并根据调度器选择合适的进程)

用户抢占,内核在即将返回用户空间的时候,会检查进程是否设置了TIF_NEED_RESCHED标志,如果设置,在继续运行前会调用schedule(),此时就会发生用户抢占,用户抢占发生的时机有两个:从系统调用返回用户空间的时候,从中断处理程序返回用户空间的时候。

内核抢占,如果内核不支持内核抢占,内核态运行的任何进程,如果它不自己主动停止,就会一直执行到完成为止,这就无法保证实时任务的及时响应。在2.6之后的内核,支持了内核抢占,现在只要重新调度是安全的,内核就可以在任何时刻抢占当前正在执行的进程。什么时候重新调度才是安全的呢?只要没有加锁,内核就可以进行抢占。锁是非抢占区域的标志,由于内核是支持SMP的,如果没有持有锁,正在执行的代码就是可重新导入的,也就是可以抢占的。为了支持内核抢占,每个进程的thread_info结构体中加入了preempt_count计数器,计数器初始为0,每当使用锁的时候就+1,释放锁的时候就-1,当数值为0的时候,内核就可以抢占。从中断返回内核空间的时候,内核会检查flag域和preempt_count,如果这两个字段同时都能满足,就说明有一个更为重要的进程需要执行并且可以安全抢占,此时调度程序就会被调用。如果preempt_count不为0,说明当前进程持有锁,抢占是不安全的,这时,内核就会像通常那样直接从中断返回当前执行的进程,如果当前进程的锁都释放了,preempt_count计数器就会被设置为0,释放锁的代码会检查flag域是否设置TIF_NEED_RESCHED,如果设置的话,就会调用调度程序。如果内核中的进程被阻塞了,也会调用schedule(),内核抢占就会显式的发生,这种主动的调用,说明进程清楚自己的调用是安全的,因此抢占就是安全的。内核抢占的发生时机:中端处理程序正在执行且返回内核空间之前,内核代码再次具有可抢占性时(当前进程所持有的锁都已经被释放,preempt_count为0,内核再一次具有可抢占性,此时释放锁的代码就会检查TIF_NEED_RESCHED,根据检查结果决定是否调用schedule),内核中的进程显式的调用schedule(),内核中的进程被阻塞。

进程切换,也就是从一个可执行进程切换到另一个可执行进程的过程。也可以称为上下文切换。尽管每个进程都拥有自己的地址空间,但所有的进程都必须共享同样的CPU寄存器,因此在恢复一个进程运行之前,内核必须确保CPU的每个寄存器装入的是与该进程挂起时同样的值,进程恢复运行之前必须装入寄存器的一组数据就被称为硬件上下文。部分硬件上下文保存在进程描述符的struct thread_struct thread域,其余硬件上下文保存在内核栈中。进程切换的过程有context_switch完成,它只能被schedule()调用,context_switch完成了两个基本过程switch_mm进程页表切换,switch_to进行处理器状态切换。

疑惑点整理:页表是什么?用户空间?内核空间?如何从用户到内核,内核到用户转化?内核在即将返回用户空间的时候,处于用户空间还是内核空间,假如在内核空间,是怎么做到进程要返回用户空间的?

写在最后

本篇文章,根据”LINUX内核修炼之道任桥伟著“和”Linux内核设计与实现“这两本书,纯理论的介绍了Linux进程调度相关知识。了解了Linux进程调度的一些基本策略,不同的策略对应着不同的优先级的进程。进程的调度需要调度程序,对应不同的策略也有相应的调度器。可以简单的说不同的调度器就是实现不同的调度策略,实现对不同进程的调度。Linux在进行进程调度的时候将进程分为普通进程和实时进程,目前Linux内核对于这两种进程采取的便是CFS调度器和RT调度器,他们分别实现了SCHED_NORMAL/BATCH/IDLE ,SCHED_FIFO/_RR调度策略,但Linux中调度器并不止于此,还有dl,idel等等,他们都是对不同进程进行调度的补充。在讲解进程切换的时候,我们再次提及进程的地址空间和内核栈这些概念,仍然心存疑惑,当然又有很多新增的疑惑,不明白的地方,我们还是放一放。

猜你喜欢

转载自blog.csdn.net/abm1993/article/details/82424317