OSTEP | 第 5 章 插叙 :进程API
第 5 章 插叙 :进程API(Gemini 2.5 Flash生成)
深入理解 UNIX 进程创建:fork(), exec(), 和 wait()
本章详细探讨了 UNIX 系统中进程创建和控制的核心机制,即通过 fork()、exec() 和 wait() 这三个系统调用。理解这组 API 对于任何深入学习操作系统或进行系统编程的人来说都至关重要。
1. fork() 系统调用:创建进程的“双生子”
fork() 系统调用是 UNIX 创建新进程的基石,但其行为方式初看起来可能有些“奇怪”。当你调用 fork() 时,操作系统会创建一个几乎与调用进程一模一样的新进程。这个新进程被称为子进程 (child process),而调用 fork() 的原始进程则被称为父进程 (parent process)。
关键特性:
- 代码与数据复制:子进程会获得父进程代码段、数据段、堆、栈以及其他内存空间的副本。这意味着从
fork()调用点开始,父子进程的代码是相同的,并且它们拥有各自独立的内存空间。 - 独特的返回值:这是
fork()最“奇怪”但也最强大的地方。- 在父进程中,
fork()返回新创建子进程的 PID (Process Identifier)。这个 PID 用于父进程后续识别和操作子进程(例如,通过wait()等待它)。 - 在子进程中,
fork()返回0。子进程知道自己是子进程,并且可以通过getpid()获取自己的 PID,而通过getppid()获取父进程的 PID。
这种区别使得我们可以在fork()调用后,通过一个简单的if/else if/else结构来编写不同的代码路径,分别处理父进程和子进程的行为。
- 在父进程中,
- 非确定性执行:
fork()调用后,系统中同时存在父进程和子进程。在单 CPU 系统上,操作系统中的 CPU 调度器将决定哪个进程先运行。这意味着你无法预先确定父进程或子进程哪一个会首先执行。这种非确定性是并发编程中的一个常见挑战,尤其在多线程程序中会体现得更为明显。
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) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
} else { // parent goes down this path (main)
printf("hello, I am parent of %d (pid:%d)\n",
rc, (int) getpid());
}
return 0;
}
p1.c 运行结果:
由于 CPU 调度器的非确定性,输出顺序可能不同。
情况一:父进程先运行
prompt> ./p1
hello world (pid:29146)
hello, I am parent of 29147 (pid:29146)
hello, I am child (pid:29147)
prompt>
情况二:子进程先运行
prompt> ./p1
hello world (pid:29146)
hello, I am child (pid:29147)
hello, I am parent of 29147 (pid:29146)
prompt>
初始进程输出一条“hello world”信息及自己的 PID (例如 29146)。接着调用 fork()。父进程(PID 29146)将获得子进程的 PID (例如 29147) 作为 fork() 的返回值,并输出自己的信息。子进程(PID 29147)则获得 0 作为 fork() 的返回值,并输出自己的信息。
2. wait() 系统调用:父进程的耐心等待
在某些场景下,父进程需要等待其子进程执行完毕后再继续自己的任务。wait() (或者更通用的 waitpid()) 系统调用正是为此目的而设计。
工作原理:
- 当父进程调用
wait(NULL)时,它会暂停自身的执行。 - 只有当它的某个子进程终止时,
wait()调用才会返回到父进程。 wait()返回终止子进程的 PID。
p2.c 代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h> // for wait()
int
main(int argc, char *argv[])
{
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
} else { // parent goes down this path (main)
int wc = wait(NULL); // parent waits for child to complete
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
rc, wc, (int) getpid());
}
return 0;
}
p2.c 运行结果:
prompt> ./p2
hello world (pid:29266)
hello, I am child (pid:29267)
hello, I am parent of 29267 (wc:29267) (pid:29266)
prompt>
与 p1.c 类似,但在父进程中增加了 int wc = wait(NULL); 语句。由于父进程在 wait() 处会等待子进程完成,这消除了 p1.c 中存在的非确定性。无论父进程和子进程哪个先被调度运行,父进程都会“礼貌地”等待子进程执行完毕并输出信息后,自己才继续执行并输出。因此,子进程的输出总是出现在父进程的输出之前。wc 的返回值是子进程的 PID,确认了是哪个子进程完成了。
3. exec() 系统调用:华丽转身,替换自我
fork() 创建的是一个父进程的副本,而 exec() 系统调用则允许子进程执行一个与父进程完全不同的程序。这通常与 fork() 结合使用,构成 UNIX 中创建新进程并运行新程序的标准模式。
关键行为:
- 替换当前进程映像:
exec()的核心功能是加载一个全新的可执行程序。它会用新程序的代码段、静态数据来覆写当前进程的代码段和静态数据。同时,堆、栈以及其他内存空间也会被重新初始化,以适应新的程序。 - 不创建新进程:重要的是要理解,
exec()不会创建新的进程。它是在当前进程的上下文中替换正在运行的程序。进程的 PID 保持不变,但其内存内容和执行逻辑被完全替换为新程序的。 - 永不返回 (通常):如果
exec()调用成功,它永远不会返回。因为调用exec()的代码已经被新加载的程序覆盖和替换了。如果exec()返回,那通常意味着它失败了(例如,找不到指定的可执行文件)。
p3.c 代码:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h> // for strdup()
#include <sys/wait.h> // for wait()
int
main(int argc, char *argv[])
{
printf("hello world (pid:%d)\n", (int) getpid());
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child (new process)
printf("hello, I am child (pid:%d)\n", (int) getpid());
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p3.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
printf("this shouldn't print out"); // This line will NOT be executed
} else { // parent goes down this path (main)
int wc = wait(NULL); // parent waits for child
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n",
rc, wc, (int) getpid());
}
return 0;
}
p3.c 运行结果:
prompt> ./p3
hello world (pid:29383)
hello, I am child (pid:29384)
29 107 1030 p3.c
hello, I am parent of 29384 (wc:29384) (pid:29383)
prompt>
子进程在输出自己的信息后,不再执行 p3.c 的剩余代码,而是调用 execvp()。execvp(myargs[0], myargs) 会加载并执行 wc (word count) 程序,并以 p3.c 作为其参数。wc 程序会统计 p3.c 文件的行数、单词数和字节数,并将结果输出 (29 107 1030 p3.c)。由于 execvp() 成功后不会返回,printf("this shouldn't print out"); 这行代码永远不会被执行。
4. fork() 与 exec() 分离设计的原因:UNIX Shell 的奥秘
你可能会疑惑,为什么不提供一个单独的“spawn()”或“create()”系统调用来直接创建并运行一个新程序呢?UNIX 这种将 fork() 和 exec() 分离的设计,虽然初看有些复杂,但它却异常强大和灵活,尤其对于构建 UNIX Shell 而言是完美的。
Shell 如何利用这种分离:
Shell 本身也是一个用户程序。当你输入一个命令(如 ls -l)时,Shell 通常会执行以下步骤:
fork():创建一个子进程。- 在子进程中 (
exec()之前):子进程在此时是父进程(Shell)的精确副本。这提供了一个关键的机会,让 Shell 在新程序开始执行前修改子进程的环境。 exec():子进程调用exec()来加载并执行你输入的命令(如ls)。wait():父进程(Shell)调用wait()来等待子进程执行完毕。- 返回提示符:子进程完成后,父进程(Shell)从
wait()返回,并再次显示命令提示符,等待下一个用户输入。
这种分离带来的强大功能:
- I/O 重定向 (Redirection):
当你在 Shell 中输入wc p3.c > newfile.txt时,Shell 会在fork()之后、exec()之前在子进程中做手脚:- 关闭子进程的标准输出文件描述符 (
STDOUT_FILENO)。 - 打开
newfile.txt文件。UNIX 系统会寻找第一个可用的文件描述符来分配给新打开的文件,而STDOUT_FILENO(通常是 1) 正是第一个可用的,因此newfile.txt的文件描述符变成了 1。 - 子进程随后调用
exec("wc", "p3.c")。此时,wc程序会将所有输出写入到文件描述符 1,而现在文件描述符 1 指向的是newfile.txt,而不是屏幕。
这样,wc的输出就被透明地重定向到了文件。
- 关闭子进程的标准输出文件描述符 (
p4.c 代码 (包含 I/O 重定向):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
#include <fcntl.h> // for open() flags
#include <sys/wait.h>
int
main(int argc, char *argv[])
{
int rc = fork();
if (rc < 0) { // fork failed; exit
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) { // child: redirect standard output to a file
close(STDOUT_FILENO); // close standard output
// open a new file, which will get file descriptor 1 (STDOUT_FILENO)
open("./p4.output", O_CREAT|O_WRONLY|O_TRUNC, S_IRWXU);
// now exec "wc"...
char *myargs[3];
myargs[0] = strdup("wc"); // program: "wc" (word count)
myargs[1] = strdup("p4.c"); // argument: file to count
myargs[2] = NULL; // marks end of array
execvp(myargs[0], myargs); // runs word count
} else { // parent goes down this path (main)
int wc = wait(NULL); // parent waits for child
}
return 0;
}
p4.c 运行结果:
prompt> ./p4
prompt> cat p4.output
32 109 846 p4.c
prompt>
运行 p4 后,屏幕上看似什么都没发生,Shell 立即返回了提示符。这是因为 wc 程序的输出被重定向到了 p4.output 文件中。通过 cat p4.output 命令,我们可以看到 wc 程序对 p4.c 文件统计的结果 (32 109 846 p4.c)。
- 管道 (Pipes):
UNIX 管道(如grep -o foo file | wc -l)的实现也依赖于fork()和exec()的分离,并结合pipe()系统调用。- Shell 创建两个子进程。
- 使用
pipe()创建一个内核缓冲区(管道)。 - 在第一个子进程中,将其标准输出连接到管道的写入端,然后
exec()执行grep命令。 - 在第二个子进程中,将其标准输入连接到管道的读取端,然后
exec()执行wc命令。
这样,grep的输出无缝地流入管道,并作为wc的输入,实现了命令的串联执行。
这种设计哲学体现了“做对事 (Get it right)”的原则。它简单、优雅且极其强大,使得 UNIX 系统在命令行和脚本编程方面具备无与伦比的灵活性。
5. 其他进程相关 API 和工具
除了这三个核心系统调用之外,UNIX 还提供了其他多种与进程交互的方式:
kill()系统调用:用于向进程发送信号 (signals),例如请求进程终止、暂停或恢复执行。信号子系统是 UNIX 进程间通信和事件处理的重要组成部分。- 命令行工具:
ps:用于查看当前系统中运行的进程快照,提供进程的 PID、状态、CPU 占用等信息。top:提供系统进程的实时动态视图,显示 CPU、内存等资源的消耗情况,有助于监控系统负载。- 还有许多其他工具如
htop,pidstat,mpstat等,用于更细致的性能监控和故障排除。
6. 总结
本章深入讲解了 UNIX 系统中进程创建和管理的关键 API:fork() 用于创建进程的副本;wait() 用于父进程等待子进程完成;而 exec() 则用于在新创建的子进程中加载并运行不同的程序。fork() 和 exec() 的分离设计是 UNIX 操作系统强大灵活性的一个标志,它使得 Shell 能够轻松实现 I/O 重定向、管道等高级功能。深入理解这些机制是掌握 UNIX 系统编程的基石,并且鼓励读者通过阅读 man 手册来探索这些系统调用的更多细节和变体。

浙公网安备 33010602011771号