《操作系统导论》知识总结 第四章 抽象:进程

进程的非正式定义非常简单:进程就是运行中的程序。

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

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

抽象:进程

操作系统为正在运行的程序提供的抽象,就是所谓的进程(process)。

为了理解构成进程的是什么,我们必须理解它的机器状态(machine state):程序在运行时可以读取或更新的内容。

  • 内存:指令存在内存中。正在运行的程序读取和写入的数据也在内存中。
  • 寄存器:程序计数器告诉我们程序当前正在执行哪个指令;栈指针和帧指针用于管理函数参数栈、局部变量和返回地址。

进程 API

API,全称Application Programming Interface,即应用程序编程接口。
API就是操作系统给应用程序的调用接口,应用程序通过调用操作系统的 API而使操作系统去执行应用程序的命令(动作)。(资料来源于网络)

这里先介绍一下操作系统的所有接口必须包含哪些内容。

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

进程创建:更多细节

  1. 操作系统运行程序必须做的第一件事是将代码和所有静态数据(例如初始化变量)从磁盘加载(load)到内存中加载到进程的地址空间中。在早期的(或简单的)操作系统中,加载过程尽早(eagerly)完成,即在运行程序之前全部完成。现代操作系统惰性(lazily)执行该过程,即仅在程序执行期间需要加载的代码或数据片段,才会加载。
    加载:从程序到进程
  2. 为栈分配空间将代码和静态数据加载到内存后,必须为程序的运行时栈(run-time stack 或 stack)分配一些内存。C 程序使用栈存放局部变量、函数参数和返回地址。操作系统也可能会用参数初始化栈。具体来说,它会将参数填入 main()函数,即 argcargv 数组
  3. 为堆分配空间:操作系统也可能为程序的堆(heap)分配一些内存。程序通过调用 malloc()来请求这样的空间,并通过调用 free()来明确地释放它。数据结构(如链表、散列表、树和其他有趣的数据结构)需要堆。
  4. I/O 初始化:操作系统还将执行一些其他初始化任务,特别是与输入/输出(I/O)相关的任务。例如,在 UNIX 系统中,默认情况下每个进程都有 3 个打开的文件描述符(file descriptor),用于标准输入输出错误。这些描述符让程序轻松读取来自终端的输入以及打印输出到屏幕。
  5. 运行程序入口:通过将代码和静态数据加载到内存中,通过创建和初始化栈以及执行与 I/O 设置相关的其他工作,完成准备后,接下来就是启动程序,在入口处运行,即 main()

进程状态

进程可以处于以下 3 种状态之一。

  • 运行(running):在运行状态下,进程正在处理器上运行。这意味着它正在执行指令。
  • 就绪(ready):在就绪状态下,进程已准备好运行,但由于某种原因,操作系统选择不在此时运行。
  • 阻塞(blocked):在阻塞状态下,一个进程执行了某种操作,直到发生其他事件时才会准备运行。一个常见的例子是,当进程向磁盘发起 I/O 请求时,它会被阻塞,因此其他进程可以使用处理器
    进程:状态转换
    可以根据操作系统的载量,让进程在就绪状态和运行状态之间转换。从就绪到运行意味着该进程已经被调度(scheduled)。从运行转移到就绪意味着该进程已经取消调度(descheduled)。一旦进程被阻塞(例如,通过发起 I/O 操作),OS 将保持进程的这种状态,直到发生某种事件(例如,I/O 完成)。此时,进程再次转入就绪状态(也可能立即再次运行,如果操作系统这样决定)。
    总结:一个进程阻塞或停止时,就会去调度另一个就绪的进程,从而让 cpu 一直保持在满负荷状态。

书中23页有两个例子很好解释了这些状态转换,这里不详细说了。

数据结构

为了跟踪每个进程的状态,操作系统可能会为所有就绪的进程保留某种进程列表(process list),以及跟踪当前正在运行的进程的一些附加信息。操作系统还必须以某种方式跟踪被阻塞的进程。当 I/O 事件完成时,操作系统应确保唤醒正确的进程,让它准备好再次运行。

下面的代码展示了OS需要跟踪xv6内核中每个进程的信息类型。

// 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
// 可以看到实际操作系统对于进程状态的定义远不止上面介绍的3种
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
};

可以看到,对于停止的进程,寄存器上下文将保存其寄存器的内容。当一个进程停止时,它的寄存器将被保存到这个内存位置。通过恢复这些寄存器(将它们的值放回实际的物理寄存器中),操作系统可以恢复运行该进程,这就是上下文切换(context switch)。

从上述代码中还可以看到,除了运行、就绪和阻塞之外,还有其他的一些进程状态。

  • 初始(initial)状态:表示进程在创建时处于的状态。
  • 最终(final)状态(僵尸状态):一个进程可以处于已退出但尚未清理的状态。这个最终状态非常有用,因为它允许其他进程(通常是创建进程的父进程)检查进程的返回代码,并查看刚刚完成的进程是否成功执行(通常,在基于 UNIX 的系统中,程序成功完成任务时返回零,否则返回非零)。完成后,父进程将进行最后一次调用(例如,wait()),以等待子进程的完成,并告诉操作系统它可以清理这个正在结束的进程的所有相关数据结构

小结

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