Linux内核----进程调度

       进程调度是指将哪个进程投入运行,何时运行以及运行多长时间。进程调度程序可看做在可运行态之间分配有限的处理器时间资源的内核子系统。

相关概念

  1. 非抢占式多任务和抢占式多任务:Linux提供了抢占式的多任务,在此模式下由调度程序来决定什么时候停止一个进程的运行,以便其他进程能够得到执行的机会。这个强制的挂起动作叫做抢占。
  2. 时间片:实际上就是分配给每个可运行进程的处理器时间段。Linux独一无二的“公平”调度本身并没有采取时间片来达到公平调度。

策略

策略决定调度程序在何时让什么进程运行

  • 进程的分类:(1)、I/O消耗型。(2)、处理器消耗型
  1. I/O消耗型:这类进程大部分时间用来提交I/O请求或是在等待I/O请求。因此,这样的进程经常处于可运行状态,但通常都是运行短短一会儿,因为它在等待更多的I/O请求时最后会阻塞。
  2. 处理器消耗型:该类进程把大把的时间用在执行代码上。除非被抢占,否则它们通常都是一直不停的运行。

       不过这种划分并不绝对,有时候进程可以同时拥有这两种行为。典型的例子就是子处理器,其通常都在等待键盘输入,但在任一时刻可能又粘住处理器不停的拼写检查或者宏计算。

       调度策略通常要在这两个矛盾之间寻找平衡:进程相应迅速最大系统利用率。Linux为了保证交互应用和桌面系统的性能,所以对进程的相应做了优化,更倾向于优先调度I/O消耗型进程,但是调度程序也并未忽略处理器消耗型的进程。

  • 进程的优先级

       调度算法中最基本的一类就是基于优先级的调度。通常的做法是(Linux系统并未完全采用)优先级高的进程先运行,低的后运行,相同优先级的进程按轮转的方式进行调度。这种调度方式的缺点是用户和系统都可以通过设置进程优先级来影响系统的调度。

      Linux采用了两种不同的优先级范围:

       第一种是用nice值,它的范围从-20到+19,默认值为0;越大的nice值意味着更低的优先——nice值意味着该程序对系统中的其他进程更“优待”。低nice值的进程可以获得更多的处理器时间。在Linux系统中nice值还代表时间片的比例。通过ps -el命令可以查看系统中的进程列表,结果中标记NI的一列就是该进程对应的nice值。
       第二种范围是实时优先级,其值是可配置的,默认情况下它的变化范围是从0到99。其值越高意味着进程优先级越高。任何实时进程的优先级都高于普通进程的优先级,也就是说实时优先级和nice优先级处于两个互不相交的范畴。通过以下命令:

ps -eo state,uid,ppid,rtprio,time,comm

查看进程列表,其中RTPRIO就是该进程对应的实时优先级,其中如果有进程对应列显示“-”,则说明它不是实时进程。

  • 时间片

       时间片是一个数值,它表明进程在被抢占前所能持续运行的时间。但是时间片的选择并不容易。时间片过长会导致系统对交互相应表现欠佳;时间片太短会明显增大进程切换带来的处理器耗时,因为相当一部分系统时间用在进程切换上了,而这些进程所能运行的时间片却很短。此时I/O消耗型进程和处理器消耗型进程之间的矛盾又体现了出来:I/O消耗型不需要太多的时间片,而处理器消耗型进程则希望时间片越长越好。

       Linux的CFS调度器并没有直接分配时间片到进程,它是将处理器的使用比例划分给了进程。这样进程所获得的处理器时间其实是和系统的负载相关的。这个比例进一步还会受nice值的影响,nice值作为权重将调整进程所使用的处理器使用比。具有高nice值(低优先权)的进程将被赋予低权重,从而损失一小部分处理器使用比;而具有低nice值(高优先权)的进程将被赋予高权重,从而获得更多的处理器使用比。

      前边提到了Linux系统是抢占式的。当一个进程进入可运行态,它就被允许投入运行。在多数操作系统中,是否要将一个进程立刻投入运行(也就是抢占当前进程),是完全由进程优先级和是否有时间片决定的。而在Linux中使用新的CFS调度器,其抢占时机取决于新的可运行程序消耗了多少处理器使用比,如果消耗的使用比比当前进程小,则立刻投入运行,抢占当前进程;否则将推迟运行

Linux调度算法

上述内容大致描述了进程调度原理。接下来我们看一下Linux独有的进程调度程序。

  • 调度器类

      Linux调度器是以模块方式提供的,这样做的目的是允许不同类型的进程可以有针对性地选择调度算法。这种模块化结构称为调度器类,它允许多种不同的可动态添加的调度算法并存,调度属于自己范畴的进程。每个调度器都有一个优先级,基础的调度器代码定义在kernel/sched.c文件中,它会按照优先级顺序遍历调度类,拥有一个可执行进程的最高优先级的调度类胜出,去选择下面要执行的那一个程序。

  • 完全公平调度CFS

       CFS的做法是:允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,而不再采用分配给进程时间片的做法了,CFS在所有可运行进程总数基础上计算出一个进程应该运行多久,而不是依靠nice值来计算时间片。nice值在CFS中被作为进程获得的处理器运行比的权重:越高的nice值进程获得更低的处理器使用权重,这是相对默认值nice值进程的进程而言的;相反,更低的nice值的进程获得更高的处理器使用权重。

      每个进程都按其权重在全部可运行进程中所占比例的“时间片”来运行,为了计算准确的时间片,CFS为完美多任务中的无限小调度周期值设立了一个目标。而这个目标称作“目标延迟”,越小的调度周期将带来越好的交互性,同时也更接近完美多任务。但是按照这样的做法会出现一个问题:当可运行任务数量趋于无限时它们各自所获得的处理器使用比和时间片都将趋于0。这样无疑造成了不可接收的切换消耗。CFS为此引入了每个进程获得的时间片的底线,这个底线称为最小粒度。默认情况下这个值是1ms。如此一来,即便是可运行进程数量趋于无穷,每个最少也能获得1ms的运行时间,确保切换消耗被限制在一定时间内。

Linux调度的实现

CFS调度算法主要由以下四个部分组成:

  1. 时间记账
  2. 进程选择
  3. 调度器入口
  4. 睡眠和唤醒

时间记账:

      调度器实体结构:

      所有的调度器都必须对进程运行的时间进行记账。CFS不再有时间片的概念,但是它也必须维护每个进程运行的时间记账,因为它需要确保每个进程只在公平分配给它的处理器时间内运行。CFS使用调度器实体结构(struct sched_entity)来追踪进程运行记帐。调度器实体结构作为一个名为se的成员变量,嵌入在进程描述符struct task_struct内。该结构体在文件<linux/sched.h>的struct_sched_entity中。

      虚拟实时:

      vruntime变量存放进程的虚拟运行时间,该运行时间(花在运行上的时间和)的计算是经过了所有可运行进程总数的标准化(或者说是被加权的)。虚拟时间是以ns为单位的,所以vruntime和定时器节拍不再相关。

     虚拟时间可以帮助我们逼近CFS模型所追求的“理想多任务处理器”。什么意思呢?就是说,CFS调度算法是给每个进程分配处理器使用比,但是并不是让所有进程同时执行,每个进程占用不同的比例,而是依次运行每个任务。因此CFS使用vruntime变量来记录一个进程到底运行了多长时间以及它还应该运行多久。

     功能实现:

     定义在kernel/sched_fair.c文件中的update_curr()函数实现了该记账功能

     update_curr()计算了当前进程的执行时间,并且将其存放在变量delta_exec中。然后它又将运行时间传递给了_update_curr(),由后者再根据当前可运行进程总数对运行时间进行加权计算。最终将上述的权重值与当前运行进程的vruntime相加。

     update_curr()是由系统定时器周期性调用的,无论是在进程处于可运行态,还是被阻塞处于不可运行状态。根据这种方式,vruntime可以准确地测量给定进程地运行时间,而且可以知道谁应该是下一个被运行地进程。

进程选择:

     CFS利用一个简单的规则去均衡进程地虚拟运行时间:当需要选择下一个运行程序时,它会挑一个具有最小vruntime的进程。这其实就是CFS算法的核心:选择具有最小vruntime的任务。CFS使用红黑树来组织可运行进程队列,并利用其迅速找到最小vruntime值的进程。在Linux中,红黑树称为rbtree,它是一个自平衡二叉搜索树。

挑选下一个任务:

      当一个红黑树中存储了系统中所有的可运行程序,其中节点的键值便是可运行进程的虚拟运行时间。CFS调度器优先取待运行的下一个进程,是所有进程中vruntime最小的那一个,它对应的便是在树中最左侧的叶子节点。也就是说,你从树的根节点沿着左边的子节点向下找,一直找到叶子节点,你便找到了其vruntime值最小的那个进程。实现这一过程的函数是_pick_next_entity(),它定义在文件kernel/sched_fair.c中。如下:

static struct sched_entity *_pick_next_etity(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);
}

注意:_pick_next_entity()函数并不会遍历树找到最左叶子节点,因为该值已经缓存在rb_leftmost字段中。

向树中加入进程:

       上述我们讲到,_pick_next_entity()会直接返回缓存在rb_leftmost字段中的最左叶子节点。那么就有一个问题,如何最左叶子节点缓存进去的呢?这一切发生在进程变为可运行态或者是通过fork()调用第一次创建进程的时候。enqueue_entity()函数实现了这一目的。

从树种删除进程:

       删除动作发生在进程堵塞或者终止的时候。通过dequeue_entity()函数来实现。

调度器入口:
       进程调度的主要入口点是函数schedule(),它定义在文件kernel/sched.c中。它正是内核其他部分用于调用进程调度器的入口:选择哪个进程可以运行,何时将其投入运行。schedule通常都需要和一个具体的调度类相关联,也就是说,它会找到一个最高优先级的调度类——后者需要有自己的可运行队列,然后问后者谁才是下一个该运行的进程。该函数唯一重要的一点是:它会调用pick_next_task(),pick_next_task()会以优先级为序,从高到低,依次检查每一个调度类,并且从最高优先级的调度类中,选择最高优先级的进程。

休眠和唤醒:

      休眠(被阻塞)的进程处于一个特殊的不可执行状态。这点非常重要,如果没有这种特殊状态的话,调度程序就可能选出一个本不愿意被执行的进程,更糟糕的是,休眠就必须以轮询的方式实现了。进程休眠有多种原因,但肯定都是为了等待一些事件。当进程休眠的时候:进程把自己标记成休眠状态,从可执行红黑树中移出,放入等待队列,然后调用schedule()选择和执行一个其他进程。唤醒的过程刚好相反:进程被设置为可执行状态,然后再从等待队列中移到可执行红黑树中。

等待队列:

      休眠通过等待队列进行处理。等待队列是由等待某些事件发生的进程组成的简单链表。内核用wake_queue_head_t来代表等待队列。进程把自己放入等待队列中并设置成不可执行状态。当与等待队列相关的事件发生的时候,队列上的进程会被唤醒。为了避免竞争条件,休眠和唤醒的实现不能有纰漏。

进程通过执行下边几个步骤将自己加入到一个等待队列中:

  1. 调用宏DEFINE_WAIT()创建一个等待队列的项
  2. 调用add_wait_queue()把自己加入到队列中。该队列会在进程等待的条件满足时唤醒它。当然我们必须在其他地方撰写相关代码,在事件发生时,对等待队列执行wake_up()操作。
  3. 调用prepare_to_wait()方法将进程的状态变更为TASK_INTERRUPTIBLE或TASK_UNINTERRUPTIBLE。而且该函数如果有必要的话会将进程加回到等待队列,这是在接下来的循环遍历中所需要的。
  4. 如果状态被设置为TASK_INTERRUPTIBLE,则信号唤醒进程。这就是所谓的伪唤醒(唤醒不是因为事件的发生),因此检查并处理信号。
  5. 当进程被唤醒的时候,它会再次检查条件是否为真。如果是,它就退出循环;如果不是,它再次调用schedule()并一直重复这步操作。
  6. 当条件满足后,进程将自己设置为TASK_RUNNING并调用finish_wait()方法把自己移出等待队列。

伪代码实现如下:

/*'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);//第六步

注意:关于休眠,存在虚假的唤醒。有时候进程唤醒并不是因为它所等待的条件达成了,所以这才需要用一个循环处理来保证它等待的条件真正的达成。

唤醒:
       唤醒操作通过函数wake_up()进行,它会唤醒指定的等待队列上的所有进程。它调用函数try_to_wake_up(),该函数负责将进程设置为TASK_RUNNING状态,调用enqueue_task()将此进程放入红黑树中,如果被唤醒的进程优先级比当前正在执行的进程的优先级高,还要设置need_resched标志。通常哪段代码促使等待条件达成,它就要负责随后调用wake_up()函数。

抢占和上下文切换

       上下文切换也就是从一个可运行进程切换到另一个可执行进程,由定义在kernel/sched.c中的context_switch()函数负责处理。每当一个新的进程被选出来准备投入运行的时候,schedule()就会调用该函数。它完成了两项基本的工作:

  1. 调用声明在<asm/mmu_context.h>中的switch_mm(),该函数负责把虚拟内存从上一个进程映射到新进程中。
  2. 调用在<asm/system.h>中的switch_to(),该函数负责从上一个进程的处理器状态切换到新进程的处理器状态。这包括保存、恢复栈信息和寄存器信息,还有其他任何与体系结构相关的状态信息,都必须以每个进程为对象进行管理和保存。

       内核必须知道在什么时候调用schedule()。如果仅靠用户程序代码显示的调用schedule(),它们可能就会永远地执行下去。相反,内核提供了一个need_resched标志来表明是否需要重新执行一次调度。当某个进程应该被抢占时,schedule_tick()就会设置这个标志;当一个优先级高地进程进入可执行状态地时候,try_to_wake_up()也会设置这个标志;内核检查标志,确认其被设置,调用schedule()来切换到一个新进程。该标志对于内核来讲是一个信息,它表示有其他进程应当被运行,要尽快调用调度程序。

用于访问和操作need_resched的函数
函数 目的
set_tsk_need_resched() 设置指定进程中的need_resched标志
clear_tsk_need_resched() 清除指定进程中的need_resched标志
need_resched() 检查need_resched标志的值,如果被设置就返回真,否则返回假

      再返回用户空间以及从中断返回的时候,内核也会检查need_resched标志。如果已被设置,内核会在继续执行之前调用调度程序。

      每个进程都包含一个need_resched标志,这是因为访问进程描述符内的数值要比访问一个全局变量快。

用户抢占:

简而言之,用户抢占在以下情况时发生:

  1. 从系统调用返回用户空间时。
  2. 从中断处理程序返回用户空间时。

内核抢占:

Linux完整地支持内核抢占,只要重新调度是安全的,内核就可以在任何时间抢占正在执行的任务。

内核抢占会发生在:

  1. 中断处理程序正在执行,且返回内核空间之前。
  2. 内核代码再一次具有可抢占性的时候。
  3. 如果内核中的任务显示地调用schedule()。
  4. 如果内核中的任务阻塞

实时调度策略

        Linux提供了两种实时调度策略:SCHED_FIFO和SCHED_RR。而普通的、非实时的调度策略是SCHED_NORMAL。这些实时策略并不被完全公平调度器来管理,而是被一个特殊的实时调度器管理。

        SCHED_FIFO实现了一种简单的、先入先出的调度算法:它不适用时间片。处于可运行状态的SCHED_FIFO级的进程会比任何SCHED_NORMAL级的进程都先得到调度。一旦一个SCHED_FIFO级进程处于可执行状态,就会一直执行,直到它自己受阻塞或显式的释放处理器为止;它不基于时间片,可以一直执行下去。只有更高优先级的SCHED_FIFO或者SCHED_RR任务才可以抢占SCHED_FIFO任务。如果有两个或者更多的同优先级的SCHED_FIFO级进程,它们会轮流执行。

        SCHED_RR与SCHED_FIFO大体相同,只是SCHED_RR级的进程在耗尽事先分配给它的时间后就不再执行了。也就是说:SCHED_RR是有时间片的SCHED_FIFO。

        以上这两种实时算法实现的都是静态优先级。内核不为实时进程计算动态优先级。这能保证给定优先级级别的实时进程总能抢占优先级比它低的进程。Linux的实时调度算法提供了一种软实时的工作方式。软实时的含义是,内核调度进程,尽力使进程在它的限定时间到来之前运行,但内核不保证总能满足这些进程的要求。

猜你喜欢

转载自blog.csdn.net/qq_41727218/article/details/86482787