操作系统:进程及其执行机制

39 阅读21分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 10 月更文挑战」的第26天,点击查看活动详情

以最基本的计算机资源CPU为例,假设一个计算机只有一个CPU(尽管现代计算机一般拥有2个、4个或者更多CPU),虚拟化要做的就是将这个CPU虚拟成多个虚拟CPU并分给每一个进程使用,因此,每个应用都以为自己在独占CPU,但实际上只有一个CPU。这样操作系统就创造了美丽的假象——它虚拟化了CPU。

1 抽象:进程

进程的非正式定义非常简单:进程就是运行中的程序。程序本身是没有生命周期的,它只是存在磁盘上面的一些指令(也可能是一些静态数据)。

**关键问题:**如何提供有许多CPU的假象?虽然只有少量的物理CPU可用,但是操作系统如何提供几乎有无数个CPU可用的假象?

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

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

1.1 抽象:进程概念

进程的机器状态有一个明显组成部分,就是它的内存。指令存在内存中。正在运行的程序读取和写入的数据也在内存中。因此进程可以访问的内存(称为地址空间,address space)是该进程的一部分。

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

请注意,有一些非常特殊的寄存器构成了该机器状态的一部分。例如,程序计数器(Program Counter,PC)(有时称为指令指针,Instruction Pointer或IP)告诉我们程序当前正在执行哪个指令;类似地,栈指针(stack pointer)和相关的帧指针(frame pointer)用于管理函数参数栈、局部变量和返回地址。

提示: 分离策略机制在许多操作系统中,一个通用的设计范式是将高级策略与其低级机制分开。你可以将机制看成为系统的“如何(how)”问题提供答案。例如,操作系统如何执行上下文切换?策略为“哪个(which)”问题提供答案。例如,操作系统现在应该运行哪个进程?

将两者分开可以轻松地改变策略,而不必重新考虑机制,因此这是一种模块化(modularity)的形式,一种通用的软件设计原则。

1.2 进程API

● 创建(create):操作系统必须包含一些创建新进程的方法。在shell中键入命令或双击应用程序图标时,会调用操作系统来创建新进程,运行指定的程序。

● 销毁(destroy):由于存在创建进程的接口,因此系统还提供了一个强制销毁进程的接口。当然,很多进程会在运行完成后自行退出。但是,如果它们不退出,用户可能希望终止它们,因此停止失控进程的接口非常有用。

● 等待(wait):有时等待进程停止运行是有用的,因此经常提供某种等待接口。

● 其他控制(miscellaneous control):除了杀死或等待进程外,有时还可能有其他控制。例如,大多数操作系统提供某种方法来暂停进程(停止运行一段时间),然后恢复(继续运行)。

● 状态(statu):通常也有一些接口可以获得有关进程的状态信息,例如运行了多长时间,或者处于什么状态。

1.3 进程创建

操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)加载(load)到内存中,加载到进程的地址空间中。

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

将代码和静态数据加载到内存后,操作系统在运行此进程之前还需要执行其他一些操作。必须为程序的运行时栈(run-time stack或stack)分配一些内存。C程序使用存放局部变量、函数参数和返回地址。操作系统分配这些内存,并提供给进程。操作系统也可能会用参数初始化栈。具体来说,它会将参数填入main()函数,即argc和argv数组。

操作系统也可能为程序的堆(heap)分配一些内存。在C程序中,堆用于显式请求的动态分配数据。程序通过调用malloc()来请求这样的空间,并通过调用free()来明确地释放它。数据结构(如链表、散列表、树和其他有趣的数据结构)需要堆。起初堆会很小。随着程序运行,通过malloc()库API请求更多内存,操作系统可能会参与分配更多内存给进程,以满足这些调用。

操作系统还将执行一些其他初始化任务,特别是与输入/输出(I/O)相关的任务。

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

1.4 进程状态

● 运行(running):在运行状态下,进程正在处理器上运行。这意味着它正在执行指令。 ● 就绪(ready):在就绪状态下,进程已准备好运行,但由于某种原因,操作系统选择不在此时运行。 ● 阻塞(blocked):在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备运行。一个常见的例子是,当进程向磁盘发起I/O请求时,它会被阻塞,因此其他进程可以使用处理器。 在这里插入图片描述

在这里插入图片描述 请注意,即使在这个简单的例子中,操作系统也必须做出许多决定。首先,系统必须决定在Process0发出I/O时运行Process1。这样做可以通过保持CPU繁忙来提高资源利用率。其次,当I/O完成时,系统决定不切换回Process0。目前还不清楚这是不是一个很好的决定。你怎么看?这些类型的决策由操作系统调度程序完成,

1.5 数据结构

操作系统是一个程序,和其他程序一样,它有一些关键的数据结构来跟踪各种相关的信息。例如,为了跟踪每个进程的状态,操作系统可能会为所有就绪的进程保留某种进程列表(process list),以及跟踪当前正在运行的进程的一些附加信息。操作系统还必须以某种方式跟踪被阻塞的进程。

对于停止的进程,寄存器上下文将保存其寄存器的内容。当一个进程停止时,它的寄存器将被保存到这个内存位置。通过恢复这些寄存器(将它们的值放回实际的物理寄存器中),操作系统可以恢复运行该进程。我们将在后面的章节中更多地了解这种技术,它被称为上下文切换(context switch)。

我们已经介绍了操作系统的最基本抽象:进程。它很简单地被视为一个正在运行的程序。有了这个概念,接下来将继续讨论具体细节:实现进程所需的低级机制和以智能方式调度这些进程所需的高级策略。结合机制和策略,我们将加深对操作系统如何虚拟化CPU的理解。

2 插叙:进程API

本章将讨论UNIX系统中的进程创建。UNIX系统采用了一种非常有趣的创建新进程的方式,即通过一对系统调用:fork()和exec()。进程还可以通过第三个系统调用wait(),来等待其创建的子进程执行完成。

1    #include <stdio.h>
2    #include <stdlib.h>
3    #include <unistd.h>
4
5    int
6    main(int argc, char *argv[])
7    {
8        printf("hello world (pid:%d)\n", (int) getpid());
9        int rc = fork();
10       if (rc < 0) {        // fork failed; exit
11           fprintf(stderr, "fork failed\n");
12           exit(1);
13       } else if (rc == 0) { // child (new process)
14           printf("hello, I am child (pid:%d)\n", (int) getpid());
15       } else {             // parent goes down this path (main)
16           printf("hello, I am parent of %d (pid:%d)\n",
17                   rc, (int) getpid());
18       }
19       return 0;
20   }

当它刚开始运行时,进程输出一条hello world信息,以及自己的进程描述符(process identifier,PID)。该进程的PID是29146。在UNIX系统中,如果要操作某个进程(如终止进程),就要通过PID来指明。

紧接着有趣的事情发生了。进程调用了fork()系统调用,这是操作系统提供的创建新进程的方法。新创建的进程几乎与调用进程完全一样,对操作系统来说,这时看起来有两个完全一样的p1程序在运行,并都从fork()系统调用中返回。新创建的进程称为子进程(child),原来的进程称为父进程(parent)。子进程不会从main()函数开始执行(因此hello world信息只输出了一次),而是直接从fork()系统调用返回,就好像是它自己调用了fork()。

子进程和父进程。假设我们在单个CPU的系统上运行(简单起见),那么子进程或父进程在此时都有可能运行。在上面的例子中,父进程先运行并输出信息。在其他情况下,子进程可能先运行

2.3 exec()系统调用

最后是exec()系统调用,它也是创建进程API的一个重要部分。这个系统调用可以让子进程执行与父进程不同的程序。例如,在p2.c中调用fork(),这只是在你想运行相同程序的拷贝时有用。但是,我们常常想运行不同的程序,exec()正好做这样的事。

3 机制:受限直接执行

这个概念的“直接执行”部分很简单:只需直接在CPU上运行程序即可。因此,当OS希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点(main()函数或类似的),跳转到那里,并开始运行用户的代码。

3.1 基本技巧:受限直接执行

为了使程序尽可能快地运行,操作系统开发人员想出了一种技术——我们称之为受限的直接执行·(limited direct execution)。这个概念的“直接执行”部分很简单:只需直接在CPU上运行程序即可。因此,当OS希望启动程序运行时,它会在进程列表中为其创建一个进程条目,为其分配一些内存,将程序代码(从磁盘)加载到内存中,找到入口点(main()函数或类似的),跳转到那里,并开始运行用户的代码。 在这里插入图片描述

这种方法在我们的虚拟化CPU时产生了一些问题。 第一个问题很简单:如果我们只运行一个程序,操作系统怎么能确保程序不做任何我们不希望它做的事,同时仍然高效地运行它?第二个问题:当我们运行一个进程时,操作系统如何让它停下来并切换到另一个进程,从而实现虚拟化CPU所需的时分共享?

3.2 问题1:受限制的操作

关键问题如何执行受限制的操作 一个进程必须能够执行I/O和其他一些受限制的操作,但又不能让进程完全控制系统。操作系统和硬件如何协作实现这一点?

提示: 采用受保护的控制权转移 硬件通过提供不同的执行模式来协助操作系统。在用户模式(user mode)下,应用程序不能完全访问硬件资源。在内核模式(kernel mode)下,操作系统可以访问机器的全部资源。还提供了陷入(trap)内核从陷阱返回(return-from-trap)到用户模式程序的特别说明,以及一些指令,让操作系统告诉硬件陷阱表(trap table)在内存中的位置。

对于I/O和其他相关操作,一种方法就是让所有进程做所有它想做的事情。但是,这样做导致无法构建许多我们想要的系统。

因此,我们采用的方法是引入一种新的处理器模式,称为用户模式(user mode)。在用户模式下运行的代码会受到限制。例如,在用户模式下运行时,进程不能发出I/O请求。这样做会导致处理器引发异常,操作系统可能会终止进程。

与用户模式不同的内核模式(kernel mode)操作系统(或内核)就以这种模式运行。

但是,我们仍然面临着一个挑战——如果用户希望执行某种特权操作(如从磁盘读取),应该怎么做?为了实现这一点,几乎所有的现代硬件都提供了用户程序执行系统调用的能力系统调用是在Atlas [K+61,L78]等古老机器上开创的,它允许内核小心地向用户程序暴露某些关键功能,例如访问文件系统、创建和销毁进程、与其他进程通信,以及分配更多内存

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

执行陷阱时,硬件需要小心,因为它必须确保存储足够的调用者寄存器,以便在操作系统发出从陷阱返回指令时能够正确返回。

为什么对系统调用的调用(如open()或read())看起来完全就像C中的典型过程调用。也就是说,如果它看起来像一个过程调用,系统如何知道这是一个系统调用,并做所有正确的事情?原因很简单:它是一个过程调用,但隐藏在过程调用内部的是著名的陷阱指令。更具体地说,当你调用open()(举个例子)时,你正在执行对C库的过程调用。其中,无论是对于open()还是提供的其他系统调用,库都使用与内核一致的调用约定来将参数放在众所周知的位置(例如,在栈中或特定的寄存器中),将系统调用号也放入一个众所周知的位置(同样,放在栈或寄存器中),然后执行上述的陷阱指令。库中陷阱之后的代码准备好返回值,并将控制权返回给发出系统调用的程序。因此,C库中进行系统调用的部分是用汇编手工编码的,因为它们需要仔细遵循约定,以便正确处理参数和返回值,以及执行硬件特定的陷阱指令。现在你知道为什么你自己不必写汇编代码来陷入操作系统了,因为有人已经为你写了这些汇编。

陷阱如何知道在OS内运行哪些代码?显然,发起调用的过程不能指定要跳转到的地址(就像你在进行过程调用时一样),这样做让程序可以跳转到内核中的任意位置,这显然是一个糟糕的主意(想象一下跳到访问文件的代码,但在权限检查之后。实际上,这种能力很可能让一个狡猾的程序员令内核运行任意代码序列[S07])。因此内核必须谨慎地控制在陷阱上执行的代码

内核通过在启动时设置陷阱表(trap table)来实现。当机器启动时,它在特权(内核)模式下执行,因此可以根据需要自由配置机器硬件。操作系统做的第一件事,就是告诉硬件在发生某些异常事件时要运行哪些代码。例如,当发生硬盘中断,发生键盘中断或程序进行系统调用时,应该运行哪些代码?操作系统通常通过某种特殊的指令,通知硬件这些陷阱处理程序的位置。一旦硬件被通知,它就会记住这些处理程序的位置,直到下一次重新启动机器,并且硬件知道在发生系统调用和其他异常事件时要做什么(即跳转到哪段代码)。

能够执行指令来告诉硬件陷阱表的位置是一个非常强大的功能。因此,你可能已经猜到,这也是一项特权(privileged)操作。

受限直接运行协议 在这里插入图片描述

3.3 问题2:在进程之间切换

直接执行的下一个问题是实现进程之间的切换。在进程之间切换应该很简单,对吧?操作系统应该决定停止一个进程并开始另一个进程。有什么大不了的?但实际上这有点棘手,特别是,如果一个进程在CPU上运行,这就意味着操作系统没有运行。如果操作系统没有运行,它怎么能做事情?(提示:它不能)虽然这听起来几乎是哲学,但这是真正的问题——如果操作系统没有在CPU上运行,那么操作系统显然没有办法采取行动。

关键问题:如何重获CPU的控制权 操作系统如何重新获得CPU的控制权(regain control),以便它可以在进程之间切换?

协作方式:等待系统调用 过去某些系统采用的一种方式称为协作(cooperative)方式

事实证明,大多数进程通过进行系统调用,将CPU的控制权转移给操作系统,例如打开文件并随后读取文件,或者向另一台机器发送消息或创建新进程。像这样的系统通常包括一个显式的yield系统调用,它什么都不干,只是将控制权交给操作系统,以便系统可以运行其他进程。

如果应用程序执行了某些非法操作,也会将控制转移给操作系统。例如,如果应用程序以0为除数,或者尝试访问应该无法访问的内存,就会陷入(trap)操作系统。操作系统将再次控制CPU(并可能终止违规进程)。

非协作方式:操作系统进行控制

事实证明,没有硬件的额外帮助,如果进程拒绝进行系统调用(也不出错),从而将控制权交还给操作系统,那么操作系统无法做任何事情。事实上,在协作方式中,当进程陷入无限循环时,唯一的办法就是使用古老的解决方案来解决计算机系统中的所有问题——重新启动计算机。

**关键问题:**如何在没有协作的情况下获得控制权即使进程不协作,操作系统如何获得CPU的控制权?操作系统可以做什么来确保流氓进程不会占用机器?

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

首先,正如我们之前讨论过的系统调用一样,操作系统必须通知硬件哪些代码在发生时钟中断时运行。因此,在启动时,操作系统就是这样做的。其次,在启动过程中操作系统必须启动时钟,这当然是一项特权操作。一旦时钟开始运行,操作系统就感到安全了,因为控制权最终会归还给它,因此操作系统可以自由运行用户程序。

保存和恢复上下文 既然操作系统已经重新获得了控制权,无论是通过系统调用协作,还是通过时钟中断更强制执行,都必须决定:是继续运行当前正在运行的进程,还是切换到另一个进程。这个决定是由调度程序(scheduler)做出的,它是操作系统的一部分。

如果决定进行切换,OS就会执行一些底层代码,即所谓的上下文切换(context switch)。上下文切换在概念上很简单:操作系统要做的就是为当前正在执行的进程保存一些寄存器的值(例如,到它的内核栈),并为即将执行的进程恢复一些寄存器的值(从它的内核栈)。这样一来,操作系统就可以确保最后执行从陷阱返回指令时,不是返回到之前运行的进程,而是继续执行另一个进程。

受限直接执行协议(时钟中断) 在这里插入图片描述

在此协议中,有两种类型的寄存器保存/恢复。第一种是发生时钟中断的时候。在这种情况下,运行进程的用户寄存器由硬件隐式保存,使用该进程的内核栈。第二种是当操作系统决定从A切换到B。在这种情况下,内核寄存器被软件(即OS)明确地保存,但这次被存储在该进程的进程结构的内存中。后一个操作让系统从好像刚刚由A陷入内核,变成好像刚刚由B陷入内核。

3.4 并发问题:

在系统调用期间发生时钟中断时会发生什么?”或“处理一个中断时发生另一个中断,会发生什么?这不会让内核难以处理吗?”

受限直接执行基本思路:就让你想运行的程序在CPU上运行,但首先确保设置好硬件,以便在没有操作系统帮助的情况下限制进程可以执行的操作。