2. 进程与进程 API

程序本身只是存储在磁盘上面的一些指令(或静态数据)。操作系统通过提供称作 进程(Process) 的抽象来让程序运行发挥作用。一言以蔽之,进程即运行中的程序。

什么是进程?

但为了具体理解什么是进程,我们需要考察进程的 机器状态(Machine State),即程序在运行时可以读取或更新的内容,这包括:

进程 API

操作系统必须提供一些控制进程的方法,这被称作 进程 API。其必须包括如下几类接口:

我们以 Unix 系统的进程 API 为例进行讲解:

fork()

系统调用 fork() 用于创建进程。看如下的示例代码:

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();  // 创建子进程

    if (pid < 0) {
        perror("fork failed!");
        return 1;
    } 
    else if (pid == 0) {
        // 子进程代码
        printf("Hi! I'm child process,my PID=%d\n", getpid());
    } 
    else {
        // 父进程代码
        printf("Hi! I'm father process,my PID=%d\n", pid);
    }
    return 0;
}

在 Unix 系统中,每个进程都有一个 进程标识符(Process Identifier,PID)。对某个进程的操作必须通过 PID 来指明。

在本例中,进行 fork() 系统调用创建子进程后,在操作系统的视角 有两个完全一样的程序在运行,它们都从 fork() 调用中返回。也即,创建的子进程不会从 main() 开始执行,而是直接从 fork() 中返回并继续执行后续代码。两个进程拥有独立的地址空间、寄存器、程序计数器等,然而这并不代表子进程拷贝了父进程。这与 fork() 地址的返回值有关:

因此,上面这段程序的运行结果为:

Hi! I'm child process,my PID=1234
Hi! I'm father process,my PID=1235

Hi! I'm father process,my PID=1235
Hi! I'm child process,my PID=1234

具体会出现哪种结果是 不确定的,这取决于调度程序的调度顺序。顺带一提,这种不确定性会引发许多将在并发章节讨论的问题。

wait()

系统调用 wait() 可以用于父程序等待子程序执行完毕。其拓展版本 waitpid() 可以等待特定程序的执行。看如下的示例代码:

#include <stdio.h>
#include <unistd.h>

int main() {
    pid_t pid = fork();  // 创建子进程

    if (pid < 0) {
        perror("fork failed!");
        return 1;
    } 
    else if (pid == 0) {
        // 子进程代码
        printf("Hi! I'm child process,my PID=%d\n", getpid());
    } 
    else {
	    int wc = wait();
        // 父进程代码
        printf("Hi! I'm father process,my PID=%d\n", pid);
    }
    return 0;
}

一般来说此时父程序执行到 wait() 时会被阻塞等待子程序执行完成后继续执行。因此该程序的输出结果具有确定性:

Hi! I'm child process,my PID=1234
Hi! I'm father process,my PID=1235
wait() 何时返回?

系统调用 wait() 在子进程结束后返回只是一种最常见的情况,在某些情况下 wait() 会在子程序结束前返回。具体而言:

场景 返回值 错误码 (errno)
子进程终止 终止子进程PID -
被信号中断 -1 EINTR
没有子进程可等待 -1 ECHILD
选项WNOHANG+无子进程终止 0 -

wait() 系统调用同样有返回值,如果执行成功则返回子进程的 PID,如果有错误发生则返回 -1。该系统调用可以用于回收子进程资源以防止僵尸进程(见下面进程状态,且获取子进程退出状态(传参数 NULL 代表不关心这一点)。

exec()

exec() 系统调用同样用于创建进程,其代表了一类执行其它程序的系统调用族,如 execl()execv() 等等。且允许子进程执行与父进程不同的程序,见如下示例代码:

#include <stdio.h>
#include <unistd.h>

int main() {
    printf("Executing ls command...\n");
    
    // 替换当前进程为/bin/ls
    execl("/bin/ls", "ls", "-l", NULL);
    
    // 只有exec失败才会执行到这里
    perror("exec失败");
    return 1;
}

系统调用 exec() 会从可执行程序中加载代码和静态数据,用其 覆盖 当前程序的代码段、静态数据,堆、栈等其它内存空间被重新初始化后,执行新的程序,但进程的 PID 保持不变。也即, exec() 没有创建新的程序,而是用新程序替换当前进程的内存空间。也因此,对 exec() 的调用一旦成功,就不会返回。

因此,上述代码成功执行的结果为:

Executing ls command...
<当前目录下执行 ls -l 命令的结果>

上述只是 Unix 系统最常见的与进程交互的系统调用,还有其它的系统调用,例如可以向进程发送信号的 kill()。还有一些有用的 Unix CLI 工具,例如 ps 可以查看当前正在运行的进程,top 查看进程占用 CPU 或其它资源的情况等等。

这些系统调用的功能虽然奇怪,但在 Unix 系统中十分实用,尤其是当实现 shell 时:shell 启动后输出一个命令提示符,我们可以向 shell 输入命令,shell 通过 fork() 创建一个子进程,在子进程中使用 exec() 执行命令,在父进程中通过 wait() 等待子进程执行完成,然后继续重复上述步骤。

此外,shell 允许用户通过 <> 符号进行流的重定向。具体原理就是在 exec() 后关闭了系统的标准输入流或标准输出流,然后打开用户执行的重定向对象。新打开的文件会占用可用的标准输入或标准输出的文件描述符(链接待补充)。

Unix 的 管道(Pipe) 也是通过类似方式实现的,不过其有一个专门的 pipe() 系统调用。此时一个进程的输出与另一个进程的输入被链接到了同一个 内核管道 上,这允许许多程序串联到一起共同完成某项任务。

虚拟化 CPU

前面 提到过,操作系统能提供机器具有许多 CPU 的假象允许多个进程同时运行。

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

操作系统通过 虚拟化 CPU 来提供这种假象。从实际结果来说,操作系统通过让一个进程运行一段时间,然后切换到其它进程来实现这一点。这是一种 时分共享(Time Sharing)技术

为了实现虚拟化,操作系统需要一些 机制(Mechanism)策略(Policy)。机制是一些低级方法或协议,实现了需要的功能,即“How”的问题;策略是操作系统内做出某种决定的算法,负责解答'Which"的问题。具体到本例,机制负责实现某个程序运行一段时间后暂停,恢复另一个程序的运行的功能,这被称作 上下文切换(Context Switch);策略负责决定当一组程序需要在 CPU 上运行时,到底运行哪个程序,这被称作 调度策略(Scheduling Policy)

将高级策略与低级机制分离设计是许多操作系统设计中的一种范式。

操作系统也是一个程序,其会维护一些关键的数据结构来跟踪各种相关的信息。例如 进程列表 可以跟踪每个进程的状态与正在运行的进程的某些附加信息。此外,操作系统还必须跟踪被阻塞的进程,例如使用寄存器上下文保存阻塞程序的寄存器信息,以确保进程能够被唤醒。

进程创建

操作系统在提供进程这一抽象之外,还需要提供真正启动程序,将程序转化为进程的方法。启动程序的详细经过如下:

进程状态

早期的计算机系统中,程序可以处于以下三种状态之一:

三种状态的状态机描述如下:

Pasted image 20250531170542.png

Unix 扩充了该描述,其核心程序状态包括:

  1. 运行 (Running / Runnable - R): 进程正在 CPU 上执行,或者已经准备好执行,正在运行队列中等待 CPU 调度器分配时间片。
  2. 可中断睡眠 (Interruptible Sleep - S): 进程正在等待某个事件(如等待 I/O 操作完成、等待信号量、等待网络数据、等待用户输入、等待子进程退出等)。关键特性是它可以被信号唤醒或中断。
  3. 不可中断睡眠 (Uninterruptible Sleep - D): 进程同样在等待某个事件(通常是等待底层 I/O,特别是磁盘 I/O)。关键特性是它在等待期间不能被信号唤醒或中断(即使是 SIGKILL 也不行)。
  4. 停止 (Stopped - T): 进程的执行被暂停(挂起),通常是因为收到了一个暂停信号(如 SIGSTOP, SIGTSTP / Ctrl+Z)。它可以被恢复信号(如 SIGCONT)唤醒,继续执行。
  5. 僵尸 (Zombie - Z): 进程已经终止执行(exit() 系统调用),但其在内核中的进程描述符仍然保留着。保留的原因是为了让父进程能够读取该终止子进程的退出状态码。一旦父进程通过 wait()waitpid() 系统调用读取了退出状态,僵尸进程的残留信息就会被内核彻底释放。 僵尸进程本身不消耗 CPU 和内存资源(代码、数据、堆栈等已释放),只占用一个 PID 和进程表中的一个槽位。

关于所有的 Unix 程序状态请执行 man ps 命令,见 PROCESS SELECTION BY LIST 一节。