linux调度子系统是内核最重要的子系统之一,也是其中复杂性以及理解难度较大的模块之一,尤其在日常工作中对一些系统性能的分析,往往都与其有关,本文旨在为后面深入研究调度子系统做铺垫。

1.调度子系统介绍

1.1什么是调度子系统

      调度是对CPU资源的管理,操作系统的作用之一就是系统资源的管理器。所有进程的运行都需要CPU,对CPU该如何管理呢?

      对于直接共享的事务,一般有两种管理方法:

      一种是时间分割管理,另一种则是空间分割管理。由于CPU自身的特性,我们只能对其进行时间分割管理,而这个过程中产生的管理方法,就称其为进程调度。

     到这里熟悉进程、线程概念的同学就会问:线程不才是linux系统进程调度的基本单位么?这其实源自于操作系统理论的历史发展,早期还没有多线程的概念,进程就是线程,所以早期的调度指的就是进程调度,但后来,有了多线程的概念,但是因为历史原因,大家都习惯称其为进程调度,但其准确的称呼应该是线程调度,但遵循其一般称谓,本文后面都会称其为进程调度

     对线程的调度可以有两种方式:一种是直接调度线程,不考虑它们所属的进程,这种方式称为一级调度或者直接调度;

      另一种则是先调度进程,再在进程内部调度线程这种方式称为间接调度者二级调度;

      POSIX标准规定:操作系统择其一实现即可!linux选择的是一级调度,之所以选择这种方式,主要是为了提高进程的并发性,充分利用多核心的有事,如果使用二级调度,看似每个进程之间都是公平的,但是某些计算量较大的进程,无法通过多开线程提高自己的性能,这样对系统的整体性能是有害的,也不利于发挥多核优势。

     而一级调度看似不公平,事实上可以通过对计算量小的进程少开线程,对计算量较大的进程多开线程这样的操作,来实现系统高性能的运行。

1.2为什么要调度

     在知道了什么调度后,我们就需要了解为什么要进行调度?最早的计算机操作系统是没有调度的,程序只能一个一个地运行,一个进程运行结束后才能去运行下一个进程。这里面首先存在的问题就是我们没法同时运行多个进程。其次就算我们不需要同时运行多个进程,程序在运行的过程中如果要等IO,CPU就只能空转,这也十分浪费CPU资源。于是最早的多任务——协作式多任务诞生了,当程序由于要等IO而阻塞时就会去调度执行其它的进程。但是协作式多任务存在着很大的问题,就是每个进程运行的时间片长短是不确定的,而且是很偶然很随机的。如果一个进程它一直在做运算就是不进行IO操作,那么它就会一直霸占CPU。针对这个问题,当时想出的方法是道德解决方案。内核向进程提供系统调用sched_yield,它会使进程主动放弃CPU让其它进程来执行。然后要求所有的程序员在程序中合适的地方尽量多地加入sched_yield调用。这个方法在当时是管用的,因为当时计算机的使用者(同时也是程序员)仅限于少数科研机构和政府机关的部分人员,一台电脑的共同使用者都认识,面子上还得过得去。

       后来对着计算机的普及,计算器面向普罗大众,上述的方式不再适合当下的情况。操作系统需要强制性多任务,也就是抢占式多任务。抢占式多任务使得每个进程都可以相对公平地平分CPU时间,如果一个进程运行了过长的时间就会被强制性地调度出去,不管这个进程是否愿意。有了抢占式多任务,我们在宏观上不仅可以同时运行多个进程,而且它们会一起齐头并进地往前运行,不至于出现极个别进程阻塞式占用宝贵的CPU资源。值得一提的是,抢占式多任务和协作式多任务相互独立,可以同时存在于系统中。

      抢占又分为用户抢占和内核抢占。由于抢占对进程来说是异步的,进程被抢占时不一定运行在什么地方,有可能运行在用户空间,也有可能运行在内核空间(进程通过系统调用进入内核空间)。如果抢占点是在用户空间,那么抢占就是安全的,如果在内核空间就不一定安全,这是为什么呢?因为对于用户空间来说,如果抢占会导致线程同步问题,那么用户空间有责任使用线程同步机制来保护临界区,只要用户空间做好同步就不会出问题。如果内核也做好了同步措施,内核抢占也不会出问题,但是内核最初的设计就没有考虑内核抢占问题,所以刚开始的时候内核是不能抢占的。后来内核开发者对内核进行了完善,把内核所有的临界区都加上了同步措施,然后内核就是可抢占的了。内核能抢占了不代表内核一定会抢占,内核会不会抢占由config选项控制,可以开启也可以关闭,因为内核抢占还会影响系统的响应性和性能。开启内核抢占会提高系统的响应性但是会降低一点性能,关闭内核抢占会降低系统的响应性但是会提高一点性能。因此把内核抢占做成配置项,可以让大家灵活配置。服务器系统一般不需要与用户交互,所以会关闭内核抢占来提高性能,桌面系统会开启内核抢占来提高系统的响应性,来增加用户体验。

       回顾以下上述的讲解,就会发现,没有调度的话,操作系统就不能支持多任务并行!

1.3为什么能调度

      先来抛出两个概念:

我们把协作式多任务叫做主动调度,抢占式多任务叫做被动调度

     然后我们把为什么能调度拆分为两个问题:为什么能触发调度和为什么能执行调度?对于主动调度,调度由其触发;然而对于被动调度,在图灵机模型中是做不到的,因为图灵机线性延伸的,被动式无法做到主动切换,这时调度系统的维护者们采用了中断的方法,创建一个定时器中断,以固定的时间间隔,例如10ms出发一次中断,检测进程是否运行时间过长,如果时间片用完,就出发调度,这样就实现了调度。

    接下来分析为何能执行调度,执行调度包括两部分:选择进程和切换进程。选择进程是纯软件的,肯定能实现。切换进程是怎么切换呢?一个进程执行的好好的,怎么就切换了呢,需不需要硬件的支持呢?进程切换主要是切换执行栈和用户空间,主要调用关系如下所示:

__schedule   // kernel/sched/core.c
->context_switch
->switch_mm_irqs_off //进程地址空间切换(用户空间切换)
->switch_to //处理器状态切换(执行栈)

1.3.1用户空间的切换

      进程地址空间指的是进程所拥有的虚拟地址空间,而这个地址空间是假的,是linux内核通过数据结构来描述出来的,从而使得每一个进程都感觉到自己拥有整个内存的假象,cpu访问的指令和数据最终会落实到实际的物理地址,对用进程而言通过缺页异常来分配和建立页表映射。进程地址空间内有进程运行的指令和数据,因此到调度器从其他进程重新切换到我的时候,为了保证当前进程访问的虚拟地址是自己的必须切换地址空间。

     实际上,进程地址空间使用mm_struct结构体来描述,这个结构体被嵌入到进程描述符(我们通常所说的进程控制块PCB)task_struct中,mm_struct结构体将各个vma组织起来进行管理,其中有一个成员pgd至关重要,地址空间切换中最重要的是pgd的设置。

    pgd中保存的是进程的页全局目录的虚拟地址,保存的是虚拟地址,那么pgd的值是何时被设置的呢?答案是fork的时候,如果是创建进程,需要分配设置mm_struct,其中会分配进程页全局目录所在的页,然后将首地址赋值给pgd。

    在进程地址空间切换的时候,即将切换进来的进程的pgd虚拟地址会转化为物理地址存放在ttbr0_el1中,这是用户空间的页表基址寄存器,当访问用户空间地址的时候MMU会通过这个寄存器来做遍历页表获得物理地址(ttbr1_el1是内核空间的页表基址寄存器,访问内核空间地址时使用,所有进程共享,不需要切换)。完成了这一步,也就完成了进程的地址空间切换,确切的说是进程的虚拟地址空间切换。

1.3.2执行栈的切换

      上面讲解了如何进行地址空间切换,只是保证了进程访问指令数据时访问的是自己地址空间(当然上下文切换的时候处于内核空间,执行的是内核地址数据,当返回用户空间的时候才有机会执行用户空间指令数据,地址空间切换为进程访问自己用户空间做好了准备),但是进程执行的内核栈还是前一个进程的,当前执行流也还是前一个进程的,需要做切换。

     这是arm64中的切换源码:

#define switch_to(prev, next, last)          \
do { \
((last) = __switch_to_asm((prev), (next))); \
} while (0)

switch_to
->__switch_to
... //浮点寄存器等的切换
->cpu_switch_to(prev, next)

      这里引用程磊前辈的理解:


如图中所示一样,每个线程都有一个线程栈,代表线程的执行,CPU只有一个(线程切换前后是同一个CPU)。CPU在哪个线程栈上运行,就是在运行在哪个线程,而线程栈上记录的就是线程的运行信息,所以这个线程就可以继续运行下去了。如果从单个进程的角度来看,从switch_to开始,我们的进程就暂停运行了,我们的进程就一直在这等,等到我们被唤醒并调度执行才会继续走下去。如果从CPU的角度来看,switch_to切换了内核栈,就在新的线程上运行了,函数返回的时候就会按照内核栈的调用地址返回,执行的就是新的代码了,就不是原来的代码了。当内核栈不停地返回,就会返回到用户空间,内核栈的底部记录的有用户空间的调用信息,由于前面已经切换了用户空间,所以程序就能返回到之前用户空间进入内核的地方。

         接下来再浅析一下为什么switch_to宏为啥有三个参数(面试官很喜欢问!!!),按程磊前辈的理解,switch_to实际上包含了三个进程:一个是当前的进程curr,一个是即将要切换的进程next,一个是下次从哪个进程切换过来的from,先从一个当前进程A角度来看,__switch_to_asm会切换到next进程去执行,当前进程就休眠了。一段时间后进程A醒来又重新开始执行了,__switch_to_asm返回的是把CPU让给我们的那个进程。从CPU的角度来看__switch_to_asm函数前半程在curr进程运行,后半程在next进程运行。由于切换了内核栈,所以from、curr、next这三个变量也变了,它们是不同栈上的同名的局部变量,它们的内存地址是不一样的。当前进程中的curr值会被作为next进程中的from值返回,所以在next进程中就知道了是从哪里切换过来的了!

1.4调度时机

       对于主动调度,触发调度和执行调度是同步的、一体的,触发即执行。主动调度发生的时机有IO等待、加锁失败等各种阻塞操作以及用户空间主动调用sched_yield。

      对于被动调度,触发调度和执行调度是异步的、分离的,触发调度并不会立马执行调度,而是做个需要调度的标记,然后在之后的某个合适的地方会检测这个标记,如果被设置就进行调度。

      触发调度的点有:在定时器中断中发现时间片用完,在唤醒进程时发现新进程需要抢占当前进程,在迁移进程时发现新进程需要抢占当前进程,在改变进程优先级时发现新进程需要抢占当前进程。其中第一个触发点是当前进程需要被抢占,它是用来保证公平调度,防止进程霸占CPU的,后三个触发点是新进程需要抢占当前进程,它是用来提高系统响应性的。

       执行调度的点有:系统调用完成之后即将返回用户空间,中断完成之后即将返回用户空间,如果开启了内核抢占的话则还有,中断完成之后即将返回内核,如果中断发生在禁止抢占临界区中,那么中断完成之后返回内核是不会执行调度的,而是会在临界区结束的时候执行调度。

1.5如何调度

      根据前面的讲解,调度要做的事分为两个部分,一个为是切换进程的选择,另一个则是用户空间和执行栈的切换。选择下面要执行的进程,就是调度算法了,且调度算法只能在可运行状态的进程中选择,阻塞态进程不能选择。其次,算法还要区分进程类型,比如普通进程与实时进程,值得一提的是,实施进程需要优先执行。同类型的进程还要有具体的算法来决定到底选择哪个进程来执行。在linux中,把进程分为了五类,每一类都有具体的算法。不同类型的进程,调度器优先执行高优先级类的进程,只有高优先级类的可执行态的进程都用完了,才考虑低等级类进程。

       选择好要执行的进程后,就要切换进程。如果要切换的两个线程属于同一个进程,因为共享内存空间,所以只需要切换执行栈。线程的用栈信息都在执行栈内存储,切换完执行栈后,线程继续执行就会返回用户空间,由于此时的用户空间已经切换完毕,内核栈存储的用户信息就会发挥作用,通过MMU就会返回到正确的用户空间线程之上!

1.6调度均衡

      调度均衡是为了解决SMP系统上CPU分配而提出的概念,分为个体均衡和总体均衡。个体均衡是从进程的角度出发选择到一个相对清闲的CPU上去运行。总体均衡是从CPU的角度出发如何从别的CPU上拉取一些进程到自己这来执行,使得所有CPU的工作量尽量平均。

1.7调度算法简介

       在介绍调度算法之前,需要明确几个概念。

       调度类:调度类代表的是进程对调度器的需求,主要是对调度紧迫性的需求

       调度策略:调度策略是调度类的子类,是对调度类的细分,是在同一个调度需求下的细微区别

       调度算法:调度算法是对调度类的实现,一个调度类一个调度算法

       同一个调度类的调度策略是有很强的相似性的,所以在同一个算法中实现,对于它们不同的部分,算法再去进行区分。

       Linux中一共有五个调度类,分别是stop(禁令调度类)、deadline(限时调度类)、realtime(实时调度类)、time-share(分时调度类)、idle(闲时调度类)。它们的调度紧迫性从上到下,依次降低。

       其中禁令调度和闲时调度,仅用于内核,没有调度策略,由于这类进程在内核启动时就设置好了,一个CPU一个相应的进程,所以也不需要调度算法。

      另外三个调度类可用于用户空间进程,有相应的调度策略和调度算法,也有相应的API供用户空间来设置一个进程的调度策略和优先级。调度类之间的关系是有高类的进程可运行的情况下,绝对不会去调度低类的进程,只有当高类无Runnable的进程的时候才会去调度低类的进程。这里面也有一个例外就是内核为了防止实时进程饿死普通进程,提供了一个配置参数,默认值是实时进程如果已经占用了95%的CPU时间,就会把剩余5%的CPU时间分给普通进程。​

下面简单介绍一下用户侧的3个调度策略:

 限时调度类属于硬实时,适用于对调度时间有明确要求的进程。

 实时调度类:属于软实时,适用于那些只要可运行就希望立马能执行的进程,比如音视频的解码进程。

 分时调度类:是给广大的普通进程来用的,大家共同分享CPU。根据优先级的不同,可能有的进程分的多有的进程分的少,但是不会出现一个进程霸占CPU的情况。分时调度类现在的算法叫做CFS(完全公平调度),所以分时调度类也叫做公平调度类。

2.参考致谢

      在写作过程中,参考了一些前辈的博客,再次致谢!

1.公众号:linux阅码场-程磊-深入理解Linux进程调度

2.​​(14条消息) Linux进程如何实现用户空间与内核空间的转换_胡涂涂~的博客-CSDN博客_linux用户态和内核态转换​