Linux内核原理之进程调度

文章目录

    • 进程调度
      • 多任务
      • Linux的进程调度
      • 策略
        • I/O消耗型和CPU消耗型的进程
        • 进程优先级
        • 时间片
      • Linux调度算法
        • 调度器类
        • Unix系统中的进程调度
        • 公平调度
      • Linux调度的实现
        • 时间记账
        • 进程选择
        • 调度器入口
        • 睡眠和唤醒
      • 抢占和上下文切换
        • 用户抢占
        • 内核抢占
      • 实时调度策略
    • 参考资料

进程调度

进程调度程序:在可运行态进程之间分配有限处理器时间资源的内核子系统

多任务

多任务操作系统是同时并发地交互执行多个进程的操作系统,能使多个进程处于阻塞或者睡眠状态,这些任务位于内存中,但是并不处于可运行状态,他们利用内核阻塞自己,直到某一时间(键盘输入、网络数据等)发生。

多任务系统分为两类:

  • 非抢占式多任务
  • 抢占式多任务

Linux提供了抢占式的多任务模式,由调度程序决定什么时候停止一个进程的运行,以便其他进程得到运行机会,这个强制的挂起动作叫做抢占。

时间片:可运行进程在被抢占之前预先设置好的处理器时间段。

非抢占任务模式下,除非进程自己主动停止运行,否则他会一直运行。进程主动挂起自己的操作称为让步(yielding)

非抢占任务模式的缺点:调度程序无法对每个进程该执行多长时间做出统一规定,进程独占的CPU时间可能超出预期,另外,一个绝不做出让步的悬挂进程就能使系统崩溃

Linux的进程调度

2.6内核系统开发初期,为了提供对交互程序的调度性能,引入新的调度算法,最为著名的是反转电梯最后期限调度算法(RSDL),在2.6.3版本替代了O(1)调度算法,最后被称为完全公平调度算法(CFS)

策略

I/O消耗型和CPU消耗型的进程

CPU消耗型进程把时间大多用在了执行代码上,不属于I/O驱动类型,从系统响应速度考虑,调度策略往往是降低它们的调度频率,而延长其运行时间

调度策略的主要矛盾是:进程响应迅速和最大系统利用率(高吞吐量)

Unix系统的调度程序更倾向于I/O消耗型程序,以提供更好的响应速度。Linux为了保证交互式应用和桌面系统的性能,对进程的响应做了优化(缩短响应时间),更倾向于调度I/O消耗型进程。

进程优先级

调度程序总是选择时间片为用尽而且优先级最高的进程运行

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

  • 第一种用nice值,范围-20~+19,默认值0;越大的nice值优先级越低。相比高nice值(低优先级)的进程,低nice值(高优先级)的进程可以获得更多的处理器时间
  • 第二种是实时优先级,数值可配置,默认范围是0~99,数值越大优先级越高。任何实时进程的优先级都比普通进程高,实时优先级和nice优先级处于互不相交的范畴

时间片

调度策略选择合适的时间片并不简单,时间片太短会增加进程切换的处理器消耗,太长会导致系统的交互响应变差

Linux的CFS调度器没有直接分配时间片到进程,它是将处理器的使用比划分给进程,所以进程所获得的时间片时间是和**系统负载(系统活跃的进程数)**密切相关的

Linux中新的CFS调度器,它的进程抢占时机取决于新的可运行程序消耗了多少处理器使用比。如果消耗的处理器使用比比当前进程小,则新进程投入运行(当前进程被强占),否则,推迟运行。

总而言之,CFS会先根据进程的nice值预期设定每个进程的cpu使用比,而在进程调度时,需要将新的被唤醒进程实际消耗的cpu使用比和当前进程比较,如果更小,则抢占当前进程,投入运行,否则,推迟运行

Linux调度算法

调度器类

Linux调度器以模块提供,允许不同类型的进程针对性地选择调度算法,这种模块化结构成为调度器类,它允许多种不同的可动态添加的调度算法并存,调度属于自己范畴的进程。

完全公平调度(CFS)是一个针对普通进程的调度类,称为SCHED_NORMAL,具体算法实现定义在文件kernel/sched_fair.c中

Unix系统中的进程调度

传统Unix系统调度:进程启动会有默认的时间片,具有高优先级的进程将运行的更频繁,而且被赋予更多的时间片。存在的问题如下:

  • nice映射到时间片,就会将nice单位值对应到处理器的绝对时间,这样将会导致进程切换无法最优化进行,同时会导致进程获得的处理器时间很大程度上取决于其nice初始值。场景实例详见P40
  • 时间片一般为系统定时器节拍的整数倍,它会随着定时器节拍改变

CFS采用的方法是:完全摒弃时间片而是分配给进程一个处理器使用比重,确保了调度中恒定的公平性,切换频率是在动态变化中

公平调度

完美的多任务系统:每个进程获得1/n的处理器时间(n是指可运行进程的数量),同时调度给他们无限小的时间周期(交互性会很好)

CFS的做法:允许每个进程运行一段时间、循环轮转、选择运行最少的进程作为下一个运行进程,在所有进程总数基础上计算一个进程应该运行多久,不在依靠nice值计算绝对的时间片,而是作为进程获得的处理器运行比的权重,越高的nice值获得更低的处理器使用权重。

每个进程按照其权重在全部可运行进程中所占比例的“时间片”来运行,由于越小的调度周期(重新调度所有可运行进程所花的时间)交互性会越好,也就更接近完美的所任务,CFS为调度周期设定一个目标(无限小的调度周期近似值)。

当可运行任务数量区域无限大时,他们所获得的处理器使用比和时间片将趋近于0(这会增加CPU的切换消耗)。因此,CFS引入每个进程获得的时间片底线,称为最小粒度。而当进程数非常多时,由于这个最小粒度的存在,调度周期会比较长,因此CFS并非完美的多任务。

总之,在CFS中任何进程所获得的的处理器时间是由它自己和其他所有可运行进程nice值的相对差值决定的,nice值对时间片的作用不再是算数加权,而是几何加权,CFS是近乎完美的多任务

Linux调度的实现

Linux调度主要关注四个部分:

  • 时间记账
  • 进程选择
  • 调度器入口
  • 睡眠和唤醒

时间记账

  1. 调度器实体结构

    CFS不再有时间片的概念,但是它会维护每个进程运行的时间记账,需要确保每个进程在分配给它的处理器时间内运行。CFS使用调度器实体(文件中的struct_sched_entity中)来追踪进程运行记账

    struct sched_entity {
    	struct load_weight load;
        struct rb_node run_node;
        struct list_head group_node;
        ...
        u64 vruntime;
        ...
    };
    

    调度器实体结构作为一个名为se的成员变量,嵌入在进程描述符task_struct内

  2. 虚拟实时

    vruntime变量存放进程的虚拟运行时间,这个数值的计算是经过所有可运行进程总数的标准化,以ns为单位,与定时器节拍无关

    定义在kernel/sched_fair.h文件中的update_curr()函数实现记账功能,它是系统定时器周期性调用,无论进程是在可运行态还是阻塞状态

    static void update_curr(struct cfs_rq *cfs_rq)
    {
    	...
        __update_curr(cfs_rq, curr, delta_exec)
        ...
    }
    

进程选择

CFS算法调度核心:当CFS需要选择下一个运行进程时,选择具有最小vruntime的进程

CFS使用红黑树组织可运行进程队列,红黑树的键值为vruntime,检索对应节点的时间复杂度为对数级别

  1. 挑选下一个任务

    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)
    }
    

    注意:如果该函数返回值为NULL,说明树中没有任何节点,代表没有可运行进程,CFS调度器选择idle任务运行

  2. 向树中加入进程

    进程变为可运行状态(被唤醒)或者通过fork()调用第一次创建进程时,会将进程加入到rbtree。enqueue_entity()函数实现了这个过程,代码详见P45

  3. 从树中删除进程

    删除动作发生在进程阻塞(变为不可运行状态)或者终止(结束运行),是由函数dequeue_entity()函数完成

调度器入口

进程调度的入口函数是schedule(),定义在kernel/sched.c文件,它是内核其他部分调用进程调度器的入口

schedule()通常需要和一个调度类相关联,它会先找到一个最高优先级的调度类,后者要有自己的可运行进程队列,然后这个调度类决定下一个可运行的进程。

因此,schedule()函数的逻辑比较简单,它的主要逻辑就是调用pick_next_task(),这个函数会以优先级为序,从高到低一次检查每个调度器类,从最高优先级的调度类中选择下一个运行的进程。详细代码见P48

static inline struct task_struct *pick_next_task(struct rq *rq)
{
    ...
}

每个调度类都实现了pick_next_task()函数,它会返回指向下一个可运行进程的指针,在CFS中pick_next_task()会调用pick_next_entity(),该函数会调用 [4.5.2节](#4.5.2 进程选择) 提到的__pick_next_entity()

函数优化:由于CFS是普通进程的调度类,而系统绝大多数进程是普通进程。函数使用了一个小技巧,当所有可运行进程数等于CFS类对应的可运行进程数时,直接返回CFS调度类的下一个运行进程

睡眠和唤醒

睡眠(或阻塞)的进程处于一个特殊的不可运行状态。

进程睡眠时,进程把自己标记为休眠状态,从可执行进程对应的红黑树中移出,放入等待队列,然后调用schedule()调度下一个进程;唤醒的过程相反:进程被设置成可执行状态,然后从等待队列移到可执行红黑树中

  1. 等待队列

    等待队列是由等待某些事件发生的进程组成的简单链表,内核用wake_queue_head_t代表等待队列

    进程加入等待队列的详细过程和代码详见P50

  2. 唤醒

    唤醒操作通过函数wake_up()进行,它会唤醒等待队列上的所有进程,它调用函数tey_to_wake_up()将进程状态设置为TASK_RUNNING,调用enqueue_task()将此进程放入红黑树,如果被唤醒的进程比当前正在执行的进程优先级高(这里不是指nice值,而是根据CFS调度的cpu使用比规则得出的结果),还要设置进程的need_resched标志。

    注意:通常哪段代码促使等待条件达成,它就要负责调用wake_up()函数。例如,当磁盘数据到来时,VFS需要负责对等待队列调用wake_upe()

抢占和上下文切换

上下文切换由定义在kernel/sched.c中的context_switch()函数负责处理。每当一个新的进程被选出来投入运行的时候,schedule()会调用函数context_switch(),后者完成两项工作:

  • 调用声明在中的switch_mm()函数,它负责将虚拟内存从上一个进程映射切换到新进程中
  • 调用声明在的switch_to(),负责从上一个进程的处理器状态切换到新进程的处理器状态,其中包括保存、恢复栈信息和寄存器信息

内核提供一个need_resched标志标明是否需要重新执行一次调度,2.2以前放在全局变量,2.2~2.4在每个进程的进程描述符中(由于current宏速度很快并且进程描述符通常是在高速缓存中,访问task_struct内的数值比全局变量更快),而在2.6版本中,它放在thread_info结构体中,用一个特别的标志变量的一位来表示。

need_resched标志被设置的时机:

  • 当某个进程应该被抢占时,scheduler_tick()函数会设置这个标志
  • 当一个优先级更高的进程进入可运行状态时,try_to_wake_up()也会设置这个标志

然后内核检查该标志,确认被设置后,会调用schedule()切换到一个新进程

用户抢占

内核在中断处理程序或者系统调用返回后,都会检查need_resched标志。从中断处理程序或者系统调用返回的返回路径都是跟体系结构相关,在entry.S(包含内核入口和退出的代码)文件通过汇编实现

当内核将返回用户空间的时候,如果need_resched标志被设置,会导致schedule()调用,会发生用户抢占

因此,用户抢占发生在以下情况:

  • 系统调用返回用户空间时
  • 中断处理程序返回用户空间时

内核抢占

在没有内核抢占的系统中,调度程序没有办法在一个内核级的任务正在执行时重新调度,内核中的任务以协作方式调度,不具备抢占性,内核代码一直执行到完成(返回用户空间)或者阻塞为止

在2.6版本,Linux内核引入抢占能力,只要重新调度是安全的(没有持有锁的情况),内核可以在任何时间抢占正在执行的任务。

在每个进程的thread_info结构中加入preempt_count计数,代表进程使用锁的个数。

  • 在中断返回内核空间的时候,会检查need_resched和preempt_count,如果need_resched被设置且preempt_count为0,则可以进行安全的抢占,调度程序schedule()会被调用,否则,中断直接返回当前进程
  • 如果进程持有的所有锁被释放,preempt_count会减为0,此时释放锁的代码会检查need_resched标志,如果被设置,则调用schedule()

因此,内核抢占发生在:

  • 中断处理程序正在执行,且返回内核空间之前
  • 进程在内核空间释放锁的时候
  • 内核任务显式的调用schedule()
  • 内核中的任务阻塞

实时调度策略

Linux提供了一种软实时的工作方式

软实时的定义:内核调度进程尽力使进程在规定时间到来前运行,但是内核不能总是满足这些进程的要求

硬实时的定义:保证在一定条件下,可以完全满足进程在规定的时间内完成操作

Linux提供了两种实时调度策略:SCHED_FIFO和SCHED_RR,普通的、非实时的调度策略是SCHED_NORMAL。实时策略不被CFS调度器管理,而是被一个特殊的实时调度器管理

SCHED_FIFO实现了简单的、先入先出的调度算法,它不使用时间片,SCHED_RR和前者大致相同,不同点在于它使用时间片,是一种实时轮转调度算法

参考资料

  • Linux内核设计与实现
  • 深入Linux内核架构

你可能感兴趣的:(Linux内核)