在上一篇文章Linux内核进程相关知识点总结中介绍了进程相关的一些基础知识,包括进程的创建、运行以及终止等。这篇文章中主要介绍进程的调度。
进程调度:举个栗子,在一个单核处理器中,同一时刻只能有一个进程拥有处理器资源,其他的进程只能在就绪队列中排序,等待处理器空闲之后才有机会获取处理器资源并开始运行。在这种情况下,操作系统就需要从众多的就绪进程中选择一个最为合适的进程来运行,这就是进程调度器,其出现的目的就是提高处理器的利用率。
进程的优先级:Linux操作系统最早采用nice值来调整进程的优先级,取值范围是-20-19,默认值为0。Nice值越大,优先级越低。目前,Linux内核使用0-139的数值表示进程的优先级,数值越低,优先级越高。其中,0-99给实时进程用,100-139给普通进程用。另外,在用户空间有一个传统的变量nice值映射到普通进程的优先级,即100-139。
其中,static_prio指的是静态优先级,在进程启动时分配。内核不存储nice值,取而代之的是static_prio。内核中的宏NICE_TO_PRIO()实现将nice值转换为static_prio。他之所以被称为静态优先级,是因为他不会随着时间而改变,用户可以通过系统调用来改变该值。
normal_prio是基于static_prio和调度策略计算出来的,在创建进程时会继承父进程的normal_prio值。对普通进程来说,static_prio与normal_prio相同;对于实时进程来说,会根据rt_priority重新计算normal_prio。
Prio保存着进程的动态优先级,是调度类考虑的优先级,有些情况下需要暂时提高进程的优先级,例如实施互斥量等。
经典调度算法:
1)多级反馈队列算法:把进程按照优先级分成多个队列,相同优先级的进程在一个队列中。
规则一:在同一个队列中,如果进程A的优先级大于进程B的优先级,那么调度器选择进程A.
规则二:如果进程A与进程B在同一个队列中,且优先级相等,则使用轮流调度算法来选择。
规则三:当一个新进程进入调度器时,把它放入最高优先级的队列里。
规则四:当一个进程吃满了时间片,则为CPU消耗性,那么需要把它的优先级下降一级,即从高优先级队列迁移到低优先级队列里。当一个进程在时间片还没有结束之前放弃CPU,则为I/O消耗型,那么优先级保持不变,维持原来的高优先级。
规则五:每隔时间周期S后,把系统中所有进程的优先级都提到最高。
新规则四:当一个进程使用完时间片,不管它是否在时间片最末尾发生I/O请求从而放弃CPU,都把它的优先级降一级。
2)Linux O(n)调度算法:
就绪队列是一个全局链表,从就绪队列中查找下一个最佳就绪进程和就绪队列里进程的数目有关,所耗费的时间是O(n),所以为O(n)调度器。每个进程在创建时就被赋予一个固定时间片。当前进程的时间片使用完毕后,调度器会选择下一个进程来运行。当所有进程的时间片都用完后,才会对所有进程重新分配时间片。
3)LinuxO(1)调度算法:
每个CPU维护一个自己的就绪队列,每个就绪队列由两个优先级数组组成,即活跃优先级数组和过期优先级数组,每个优先级数组包含140个优先级队列,也就是每个优先级对应一个队列,前100个对应实时进程,后40个对应普通进程。这里使用位图来定义给定优先级队列上是否有可运行的进程,如果有,则位图中相应的比特位会被置1。如此一来,选择下一个被调度进程的时间就变成了查询位图操作,而且和系统中就绪进程数量不相关,时间复杂度为O(1),因此称为O(1)调度器。当活跃数组中所有进程用完了时间片,活跃数组和过期数组会进行互换。
4)Linux CFS调度算法:
CFS调度算法抛弃以前固定时间片和固定调度周期的算法,采用进程权重值的比重来量化和计算实际运行时间。该算法还引入虚拟时钟的概念,每个进程的虚拟时间是实际运行时间相对于nice值为0的权重的比值。该算法的核心是进程vruntime的计算以及下一个运行进程的选择。
a)Vruntime的计算方法:
通过进程的实际运行时间和进程的权重来计算。一个进程的权重越大,则说明这个进程更需要运行,因此它的虚拟运行时间就越小,这样被调度的机会就越大。
一次调度间隔的虚拟运行时间=实际运行时间*(NICE_0_LOAD/权重)
进程的实际运行时间是进程从诞生开始累加得到。NICE_0_LOAD是nice为0时的进程权重。进程权重和nice值有关,nice值每差一级,cpu占用时间差10%。如果有两个nice值为0的进程同时占用cpu,那么它们应该每人占50%的cpu,如果将其中一个进程的nice值调整为1的话,那么此时应保证优先级高的进程比低的多占用10%的cpu,就是nice值为0的占55%,nice值为1的占45%。那么它们占用cpu时间的比例为55:45。这个值的比例约为1.25。根据这个原则,内核对40个nice值做了时间计算比例的对应关系,它在内核中以一个表存在。
内核使用struct load_weight数据结构来记录调度实体的权重信息:
Weight是调度实体的权重,inv_weight是权重的一个计算结果。
b) 调度类
内核中实现了4套调度策略,分别是SCHED_FAIR,SCHED_RT,SCHED_DEADLINE,SCHED_IDLE,都是按照sched_class类来实现。这四种调度类通过next指针串联在一起,用户空间可以使用调度策略API函数来设定用户进程的调度策略。其中,SCHED_NORMAL和SCHED_BATCH使用cfs调度器,SCHED_FIFO和SCHED_RR使用realtime调度器,SCHED_IDLE使用idle调度器,SCHED_DEADLINE使用deadline调度器。
c) 选择下一个进程
CFS选择vruntime较小的的进程作为下一个要运行的进程,由于其使用红黑树来组织就绪队列,因此可以快速找到vruntime最小的那个进程,只需要查找最左侧的叶子节点即可。CFS调度器通过pick_next_task_fair()函数来调用调度类中的pick_next_task()方法。
实时调度策略有SCHED_FIFO和SCHED_RR。前者一旦处于可执行状态会一直执行,直到受阻塞,更高优先级的进程可以抢占,多个同优先级轮流执行,只有在他们愿意让出处理器时才会退出。后者与前者大致相同,只是在耗尽事先分配给他的时间片之后不再执行。
对多CPU来说,CFS对每个CPU核心都维护一个调度队列,这样每个CPU都对自己的队列进程调度即可。
d) 进程切换:
_schedule()函数是调度器的核心函数,其作用是让调度器选择和切换到一个合适的进程并运行。调度的时机主要有以下几种:
1)阻塞操作:互斥量、信号量、等待队列等;
2)在中断返回前和系统调用返回用户空间时,去检查TIF_NEED_RESCHED标志位以判断是否需要调度;
3)将要被唤醒的进程不会马上调用schedule()函数要求被调度,而是会被添加到CFS就绪队列中,并设置TIF_NEED_RESCHED标志位。由此引发一个问题,就是唤醒进程什么时候被调度呢?这里要根据内核是否具有可抢占功能(CONFIG_PREEMPT=y),分两种情况进行处理。
A)如果内核不可抢占,则:
1.当前进程调用cond_resched()时会检查是否需要调度;
2.主动调用schedule();
3.系统调用或者异常处理返回用户空间时;
4. 中断处理完成返回用户空间时。
B)如果内核可抢占,则:
1. 如果唤醒动作发生在系统调用或者异常处理上下文中,在下一次调用preempt_enable()时会检查是否需要抢占调度;
2.如果唤醒动作发生在应中断处理上下文中,硬件中断处理返回前夕会检查是否要抢占当前进程。
硬件中断返回前夕和硬件中断返回用户空间前夕两个概念要区分开。前者是每次硬件中断返回前夕都会检查是否有进程需要被抢占调度,不管中断发生在内核空间还是用户空间;后者只有在中断发生在用户空间才会检查。
Schedule()函数中会调用两个函数分别是pick_next_task()函数和context_switch()函数。前者实现选择下一个需要运行的进程,后者实现上下文切换。其中context_switch()函数又调用两个函数,分别是switch_mm()函数和switch_to()函数,前者实现进程地址空间的切换,即切换将要运行进程的页表到硬件页表中;后者实现切换将要运行进程的内核态堆栈和硬件上下文,硬件上下文提供了内核运行将要运行进程所需要的所有硬件信息。
下图为与进程调度相关的数据结构以及他们之间的关系: