OSTEP[4-6]:low-level mechanisms of running processes

441 阅读9分钟

OSTEP 全称叫《Operating System:Three Easy Pieces》,从 virtualization, concurrency, 和 persistence 三个角度谈论操作系统的设计和实现,是威斯康辛的研究生教材,即使本科不修 CS 的人也可以阅读,写得非常通俗易懂,是值得推荐的操作系统入门读物,难度较 CSAPP 低一点,可以先读这本再去看 CSAPP。书籍的章节 PDF 是开源的,地址在:pages.cs.wisc.edu/~remzi/OSTE…

接下来可能会更新一段时间这本书的读书笔记,感兴趣的可以看看,不感兴趣的推荐直接去看书。

写在前面

进程(process)是一个运行的程序(running program),你的电脑上可能一次性跑着很多个进程,有音乐播放器、网页浏览器、IDEA 等等,单核 CPU 理论上一个时间点只能运行一个程序,计算机通过虚拟化 CPU 的技术让成千上万个进程好像同时在运行。

完成这件壮举需要底层机制的支持,例如上下文切换(context switch),它让操作系统可以暂停某个正在运行的程序,将 CPU 让出来给另一个程序运行,这种功能有个正式名称叫时间分片,现代操作系统几乎都具备。但有底层机制还不够,还需要顶层操作系统具备一定的“智慧”,例如先调度哪个进程,这由操作系统的调度策略决定。

本文主要叙述——运行一个进程,需要哪些底层机制的支持,后一篇会详述顶层调度策略。

进程的构成要素

进程是由哪些部分构成的呢?首先是内存,代码指令和需要读取的数据都保存在内存中;第二是寄存器,因为一些指令会直接读写寄存器,注意有一些很特殊的寄存器跟进程联系紧密,例如 PC(program counter),功能是指向下一条需要执行的指令;堆栈指针,功能是用来管理程序的本地变量、方法参数和返回地址等保存在栈上的信息;第三是 IO 设备,因为进程可能也会与外部 IO 设备进行数据读写。

OS 如何运行进程

我们继续深入探讨下进程的创建和运行,提问:操作系统是如何运行一个程序的呢?

  1. 第一步需要将程序代码和静态数据加载进内存中该进程所属的地址空间,并将这些内容转换成可执行状态,现代操作系统都是懒加载(lazy),即等到程序需要运行时才会将它们载入内存,这还涉及到内存的页缓存和页交换,先暂且不提。
  2. 第二步操作系统需要为程序分配运行时栈(running-stack),C 语言利用栈(stack)保存本地变量、方法参数和返回地址。
  3. 第三步操作系统还会为程序分配动态堆(heap),程序通过调用 malloc 方法申请内存,调用 free 方法归还,堆通常用于存储需要动态分配的数据,例如数组、列表、哈希表等结构,堆一开始比较小,随着程序的运行逐渐扩大。
  4. 操作系统还会做一些其他的工作,例如在 Unix 系统中每个进程默认开启三个文件描述符,分别是标准输入(stdin)、输出(stdout)和错误(stderr),这三者让程序能更方便地从终端读取数据并将结果显示在屏幕上。
  5. 最后,操作系统会跳转到 main 函数的入口处,转交 CPU 的控制权给进程,程序就开始运行了。

进程的状态转换

我们现在知道进程是怎么产生的、怎么运行的了,下面我们看看进程的状态。简单来说,进程有三种状态:RunningReadyBlock,不需要把他们跟实际 Unix 系统或者 Java 虚拟机中的线程状态对应起来,这只是理论概念上的三种状态,它们之间的转换关系如下图:

undefined

#我们可以看出,进程可以随着 CPU 调度情况在 RunningReady 之间进行转换,但一个进程处于 Block 状态时,CPU 会等到某些事件发生之后,才将它唤醒变为 Ready 状态,再次等待 CPU 调度到它去执行。

进程的执行与切换

虚拟化 CPU 的方法很直观,就是先运行一段时间程序 A,再去运行程序 B,通过这种时间分片技术达到让成千上万的进程能共享物理 CPU 运行。但还有几个问题,一是怎么实现虚拟化的同时能不增加系统负载,二是如何高效运行程序的同时保证对 CPU 的控制权。

内核态和用户态

显然我们不能直接让程序运行在物理 CPU 上,虽然这种直接运行程序的方案执行速度肯定快,但是用户程序是不可控的,操作系统无法对它做权限管理。为了达到我们期望的效果,我们需要隔离程序跟操作系统所处的环境,因此诞生了用户态和内核态的概念,用户态是用户程序运行的地方,它限制了用户程序可以执行的动作,但它妄图访问 IO 操作或者做越权操作时会导致异常,操作系统可以感知到并杀死进程;而内核态是操作系统运行的环境,这个环境下可以运行权限更高的指令操作。

系统调用

那用户态程序如何完成需要权限的一些操作呢,例如从磁盘读取数据,答案是通过系统调用(system call)。通过系统调用,进程可以告诉操作系统它期望的动作,例如创建销毁进程、访问文件系统、与其他进程通信、申请内存等,如何判断可行则操作系统会做出回应。

进行系统调用时,用户态程序需要执行一个 trap instruction,这个指令会同步跳转进入内核并提升内核的优先级,此时操作全面转入内核态,等内核态调用完毕之后,会再发出一个 return-from-trap instruction,再次转入用户态并移交优先级。硬件在进行系统调用的期间需要非常小心,因为它需要保证有足够的空间保存调用方的寄存器信息,让操作系统发出 return-from-trap instruction 时能顺利完成回调。

还有个问题没回答,trap 指令怎么知道进内核态之后,调用程序的起始地址呢,肯定不能让用户态程序决定,这意味它们可以随意跳转到任何内核态的程序起始位置,肯定是不行的,内核应该非常小心地决定 trap 能执行哪块代码,它会在启动时初始化一张 trap table,告诉硬件当一些异常事件发生时,“你应该去哪个地址寻找要执行的代码”,例如发生磁盘中断、键盘中断事件、系统调用等,硬件会记住这些 trap handler 的地址,等到事件发生时直接找到位于那个地址的 trap handler 执行。

现代操作系统提供非常多的系统调用,不同的系统调用以不同的序号区分,用户程序只需要将这些序号存入运行时栈中特定位置,操作系统当调用 trap handler 时就会检查序号是否合法,然后找到相应的系统调用地址,进行调用。这就形成了某种意义上的隔离和安全保障,让用户程序在不了解内核态地址构成的基础上,也能进行安全的系统调用。

进程间切换

下面讨论进程间的切换(switch),最大的问题在于如果用户进程在运行,那么意味着同一时间操作系统并不在运行!既然操作系统都不在运行,怎么让操作系统停止一个运行中的进程,将控制权转移给另一个进程呢。

早期操作系统采用了与进程协作的方式解决这个问题,它信任进程会正常运行一段时间后将控制权再次转交给操作系统,二者之前通过频繁的系统调用完成切换,通常这类系统都提供一个叫做 yield 的系统调用指令,它的功能无它就是“告诉操作系统我已经让出 CPU 了,你现在可以运行其他进程了”。另外,当用户程序出现异常时也会触发 trap 指令进入内核态处理,自然也会移交控制权给操作系统,因此总结下来这种方法就是操作系统被动等待系统调用来被唤醒,进而执行进程切换。

这种方式当然不好,如果用户程序就是不愿意发出系统调用或者抛出异常,难道操作系统永远不能拿到控制权了么?因此改进的方案出现了,采用定时中断机制(timer interrupt),一个定时设备(timer device)会定期发出中断信号让当前运行的进程停止,这时候内核态中的中断处理器(interrupt handler)就开始运行,操作系统进而获取到控制权,可以做自己的事情。要让定时中断这套机制运行起来,操作系统必须在启动时告诉硬件当定时中断产生时,要运行的代码所在的地址,并且启动定时中断器,这时候操作系统才会感到安全并轻松地去执行用户程序,因为它知道由于中断机制的存在,它肯定可以获取到控制权,不会永远被用户程序骑在头上。

决定是否切换、何时切换进程是由调度器决定的,这个之后会讲,如果决定切换进程了,操作系统会进行上下文切换操作(context switch),内容是为即将停止的进程保存一些寄存器值、PC、内核栈,为待切换的进程重新恢复上述要素,通过这些操作保证当 return-from-trap instruction 指令生效时,开始执行的程序会切换至目标进程而不是原进程。

下图展现了从启动到完全两个进程间切换的时间轴,横轴分别是操作系统、硬件和用户程序,纵轴是时间流向。

undefined