0%

操作系统复习之CPU的虚拟化

什么是进程

简单地来说,进程就是跑起来的程序,而程序指的是存储在磁盘里面的可执行的二进制指令。

通常我们希望我们系统的这些程序是能够同时运行的,例如我们希望我们能够在微信聊天的同时也能够在网页上浏览新闻、听听音乐等。事实上,我们的操作系统可能会有上百个进程在运行,每个进程在运行时需要独占我们的CPU等一些资源,可是我们的资源(CPU、内存)是有限的,如何使用这些少量的资源支撑起共同运行这么多程序呢?

这就涉及到了操作系统的虚拟化的问题,虚拟出多个CPU供进程使用。

针对这一个问题,现代的操作系统采取的时空分享技术(时分共享、空分共享),就是说这个资源一个进程先用一段时间,再让另一个进程用一段时间。在切换进程的时候(上下文切换),操作系统需要把当前进程的一些状态(寄存器的内容、虚拟内存与物理内存的映射关系等等)保存下来,使得后续再次执行该进程的时候能够顺利的执行。

进程的创建

执行一个进程首先要做的是把存储在磁盘的代码装载进内存里边。在早期的操作系统中,这一个装载过程会在程序开始运行之前做完,而在现代的操作系统里边,采用的是惰性加载,只有在执行到相关的代码,发现这个代码并没有被加载进内存里面时才会到磁盘进行读取。

另外,在 UNIX 系统中,默认情况下每个进程都有 3 个打开的文件描述符,分别是标准输入、标准输出和错误输出。

进程的状态

进程主要有三个状态:

  • 运行态:意味着该进程正在占用CPU并运行
  • 就绪态:意味着该进程可以占用CPU并运行,但因为某种原因在此刻未能够运行
  • 阻塞:进程执行了某种操作而暂停运行,当这种操作完成后会进入就绪态(例如进程执行了IO操作而阻塞了)

进程的几个API

在这里列举一些比较重要的和进程相关的API。

  • fork()
    • 该函数会克隆出一个子进程,子进程里面的数据和父进程的一样(未执行其它操作)
      • 并不会把所有的数据都复制一份,只会在某一部分数据被修改前把这一部分数据单独拎出来(写时复制)
    • 对于父进程来说,返回值是子进程的 pid(创建失败则为-1),对于子进程来说返回值为 0
  • wait()
    • 用于父进程等待子进程结束,返回某个子进程的 pid
  • exec()
    • 该函数可以运行一个新的程序
    • 通常在 fork 一个子进程后,在子进程执行
    • 该函数执行后会把指定程序覆盖子进程,该函数后的指令不会被执行到

受限制直接执行可行吗

为了实现 CPU 的虚拟化,我们的思想很简单,就是一个进程运行一段时间,然后运行另一个进程,如此轮换,从而达到虚拟化的效果。

那问题就来了,我们的操作系统如何才能够以高性能的方式虚拟化 CPU,同时保持对系统的控制?

操作系统的开发人员提出了这么一种方案,叫做为受限的直接执行。这个方案非常简单,当某个进程想要运行时,操作系统在进程列表中为其创建一个进程条目,并分配一些内存,同时将程序代码加载到内存中,在最后开始直接运行用户的代码。

但这种方案会有一个很大的问题,就是我们的操作系统在程序运行的时候失去了对计算机的控制,如果这个程序有着一些恶意代码的话,这将会是一个非常可怕的事情。

首先,为了解决这么一个不受控制的问题,引入一种新的处理器模式,称为用户模式

在用户模式下,我们限制进程,使其无法执行一些敏感的操作,例如IO操作。

与用户模式对应的是内核模式,我们的操作系统是运行在这一模式中,操作系统就可以不受限制的去执行一些特权的操作(IO操作等)。在用户模式下,我们可以使用特殊的陷阱(trap)指令进入内核态,并让操作系统帮助进程完成一些特权的操作,但在切换之前需要对当前模式下的工作状态进行保护,比如程序计数器的值、寄存器的值等。用户模式与内核模式的切换有点类似于上下文切换,但并不完全等同于上下文切换。

上下文切换指的是从一个进程的工作状态切换到另一个进程的切换状态,通常也是需要保存好程序计数器的值、寄存器的值等。

这些方案听起来感觉还不错,但仍然忽略了一个很重要的问题,如果当前进程一直占有着CPU来运行,并且不做任何会触发线程切换或是从用户模式到内核模式的切换,这样的话我们的操作系统也一样是失去了对计算机的控制,这又该怎么办呢。

答案非常简单,使用时钟中断机制。时钟设备可以编程为每隔几毫秒产生一次中断。产生中断时,当前正在运行的进程停止,操作系统中预先配置的中断处理程序(interrupt handler)会运行。此时,操作系统重新获得 CPU 的控制权。

进程调度

对进程的控制方式确定了,那么下一个问题则是我们如何才能更好的管理正在运行或是即将运行的进程呢?如果管理得不够好,就很有可能会出现有的进程几乎占据了所有的CPU使用时间,而有的进程迟迟无法得到CPU的控制权,活活给饿死了。体现在我们的日常使用中就有可能会出现当前这个页面一直卡住不动,鼠标键盘操作也迟迟得不到回应,这是一个非常糟糕的事情。

那对于这个线程调度的方法都有哪些呢?

  • 先进先出(FIFO)
    • 先进先出是一个非常基础的调度算法,就那一个进程先来到我就先运行哪一个进程
    • 但这样就有一个问题,如果说一开始来了一个不是那么重要的但又耗时很长的一个进程,那么我后续到达的耗时短的紧急的进程就需要等待很久才能得到运行
  • 最短任务优先(SJF)
    • 那如果说,我让这个耗时短的进程优先执行呢?
    • 这样问题也仍然是存在的,当一个耗时长的进程比耗时短的进程先到达,也是优先完成耗时长的再去执行这个耗时短的
  • 最短完成时间优先(STCF)
    • 那假如说我可不可以在耗时长的进程执行一半时,强制切换去执行耗时短的呢?执行完短的我再继续去执行耗时长的,这样听起来好像非常不错耶。
    • 可惜还是有点问题,如果说我有几个耗时短的且耗时一样的进程同时到达呢?我先运行哪一个?处理得不好的话,也是很有可能会出现页面卡顿,鼠标键盘操作没反应等现象。
  • 轮转(RR)
    • 为了解决这么一个问题,我们采用另一种调度方法。我们在一个时间片内运行一个进程,然后切换到运行队列中的下一个进程,而不是运行一个进程直到结束。这样子反复执行,直到所有进程都执行完成。
    • 这个方案听起来很不错,但关键是如何确定每个时间片的长度是多少,又如何保证一些紧急的、耗时短的进程能够顺利优先地完成呢?
    • 另外提一点,在时间片轮转的调度下,若某一个进程发生了IO事件,则停止对其进行调度,直到其IO事件完成。且可以把空出来的时间片分配给别的进程,从而更好的利用计算机的资源。

多级反馈机制

上面所提及的几种调度算法感觉很不错,但从现实角度出发,我们是很难确定一个进程从开始执行到结束执行需要耗时多久,也很难判断这个进程是紧急的还是可以容忍一定时长的等待的。

那下面就要介绍到一种著名的调度方法——多级反馈队列(MLFQ):

  • MLFQ 中有许多独立的队列,每个队列有不同的优先级。任何时刻,一个工作只能存在于一个队列中。
  • MLFQ 总是优先执行较高优先级的工作
  • 具有相同优先级的工作会使用轮转进行调度

综上可以看出,MLFQ 调度策略的关键在于如何设置优先级,这对于最终的一个调度效果来说非常重要。

于是针对优先级的设置我们设立了下面这几条规则:

  • 规则 1:如果 A 的优先级 > B 的优先级,运行 A,不运行 B。
  • 规则 2:如果 A 的优先级 = B 的优先级,轮转运行A 和 B。
  • 规则 3:工作进入系统时,放在最高优先级。
  • 规则 4:一旦工作用完了其在某一层优先级中的时间配额,就降低其优先级。
  • 规则 5:经过一段时间 S,就将系统中所有工作重新加入最高优先级队列。

彩票机制

这里稍微介绍一下另一个不同类型的调度程序———比例份额调度程序,有时也称为公平份额调度程序。比例份额算法基于一个简单的想法:调度程序的最终目标,是确保每个工作获得一定比例的 CPU 时间。

首先每个进程都会占有一定份额的资源(我们把所占有的资源形容为拥有多少张彩票),然后通过生成一个随机数来决定下一步要调度哪一个进程(相当于从这么多张彩票之中随机选取一张中奖的彩票)。这个调度机制和彩票类似,所以又叫做彩票机制。

在选取调度次数逐步增加的情况下,每个进程获得调度机会的占比就会逼近实际占用资源的占比。

但这种调度策略的难点在于开始时如何确定每一个进程能拥有多少配额(能拥有多少张彩票)。