抽象-进程

进程的非正式定义非常简单:进程就是运行中的程序。程序本身没有生命周期,它只是保存在磁盘上的一些指令或静态数据。是操作系统让这些字节运行起来、让程序发挥作用。

事实表明,人们常常系统同时运行多个程序。比如:在使用计算机或笔记本的时候,我们会同时运行浏览器、邮件、游戏等。实际上,一个正常的系统可能会有上百个进程同时在运行。如果能实现这样的系统,人们就不需要考虑当时是哪个 CPU 是可用的,使用起来非常简单。因此我们的挑战是:

关键问题:如何提供有许多 CPU 的假象?

操作系统通过虚拟化 CPU 来提供这种假象。通过让一个进程只运行一个时间片,然后切换到其他进程,操作系统提供了存在多个虚拟 CPU 的假象。这就是 分时共享 CPU 技术,允许用户如愿运行多个并发进程。潜在的开销是性能损失,因为如果 CPU 必须共享,每个进程的运行过程都会慢一点。

要实现 CPU 的虚拟化并能实现的足够好,操作系统就需要一些低级机制和一些高级智能。我们将低级机制称为机制。机制是一些低级方法或协议,实现了所需要的功能。比如稍后我们将学习如何实现上下文切换,它让操作系统能够停止运行一个程序,并开始在指定的 CPU 上运行另一个程序。所有现代操作系统都采用了这种分时机制。

提示:使用分时共享(和空分共享) 分时共享是操作系统共享资源所使用的最基本的技术之一。通过运行资源由一个实体使用一小段时间,然后由另一个实体使用一小段时间,如此下去,所谓的资源(CPU/网络)可以被许多人共享。 分时共享的自然对应技术是空分共享,资源在空间上被划分给希望使用它的多个人。比如磁盘空间自然就是一个空分共享资源,因为一旦将块分配给文件,在用户删除文件之前,不可能将它分配给其它文件。

在这些机制之上,操作系统中有一些智能以策略的形式存在。策略是在操作系统内做出某种决定的算法。比如,给定一组可能的程序要在 CPU 上运行,操作系统应该运行哪个程序?操作系统中调度策略会做出这个决定,可能利用历史信息、工作负载知识、性能指标来做出决定。

抽象:进程

操作系统为正在运行的程序提供的抽象,就是所谓的进程。 一个进程只是一个正在运行的程序。在任何时刻,我们都可以清点它在执行过程中访问或影响了操作系统的哪些部分,从而概况为一个进程。

为了理解进程的构成,我们必须理解它的机器状态:程序在运行时可以读取或更新的内容。在任何时刻,机器的哪些部分对该程序的执行很重要?

进程的机器状态有一个明显的组成部分——内存。指令被保存在内存中,正在运行的程序读写的数据也保存在内存中。因此进程可以访问的内存(地址空间)是该进程的一部分。

进程的机器状态的另一部分是寄存器。许多指令明确的读取或更新寄存器,因此显然它们对于执行该进程很重要。

请注意,有一些非常特殊的寄存器构成了该机器状态的一部分。比如程序计数器告诉我们程序当前正在执行哪个指令;类似的,栈指针和先关的帧指针用户管理函数参数栈、局部变量和返回地址。

提示:分离策略和机制 在许多操作系统中,一个通用的设计范式是将高级策略与其低级机制分开。你可以将机制看成对系统的“HOW”问题提供的答案。例如操作系统如何执行上下文切换?策略为“WHICH”问题提供答案。例如操作系统现在应该运行哪个进程?将两者分开可以轻松改变策略,而不用重新考虑机制,因此这是一种模块化的形式,一种通用的软件设计原则。

最后,程序也经常访问持久存储设备。此类 IO 信息可能包含当前打开的文件列表。

进程 API

虽然讨论真实的进程 API 将会在第五章进行,但这里先介绍一下操作系统的所有接口必须包含哪些内容。所有现代操作系统都以某种形式提供这些 API。

  • 创建:操作系统必须包含一些创建新进程的方法。在 shell 中键入命令或双击应用程序图标时,会调用操作系统来创建新进程,运行指定的程序。
  • 销毁:由于存在创建进程接口,因此系统还提供了一个强制销毁进程的接口。当然,很多进程会在运行完成后自行退出。但是,如果它们不退出,用户可能希望终止它们,因此停止失控进程的接口非常有用。
  • 等待:有时等待进程停止运行是有用的,因此经常提供某种等待接口。
  • 控制:除了杀死或等待进程外,有时还可能存在其他控制。比如大多数操作系统提供了某种方法来暂停进程、恢复进程。
  • 状态:通常也有一些接口可以获得有关集成的状态信息,例如运行了多长时间,或者处于什么状态。

进程创建:细节

**操作系统运行进程要做的第一件事情就是将代码和所有静态数据(如初始化变量)加载到内存中,即加载到进程的地址空间中。**程序最初以某种可执行的格式保存在磁盘上。因此,将程序和静态数据加载到内存中的过程,需要操作系统从磁盘上读取这些字节,并将它们放在内存中的某处。

NAME

**在早期的操作系统中,加载过程会尽早(eagerly)完成,即在运行程序之前全部完成。现代操作系统则会惰性(lazily)执行该过程,即仅在程序执行期间需要的时候才会加载代码或数据片段。**要真正理解代码和数据的惰性加载过程是如何工作的,必须更多的了解分页和交换的机制,这是我们将来讨论内存虚拟化时要涉及的主题。现在,只要记住在运行任何程序之前,操作系统显然必须要执行一些工作,才能将重要的程序字节从磁盘读入内存。

将代码和静态数据加载到内存之后,操作系统在运行该进程之前还要执行一些其他操作。必须为程序的运行时栈分配一些内存。你可能已经知道,C 程序使用栈存放局部变量、函数参数和返回地址。操作系统分配这些内存并提供给进程。操作系统也可能会用参数初始化栈。具体来说,它会将参数填入 mian 函数,即 argcargv 数组。

**操作系统也可能会程序的堆分配一些内存。在 C 程序中,堆用于显式请求的动态分配数据。**程序通过调用 malloc 来请求这样的内存空间,并通过调用 free 来显式释放。数据结构需要堆。起初堆会很小,随着程序的运行,通过 malloc 库 API 请求更多内存,操作系统可能会参与分配更多内存给进程,以满足这些调用。

操作系统还将执行一些其他初始化任务,特别是与输入/输出先关的任务。比如,在 UNIX 系统中,默认情况下每个进程都有 3 个打开的文件描述符,分别用于标准输入、输出和错误。这些描述符让程序轻松读取来自中断的输入以及打印输出到屏幕。

通过将代码和静态数据加载到内存中,通过创建和初始化栈以及执行与 IO 设置相关的其他工作,OS 现在终于为程序执行搭好了舞台。然后它还有最后一项任务:启动程序,在入口处执行,即 main。通过调转到 main 例程,OS 将 CPU 的控制权转移到新创建的进程中,从而程序开始执行。

进程状态

进程在特定的时间可能处于不同的状态。在早期的计算机系统中,出现了一个进程可能处于多种状态的概念。简而言之,进程可以处于以下 3 种状态之一:

  • 运行:在运行状态下,进程正在处理器上运行,这意味着它正在执行指令。
  • 就绪:在就绪状态下,进程已经准备好运行,但由于某种原因,操作系统选择不在此时运行它。
  • 阻塞:在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备开始运行。一个常见的例子就是,当进程向磁盘发起 IO 请求时,进程会被阻塞,这时其他进程可以使用该处理器。
NAME

如上图所示,可以根据操作系统的负载,让进程在就绪和运行状态之间切换。从就绪到运行意味着该进程已经被调度;从运行到就绪则意味着该进程已经被取消调度;一旦进程被阻塞,OS 将保持进程的这种状态,直到发生某种事件。此时,进程再次转入就绪状态(或立即再次运行)。

我们来看一个例子,看两个进程如何通过这些状态转换。首先想象两个正在运行的进程,每个进程只使用 CPU。在这种情况下,每个进程的状态可能如下所示:

时间Process0Process1备注
1运行就绪
2运行就绪
3运行就绪
4运行就绪Process0 现在完成
5运行
6运行
7运行
8运行Process1 现在完成

在下一个例子中,第一个进程在运行一段时间之后发起 IO 请求。此时该进程被阻塞,让另一个进程有机会运行:

时间Process0Process1备注
1运行就绪
2运行就绪
3运行就绪Process0 发起 IO
4运行运行Process0 被阻塞
5阻塞运行所以 Process1 运行
6阻塞运行
7就绪运行Process0 IO 完成
8就绪运行Process1 现在完成
9运行
10运行Process0 现在完成

更具体的说,Process0 发起 IO 并被阻塞,等待 IO 完成。OS 发现 Process0 不使用 CPU,因此开始运行 Process1。当 Process1 运行时,Process0 的 IO 完成,状态变为就绪。最后 Process1 结束,Process0 运行,然后完成。

请注意,即使是在这个简单的例子中,操作系统也必须做出很多决定。首先,系统必须决定在 Process0 发出 IO 时运行 Process1。这样做可以通过保持 CPU 繁忙来提供资源利用率。其次,当 IO 完成时,系统决定不立即切换为 Process0。目前还不清楚这是不是一个很好的决定。这类决策由操作系统调度程序完成,后续几章将会展开详细的讨论。

数据结构

操作系统是一个程序,和其他程序一样,它拥有一些关键的数据结构来跟踪各种相关的信息。比如,为了跟踪每个进程的状态,操作系统可能会为所有就绪的进程保留某种进程列表,以及跟踪当前正在运行的进程的一些附加信息。操作系统还必须以某种方式跟踪阻塞的进程。当 IO 事件完成时,操作系统应确保唤醒正确的进程,使其准备好再次运行。

// the registers xv6 will save and restore
// to stop and subsequently restart a process
struct context {
  int eip;
  int esp;
  int ebx;
  int ecx;
  int edx;
  int esi;
  int edi;
  int ebp;
};

// the different states a process can be in
enum proc_state { UNUSED, EMBRYO, SLEEPING,
                  RUNNABLE, RUNNING, ZOMBIE };

// the information xv6 tracks about each process
// including its register context and state
struct proc {
  char *mem;                   // Start of process memory
  uint sz;                     // Size of process memory
  char *kstack;                // Bottom of kernel stack
                               // for this process
  enum proc_state state;       // Process state
  int pid;                     // Process ID
  struct proc *parent;         // Parent process
  void *chan;                  // If non-zero, sleeping on chan
  int killed;                  // If non-zero, have been killed
  struct file *ofile[NOFILE];  // Open files
  struct inode *cwd;           // Current directory
  struct context context;      // Switch here to run process
  struct trapframe *tf;        // Trap frame for the
                               // current interrupt
};

上面的代码展示了 OS 需要跟踪 xv6 内核中每个进程的信息类型。“真正的”操作系统中存在类似的进程结构。

从上面的代码可以看到操作系统跟踪进程的一些重要信息。对于停止的进程,寄存器上下文将保存其寄存器的内容。当一个进程停止时,它的寄存器将被保存到该内存位置。通过恢复这些寄存器(将它们的值放回实际的物理寄存器中),操作系统可以恢复运行该进程。我们将在后面的章节中详细了解该技术,即上下文切换。

从上面的代码中还可以看到,除了运行、就绪、阻塞之外,进程还可能处于一些其他状态。有时候系统会有一个初始状态,表示进程在创建时处于的状态。另外,一个进程可以处于已退出但尚未清理的最终状态(UNIX 中的僵尸状态)。这个最终状态非常有用,因为它运行其他进程(通常是该进程的父进程)检查进程的返回代码,并查看刚刚完成的进程是否执行成功。完成后,父进程将进行最后一次调用,以等待子进程的完成,并告诉操作系统可以清理这个正在结束的进程的所有相关数据结构。

补充:数据结构——进程列表 操作系统充满了我们将要在本书中讨论的各种重要的数据结构。进程列表是第一个这样的结构。这种结构比较简单,但是任何能够同时运行多个程序的操作系统当然都会有这种类似的结构,以便跟踪系统中运行的所有程序。有时人们会将存储关于进程信息的个体结构称为进程控制块(PCB),这是谈论包含每个进程信息的 C 结构的一种方式。