进程调度

95 阅读7分钟

想到一个公务员面试题:5杯水,怎么分给6个领导?

这题在os看来就不是问题,很简单,把6个领导眼睛蒙住,谁想喝水就举手,然后给他水,喝完我再把水杯拿回来给下一个举手喝水的人。

os 对 CPU 进行了这样的虚拟化,看下面3张图:

第一张图说明了,每个进程都认为自己独享一个 cpu。

第二三张图说明了,只有在os决定调度哪个进程的时候,它才能真正地使用 cpu。

CPU的虚拟化

为了虚拟化 CPU,操作系统需要以某种方式让许多任务共享物理 CPU,让它们看起来像是同时运行。基本思想很简单:运行一个进程一段时间,然后运行另一个进程,如此轮换。通过以这种方式时分共享(time sharing)CPU,就实现了虚拟化。

当 OS 希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点(main()函数或类似的),跳转到那里,并开始运行用户的代码。

但是,这种方法在我们的虚拟化 CPU 时产生了一些问题。

第一个问题:如果我们只运行一个程序,操作系统怎么能确保程序不做任何我们不希 望它做的事,同时仍然高效地运行它?

第二个问题:当我们运行一个进程时,操作系统如何让它停下来并切换到另一个进程,从而实现虚拟化 CPU 所需的时分共享?

问题一:受限制的操作

引入一种新的处理器模式,称为用户模式(user mode)。在用户模式下运行的代码会受到限制。例如,在用户模式下运行时,进程不能发出 I/O 请求。

与用户模式不同的内核模式(kernel mode),操作系统(或内核)就以这种模式运行。在此模式下,运行的代码可以做它喜欢的事,包括特权操作,如发出 I/O 请求和执行所有类型的受限指令。

要执行系统调用,程序必须执行特殊的陷阱(trap)指令。该指令同时跳入内核并将特权级别提升到内核模式。一旦进入内核,系统就可以执行任何需要的特权操作(如果允许),从而为调用进程执行所需的工作。完成后,操作系统调用一个特殊的从陷阱返回(return-from-trap)指令,该指令返回到发起调用的用户程序中,同时将特权级别降低,回到用户模式。

问题二:在进程之间切换

操作系统应该决定何时停止一个进程并开始另一个进程。说起来很简单,但是有一个棘手的问题:

假设我们只有一个CPU,此时有一个进程在 CPU 上运行,这就意味着操作系统没有运行。如果操作系统没有运行,它怎么能做事情?

过去,一些系统,比如早期的 Macintosh 就采用的是协作方式,就是相信进程会合理运行,在运行一段时间后主动放弃CPU,交给操作系统。

因此,你可能会问,在这个虚拟的世界中,一个友好的进程如何放弃 CPU?事实证明,大多数进程通过进行系统调用,将 CPU 的控制权转移给操作系统,例如打开文件并随后读取文件,或者向另一台机器发送消息或创建新进程。像这样的系统通常包括一个显式的 yield 系统调用,它什么都不干,只是将控制权交给操作系统,以便系统可以运行其他进程。当然,还有很多其他的方式,后面介绍。

显然,如果有些进程不讲武德的话,那么操作系统就没法干了。

所以,出现了一种非协作式方式,使用时钟中断(timer interrupt)。

时钟设备可以编程为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理程序(interrupt handler)会运行。此时,操作系统重新获得 CPU 的控制权,因此可以做它想做的事:停止当前进程,并启动另一个进程。

异常

上面说到的时钟中断(timer interrupt)与陷阱(trap)指令都是异常的一种。

CSAPP 里面的第8章将异常与中断统称为异常,这里我们也沿用异常的叫法。

异常分为4类:interrupts, traps, faults, and aborts.

interrupt

图上可以看到,中断发生时,os 处理完成后,切回该进程后,执行的是下一条指令。时钟中断也是属于中断的一种。

中断是异步的,比如:IO,Network 等。

trap

和中断差不多,比如,执行 read, write 等方法,就是在执行一个 syscall,就会转到内核执行。

陷阱是同步的。

fault

比较常见的就是 PageFault,出现这个后,切回原进程需要重新执行出问题的那条指令。如果还是出问题,那就只能 abort 了。

fault 是同步的。

abort

不可恢复的错误,只能 abort。

上下文切换

当进程执行时遇到上面说的几种异常后,从用户态切换到内核态,这个时候,操作系统会通过一些精妙的算法,决定下面运行哪一个进程。

这里,我们假设操作系统正在运行 p1,p2 两个进程,它采用轮询的策略(就是 p1 运行,再运行 p2,再运行 p1)。

当 p1 遇到了一个时钟中断或者 syscall 的时候,os 拿到 cpu 的执行权了,它决定等会去运行 p2。但是在此之前需要做一些事情,就是保存 p1 进程的现场。否则,后面从 p2 切回 p1 的时候,就不知道 p1 该从哪里开始运行了。

这个过程我们叫做上下文切换,它主要做的事情如下:

  1. saves the context of the current process,
  2. restores the saved context of some previously preempted process, and
  3. passes control to this newly restored process.

上下文切换除了这些需要处理,还有一个比较麻烦的就是 tlb,虽然每个进程有自己的页表,但是tlb是只缓存了当前进程的页表数据,如果切换到其他进程,那么tlb需要刷新。

内核栈

进程切换到内核态的时候,会将上下文保存在内核栈里面。

上面的图中,可以看到内核的虚拟内存里面也是有 data heap stack 的。

在内核空间里面,每个进程都有一个自己的内核栈。

对于操作系统来说,每个进程都是一个 task_struct,这个结构体里面储存了当前进程的 pid,mm(内存管理),kstack(内核栈),context(进程的上下文信息,寄存器,eflags 等)。

当中断或者异常出现时,进程从用户态陷入到内核态,操作系统要做的就是为当前正在执行的进程保存一些寄存器的值到它的内核栈,并为即将执行的进程恢复一些寄存器的值(从它的内核栈)。这样一来,操作系统就可以确保最后执行从陷阱返回的指令时,不是返回到之前运行的进程,而是继续执行另一个进程。

不同的视角看进程

在进程自身看来:

绿色线是用户态过程,蓝色线是内核态过程。

在操作系统看来: