HNU2026-操作系统-笔记 2-3 Interlude: Process API
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() 的思考题。虽然具体代码细节不同,但它们本质上都在考以下三点:
- 变量会不会被复制?
会。子进程得到的是父进程地址空间的一份副本,因此fork()之后父子进程各自修改变量,互不影响。 - 某一行代码会被执行几次?
要看该行位于fork()之前还是之后。- 在
fork()之前:通常只执行一次; - 在
fork()之后:父子进程都可能执行,因此往往会执行两次。
- 在
- 某个输出由谁打印?打印什么值?
要先判断当前是在父进程还是子进程,再结合fork()的返回值与各自 PID 推断。
1.6 连续两次 fork() 会产生多少个进程?
课件第 7 页给出如下代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
fork();
fork();
return 0;
}
这个问题最容易出错的地方,是把“调用了两次 fork()”误认为“只会多出两个子进程”。实际上,第二次 fork() 是由第一次产生出来的所有进程共同执行的。
推导过程:
- 初始只有 1 个父进程;
- 第 1 次
fork()后,变成 2 个进程; - 这 2 个进程都会继续执行第 2 次
fork(); - 每个进程再复制出一个子进程,因此总数变成 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]]](https://i-blog.csdnimg.cn/direct/d79dda080925411aa2f41681848ea67d.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");
输出中出现的是 wc 对 p3.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 里最经典的执行流程:
- 父进程先调用
fork(); - 子进程在自己的执行分支里调用
exec(),去运行目标程序; - 父进程根据需要调用
wait(),等待子进程结束。
Shell 启动外部命令,本质上就是这么做的。
5. 为什么 fork() 和 exec() 要分成两个调用?
这是本章最重要的设计思想之一。
很多初学者会问:既然最终目的是“运行另一个程序”,为什么不直接提供一个一步到位的调用,而要拆成:
- 先
fork()复制出子进程; - 再
exec()把它替换成目标程序?
答案是:正因为这两步分开,Shell 才能在 exec() 之前对子进程做定制化设置。
而重定向,就是最典型的例子。
6. 输出重定向示例:p4.c
这一部分解释了:为什么 Shell 不需要修改 wc 程序本身,就能把它的输出从屏幕改到文件里。
6.1 先看 p4.c 到底做了什么
课件中的子进程逻辑可以概括成 3 步:
- 先调用
close(STDOUT_FILENO),关闭当前的标准输出; - 再调用
open("./p4.output", ...),打开目标文件; - 最后调用
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:标准输入stdin1:标准输出stdout2:标准错误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:终端输入1:p4.output2:终端错误输出
这时,文件描述符 1 已经不再指向屏幕,而是指向文件。
6.5 第三步:execvp() 为什么会“继承”这个重定向结果?
接下来执行的是:
execvp(myargs[0], myargs);
这一步会把当前子进程的代码和数据替换成新的程序 wc,但它不会凭空重新发明一套新的标准输入 / 输出 / 错误环境。
也就是说,子进程在 execvp() 之前已经调整好的文件描述符布局,会被新程序直接继承。
因此,当 wc 开始运行时,它看到的仍然是:
- 标准输出 = 文件描述符
1 - 而文件描述符
1=p4.output
所以 wc 并不知道自己“被重定向了”;它只是像平常一样往标准输出写,结果这些数据自然就进了文件。
6.6 把整个过程串起来看一遍
可以把整个重定向过程按时间顺序理解为:
- 父进程先
fork()出一个子进程; - 子进程先把自己的标准输出
1关闭; - 子进程再
open()一个文件,于是这个文件拿到了编号1; - 子进程随后执行
execvp(),加载wc程序; wc运行时依旧只是往标准输出写;- 但此时标准输出已经等于文件
p4.output; - 所以输出不会出现在终端,而是进入文件。
如果用一句话概括,就是:
重定向是“在新程序启动前,先把它眼中的标准输出偷偷换成文件”。
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.c 中 wc 的输出会进入文件而不是终端?
因为子进程先关闭了标准输出,再打开文件,使得文件占据了文件描述符
1;于是程序对标准输出的写入就被重定向到了文件。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐

所有评论(0)