Interlude: Process API

课件:[[第2-3次课-05. Interlude_process_api.pptx]]
教材:OSTEP Chapter 5

在理解“进程是操作系统提供的抽象”之后,下一步要回答的问题就是:程序员如何实际创建、控制并组合这些进程?
UNIX 的经典答案是三组核心接口:fork()wait()exec()。它们组合在一起,构成了现代进程控制模型的基础,也解释了 Shell 为什么能启动程序、等待程序结束、做重定向以及搭建管道。

本章主线

  • fork():先复制出一个新进程;
  • wait():父进程需要时,可以等待子进程结束;
  • exec():让某个进程改为执行另一个程序。

这三者配合起来,就形成了 UNIX 进程 API 的核心工作流。

1. fork():创建一个新进程

1.1 fork() 做了什么?

fork() 是 UNIX 中最经典的进程创建系统调用。它的效果不是“启动一个全新的陌生程序”,而是:让当前进程复制出一个新的进程

这个新进程称为子进程(Child Process),原来的进程称为父进程(Parent Process)
从课件和教材的角度看,子进程会得到父进程运行现场的一份副本,包括:

  • 地址空间的副本;
  • 寄存器状态的副本;
  • 程序计数器附近的执行位置;
  • 打开的文件描述符等进程资源的继承关系。

因此,fork() 返回后,父子进程看起来都像是“从同一行代码继续往下执行”。

1.2 课件示例:p1.c

课件首先给出一个最基础的示例程序 p1.c,其核心结构是:

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

int main(int argc, char *argv[]){
    printf("hello world (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {
        printf("hello, I am child (pid:%d)\n", (int) getpid());
    } else {
        printf("hello, I am parent of %d (pid:%d)\n",
               rc, (int) getpid());
    }
    return 0;
}

这个例子最关键的观察点是课件特别提示的:请注意 rc 的值。

1.3 fork() 的返回值如何区分父子进程?

fork() 最巧妙的地方在于:同一个调用,会在两个进程里分别返回。

但它返回的值不同:

  • 返回值 < 0:创建失败;
  • 返回值 = 0:当前执行流位于子进程中;
  • 返回值 > 0:当前执行流位于父进程中,且返回值就是子进程的 PID。

所以在 p1.c 里:

  • 子进程会进入 rc == 0 分支;
  • 父进程会进入 rc > 0 分支;
  • 父进程打印出来的 rc,实际上就是它刚创建出的那个子进程的进程号。

一句话理解

fork() 不是“从外部告诉你谁是谁”,而是通过不同的返回值,让父子进程自己在同一份代码中分流执行。

1.4 为什么 fork() 的输出顺序不确定?

课件第 3 页展示了 p1.c 的两种运行结果:
在这里插入图片描述

  • 一种是父进程先打印;
  • 另一种是子进程先打印。

这表明调度结果本来就不确定。因为 fork() 返回后:

  • 父进程已经是一个可运行进程;
  • 子进程也是一个可运行进程;
  • 接下来谁先获得 CPU,由操作系统调度器决定。

因此,这里体现出的核心概念是:并发执行带来的顺序不确定性(nondeterminism)

只要没有额外同步机制,父子进程的打印顺序就不能被程序员强行假定。

1.5 fork() 相关思考题的本质

课件第 4~6 页都是围绕 fork() 的思考题。虽然具体代码细节不同,但它们本质上都在考以下三点:

  1. 变量会不会被复制?
    会。子进程得到的是父进程地址空间的一份副本,因此 fork() 之后父子进程各自修改变量,互不影响。
  2. 某一行代码会被执行几次?
    要看该行位于 fork() 之前还是之后。
    • fork() 之前:通常只执行一次;
    • fork() 之后:父子进程都可能执行,因此往往会执行两次。
  3. 某个输出由谁打印?打印什么值?
    要先判断当前是在父进程还是子进程,再结合 fork() 的返回值与各自 PID 推断。

1.6 连续两次 fork() 会产生多少个进程?

课件第 7 页给出如下代码:

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

int main()
{
    fork();
    fork();
    return 0;
}

这个问题最容易出错的地方,是把“调用了两次 fork()”误认为“只会多出两个子进程”。实际上,第二次 fork() 是由第一次产生出来的所有进程共同执行的。

推导过程:

  1. 初始只有 1 个父进程;
  2. 第 1 次 fork() 后,变成 2 个进程;
  3. 这 2 个进程都会继续执行第 2 次 fork()
  4. 每个进程再复制出一个子进程,因此总数变成 4 个。

所以答案是:包括最初父进程在内,共有 4 个进程。

记忆规律

如果一段代码中有连续 n 次、且每个进程都会走到的 fork(),最终进程数通常是 2^n

2. wait():父进程等待子进程结束

2.1 为什么需要 wait()

前面的 p1.c 告诉我们:仅靠 fork(),父子进程执行顺序是不确定的。
但很多时候,我们希望父进程先别往下走,而是等子进程执行完毕再继续。

这时就要用到 wait()

wait() 的语义是:父进程阻塞自己,直到某个子进程结束。

2.2 课件示例:p2.c

课件在 p2.c 中,把父进程分支改成了先调用 wait(NULL)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main(int argc, char *argv[]){
    printf("hello world (pid:%d)\n", (int) getpid());
    int rc = fork();
    if (rc < 0) {
        fprintf(stderr, "fork failed\n");
        exit(1);
    } else if (rc == 0) {
        printf("hello, I am child (pid:%d)\n", (int) getpid());
    } else {
        int wc = wait(NULL);
        printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
               rc, wc, (int) getpid());
    }
    return 0;
}

2.3 wait() 带来了什么变化?

p1.c 相比,最大的变化是:输出顺序变得确定了。

原因是:

  • 父进程调用 wait(NULL) 后会阻塞;
  • 在子进程退出之前,父进程不会继续往下执行打印语句;
  • 因此,子进程输出一定先于父进程输出。

这说明 wait() 的本质作用是:在父子进程之间建立同步关系。

2.4 wait() 的返回值表示什么?

![[Pasted image 20260415213012.png]]

课件输出中,父进程打印了 wc 的值。这个值等于:已经结束的那个子进程的 PID。

因此:

  • rc:是 fork() 在父进程中返回的子进程 PID;
  • wc:是 wait() 返回的已结束子进程 PID。

在只有一个子进程的简单例子里,二者通常相同。

3. exec():让当前进程去执行另一个程序

3.1 为什么仅有 fork() 不够?

如果系统只有 fork(),那么一个进程最多只能复制出“和自己差不多的另一个自己”。
但操作系统真正需要的是:先创建一个新进程,再让它去运行一个完全不同的程序。

这就是 exec() 系列调用的用途。

3.2 exec() 的核心语义

exec()用一个新程序替换当前进程原有的地址空间和执行内容。

也就是说:

  • 进程的 PID 通常不变;
  • 但它执行的代码、数据、栈、堆等都会被新程序替换;
  • 成功之后,它不再执行旧程序,而改为执行新程序。

3.3 课件示例:p3.c

课件通过 p3.c 展示了这一点。子进程先构造参数数组,然后调用 execvp() 去执行 wc p3.c

核心代码逻辑是:

char *myargs[3];
myargs[0] = strdup("wc");
myargs[1] = strdup("p3.c");
myargs[2] = NULL;
execvp(myargs[0], myargs);
printf("this shouldn't print out");

输出中出现的是 wcp3.c 的统计结果,例如:

29 107 1030 p3.c

这说明子进程已经不再继续执行原程序后续的“普通逻辑”,而是转而执行了 wc 程序。

3.4 为什么 this shouldn't print out 不会执行?

因为一旦 execvp() 成功:

  • 当前进程的代码段、数据段、栈、堆都会被新程序替换;
  • 原程序中 execvp() 之后的那条 printf() 所在代码,已经不再是当前进程要执行的程序内容。

因此,exec() 成功后通常不会返回。
只有在执行失败时,它才会返回,并让调用者继续处理错误情况。

重点区分

  • fork():创建一个新进程;
  • exec():让某个已有进程改去执行另一个程序;
  • fork() + exec():先“多出来一个进程”,再让这个新进程“改头换面”。

4. fork() + wait() + exec() 如何配合工作?

把前面的内容合起来,就得到了 UNIX 里最经典的执行流程:

  1. 父进程先调用 fork()
  2. 子进程在自己的执行分支里调用 exec(),去运行目标程序;
  3. 父进程根据需要调用 wait(),等待子进程结束。

Shell 启动外部命令,本质上就是这么做的。

5. 为什么 fork()exec() 要分成两个调用?

这是本章最重要的设计思想之一。

很多初学者会问:既然最终目的是“运行另一个程序”,为什么不直接提供一个一步到位的调用,而要拆成:

  • fork() 复制出子进程;
  • exec() 把它替换成目标程序?

答案是:正因为这两步分开,Shell 才能在 exec() 之前对子进程做定制化设置。
而重定向,就是最典型的例子。

6. 输出重定向示例:p4.c

这一部分解释了:为什么 Shell 不需要修改 wc 程序本身,就能把它的输出从屏幕改到文件里。

6.1 先看 p4.c 到底做了什么

课件中的子进程逻辑可以概括成 3 步:

  1. 先调用 close(STDOUT_FILENO),关闭当前的标准输出;
  2. 再调用 open("./p4.output", ...),打开目标文件;
  3. 最后调用 execvp("wc", ...),让子进程去执行 wc 程序。

对应的关键代码是:

close(STDOUT_FILENO);
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
execvp(myargs[0], myargs);

运行现象是:

  • 在终端里执行程序时,屏幕上看不到 wc 的统计输出;
  • 但查看文件 p4.output 时,会发现输出内容已经写进去了。

这说明:wc 的输出路径被改掉了。

6.2 关键前提:进程默认自带 3 个标准文件描述符

在一个普通进程刚启动时,通常已经默认打开了 3 个文件描述符:

  • 0:标准输入 stdin
  • 1:标准输出 stdout
  • 2:标准错误 stderr

其中最关键的是:

  • 当程序调用 printf() 向标准输出打印内容时,本质上就是把数据写到文件描述符 1
  • 平时之所以会显示在屏幕上,是因为此时文件描述符 1 恰好连着终端。

所以,“输出到屏幕”这件事的本质并不是 wc 知道屏幕在哪,而是它只是老老实实地往 stdout / fd 1 写数据。

6.3 第一步:为什么先 close(STDOUT_FILENO)

STDOUT_FILENO 就是标准输出对应的文件描述符,也就是 1
执行:

close(STDOUT_FILENO);

相当于告诉操作系统:

把当前进程的“标准输出这条通道”先关掉。

执行完这一句之后,子进程里的文件描述符使用情况就变成了:

  • 0:还在
  • 1:空出来了
  • 2:还在

这一步非常关键,因为它专门给后面的 open() 腾出了编号 1 这个位置。

6.4 第二步:为什么 open() 打开的文件会自动占据 1

UNIX 有一个很重要的规则:每次分配新的文件描述符时,优先使用当前最小的可用编号。

此时:

  • 0 已经被占用;
  • 1 刚刚被关闭,处于空闲状态;
  • 2 仍然被占用。

所以当子进程执行:

open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);

操作系统会发现当前最小可用编号是 1,于是就把新打开文件 p4.output 绑定到文件描述符 1 上。

于是,子进程内部的映射关系悄悄变成了:

  • 0:终端输入
  • 1p4.output
  • 2:终端错误输出

这时,文件描述符 1 已经不再指向屏幕,而是指向文件。

6.5 第三步:execvp() 为什么会“继承”这个重定向结果?

接下来执行的是:

execvp(myargs[0], myargs);

这一步会把当前子进程的代码和数据替换成新的程序 wc,但它不会凭空重新发明一套新的标准输入 / 输出 / 错误环境
也就是说,子进程在 execvp() 之前已经调整好的文件描述符布局,会被新程序直接继承。

因此,当 wc 开始运行时,它看到的仍然是:

  • 标准输出 = 文件描述符 1
  • 而文件描述符 1 = p4.output

所以 wc 并不知道自己“被重定向了”;它只是像平常一样往标准输出写,结果这些数据自然就进了文件。

6.6 把整个过程串起来看一遍

可以把整个重定向过程按时间顺序理解为:

  1. 父进程先 fork() 出一个子进程;
  2. 子进程先把自己的标准输出 1 关闭;
  3. 子进程再 open() 一个文件,于是这个文件拿到了编号 1
  4. 子进程随后执行 execvp(),加载 wc 程序;
  5. wc 运行时依旧只是往标准输出写;
  6. 但此时标准输出已经等于文件 p4.output
  7. 所以输出不会出现在终端,而是进入文件。

如果用一句话概括,就是:
重定向是“在新程序启动前,先把它眼中的标准输出偷偷换成文件”。

6.7 为什么这体现了 fork()exec() 分离设计的优雅?

现在就能看出 UNIX 设计的高明之处:

  • fork() 让 Shell 先得到一个可以自由摆弄的子进程;
  • 在真正运行目标程序之前,Shell 可以先修改这个子进程的 I/O 环境;
  • 然后再用 execvp() 把它替换成目标程序;
  • 目标程序无需感知这一切,就能自动享受重定向效果。

这意味着:

  • Shell 不需要修改 wc 的源码;
  • 任何“正常向标准输出写数据”的程序,都可以被同样方式重定向;
  • 管道、输入重定向等机制,本质上也都是在操作文件描述符映射关系。

7. 本章总结

从本章可以看到,UNIX 进程 API 的力量不在于某一个调用单独有多复杂,而在于它们之间的组合方式极其灵活:

  • fork() 提供了创建并复制执行现场的能力;
  • wait() 提供了父子进程之间的同步能力;
  • exec() 提供了“在已有进程中装入新程序”的能力;
  • 三者组合后,Shell 就能实现命令执行、同步控制、输出重定向,进一步还可以扩展到管道等机制。

随堂复习自测

1. fork() 在父进程和子进程中的返回值分别是什么?

  • 父进程中:返回子进程的 PID;
  • 子进程中:返回 0
  • 失败时:返回负值。

2. 为什么 p1.c 中父子进程的输出顺序不固定?

因为 fork() 之后父子进程都处于可运行状态,谁先获得 CPU 取决于调度器,因此输出顺序具有不确定性。

3. wait() 的作用是什么?

让父进程阻塞,直到某个子进程结束,从而建立父子进程之间的同步关系。

4. exec() 会不会创建新进程?

不会。exec() 不负责创建新进程,它是在当前进程内部装入并执行另一个程序。

5. 为什么 exec() 成功后,后面的 printf("this shouldn't print out") 不会执行?

因为当前进程的地址空间已经被新程序替换,原程序后续代码不再属于当前执行内容。

6. 为什么 p4.cwc 的输出会进入文件而不是终端?

因为子进程先关闭了标准输出,再打开文件,使得文件占据了文件描述符 1;于是程序对标准输出的写入就被重定向到了文件。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐