OSTEP | 第五章 homework
chapter 5 的作业
t3 使用 fork()编写另一个程序。子进程应打印“hello",父进程应打印“goodbye”。你应该尝试确保子进程始终先打印。你能否不在父进程调用 wait()而做到这一点呢?
应用pipe()
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <string.h>
int main(void) {
// 1. 定义一个整型数组 fd,长度为 2
// fd[0]:管道的“读端”文件描述符
// fd[1]:管道的“写端”文件描述符
int fd[2];
// 2. 调用 pipe(fd)
// 在内核中创建一个“无名管道”,并为它分配一对文件描述符:
// fd[0] 指向管道的读端,
// fd[1] 指向管道的写端。
// 后续进程对 fd[1] 写入的数据,可以从 fd[0] 读取到。
// 如果你尝试从 fd[0] 读数据,而此时管道无数据可读,则该 read 调用会阻塞(等待)。
// 如果你尝试向 fd[1] 写数据,而此时管道缓冲区已满,则该 write 调用也会阻塞。
if (pipe(fd) < 0) {
perror("pipe 创建失败");
exit(1);
}
pid_t pid = fork(); // 创建子进程
if (pid < 0) {
perror("fork 失败");
exit(1);
}
else if (pid == 0) {
// ===== 子进程路径 =====
// 子进程不需要读端,所以关闭它
close(fd[0]); // 关闭 fd[0],避免无意义的读操作
// 打印“hello”,这是我们想要子进程先做的事情
printf("hello\n");
fflush(stdout); // 确保缓冲区内容立即写到标准输出
// 向管道写一个字节,告诉父进程“我已经打印完了”
// 这里写入的数据内容不重要,只要写了就会解除父进程对读端的阻塞
write(fd[1], "x", 1);
// 完成后关闭写端,清理资源
close(fd[1]);
exit(0);
}
else {
// ===== 父进程路径 =====
// 父进程不需要写端,所以先关闭它
close(fd[1]); // 关闭 fd[1],避免无意义的写操作
// 这里从管道读一个字节
// 如果此时子进程还没写入,read(fd[0], ...) 会“阻塞”在这里,直到子进程执行 write()
char buf;
read(fd[0], &buf, 1);
// 读到数据后,说明子进程已经打印完“hello”
// 关闭读端,清理资源
close(fd[0]);
// 现在安全地打印“goodbye”,保证总在“hello”后面
printf("goodbye\n");
}
return 0;
}
- 解释 (Gemini 2.5 pro)
好的,我们来从初学者的角度,一步一步地详细拆解这段代码。
想象一下,你现在需要安排两个人(我们称之为“子进程”和“父进程”)协同完成一个任务:必须先由子进程说“hello”,然后父进程才能说“goodbye”。关键在于,你无法确定操作系统会先让哪个进程开始执行,所以需要一个机制来确保这个顺序。
这段代码就是利用一个叫做“管道(pipe)”的工具,来巧妙地解决这个同步问题。
整体概览
这段代码的核心思想是:
- 创建一个通信管道:就像在两个房间之间安装一个只能单向传话的传声筒。
- 创建子进程:这样我们就有了两个独立的执行单元(父进程和子进程)。
- 利用管道进行“阻塞”:父进程在说“goodbye”之前,会一直趴在传声筒的接收端听。只要子进程没有通过传声筒发送信号,父进程就会一直等待(这就是所谓的“阻塞”)。
- 子进程发送信号:子进程说完“hello”后,就通过传声筒的发送端发一个信号(比如“咳”一声)。
- 父进程接收信号并继续:父进程一听到信号,就知道子进程那边完事了,于是它就放心地说出“goodbye”。
通过这种方式,无论操作系统先调度谁,都能保证“goodbye”一定在“hello”之后输出。
代码分步详解
1. 包含头文件
#include <stdio.h> // 提供了 printf, perror 等函数
#include <stdlib.h> // 提供了 exit 函数
#include <unistd.h> // 提供了 pipe, fork, read, write, close 等核心的UNIX系统调用
#include <string.h> // 提供了字符串相关函数(虽然本代码没直接用,但包含进来是好习惯)
这些是C语言的“工具包”,我们后面用到的所有关键函数都来自这里。
2. 创建管道
int fd[2];
if (pipe(fd) < 0) {
perror("pipe 创建失败");
exit(1);
}
int fd[2];:我们定义了一个包含两个整数的数组fd。它将用来存放管道的两端。fd是 "file descriptor"(文件描述符)的缩写。在Linux/UNIX系统中,一切皆文件,管道也不例外,所以用文件描述符来代表它的两端。pipe(fd):这是最关键的一步。我们请求操作系统建立一个管道。- 成功后,操作系统会返回两个文件描述符给我们,并存放在
fd数组里。 fd[0]是管道的 “读端”(接收口)。fd[1]是管道的 “写端”(发送口)。
- 成功后,操作系统会返回两个文件描述符给我们,并存放在
- 规则:任何写入到
fd[1]的数据,都可以从fd[0]读取出来。它们是连接在一起的,并且数据是单向流动的(从fd[1]到fd[0])。 if (pipe(fd) < 0):这是一个标准的错误检查。如果因为某些原因(比如系统资源耗尽)管道创建失败,pipe()会返回一个负数,我们就打印错误信息并退出程序。
3. 创建子进程
pid_t pid = fork();
if (pid < 0) {
perror("fork 失败");
exit(1);
}
fork():这是另一个神奇的函数。调用它的那一刻,操作系统会复制当前的进程,创造出一个几乎一模一样的“子进程”。- 父进程:原有的进程。在父进程中,
fork()会返回新创建的子进程的ID(一个正整数)。 - 子进程:新创建的进程。在子进程中,
fork()会返回 0。 - 如果
fork()失败,会返回一个负数。
- 父进程:原有的进程。在父进程中,
- 通过检查
fork()的返回值pid,我们就可以让父子进程执行不同的代码块。
4. 子进程的逻辑 (pid == 0)
else if (pid == 0) {
// ===== 子进程路径 =====
// 子进程不需要读端,所以关闭它
close(fd[0]);
// 打印“hello”
printf("hello\n");
fflush(stdout); // 确保 "hello" 立即显示出来
// 向管道写一个字节,作为信号
write(fd[1], "x", 1);
// 完成后关闭写端
close(fd[1]);
exit(0); // 子进程任务完成,退出
}
close(fd[0]);:子进程的任务是“说hello”然后“发信号”。它只需要往管道里写东西(使用写端fd[1]),不需要从管道里读。所以,最好把用不上的读端fd[0]关掉。这是一个好习惯,可以避免一些潜在的问题,并及时释放资源。printf("hello\n");:执行它的核心任务:打印 "hello"。fflush(stdout);:printf通常会先把内容放到一个叫“缓冲区”的临时区域,等缓冲区满了或者程序结束了才真正显示到屏幕上。fflush(stdout)的作用是强制把缓冲区里的内容(也就是 "hello\n")立即显示出来,确保父进程在收到信号时,"hello" 已经可见。write(fd[1], "x", 1);:这是子进程在通知父进程:“我完事了!”fd[1]:往管道的写端写。"x":写的内容。其实内容是什么不重要,哪怕是"a"、"b"都行。重要的是写入这个动作本身。1:写入一个字节的数据。
close(fd[1]);:信号发完了,写端也没用了,关掉它。exit(0);:子进程的所有工作都已完成,正常退出。
5. 父进程的逻辑 (pid > 0)
else {
// ===== 父进程路径 =====
// 父进程不需要写端,所以先关闭它
close(fd[1]);
// 从管道读一个字节,等待子进程的信号
char buf;
read(fd[0], &buf, 1);
// 读到数据后,说明子进程已经打印完“hello”
// 关闭读端
close(fd[0]);
// 现在安全地打印“goodbye”
printf("goodbye\n");
}
close(fd[1]);:与子进程类似,父进程的任务是“等信号”然后“说goodbye”。它只需要从管道里读(使用读端fd[0]),不需要写,所以把用不上的写端fd[1]关掉。read(fd[0], &buf, 1);:这是整个同步机制的核心!read()函数尝试从管道的读端fd[0]读取 1 个字节的数据,并存到变量buf中。- 关键的阻塞行为:如果此时管道是空的(即子进程还没调用
write()),read()函数会暂停在这里,让父进程等待。它不会消耗CPU,只是静静地挂起,直到管道里有数据可读。 - 一旦子进程执行了
write(fd[1], ...),管道里就有了数据,父进程的read()立刻就能读到数据,然后代码继续往下执行。
close(fd[0]);:已经成功收到了信号,读端没用了,关掉它。printf("goodbye\n");:因为read()的阻塞,执行到这一步时,我们可以百分之百确定子进程已经打印完 "hello" 并发送了信号。所以现在可以安全地打印 "goodbye",从而保证了正确的输出顺序。
总结
这个程序通过 pipe 创建了一个通信渠道,通过 fork 创建了两个进程,并巧妙地利用了 read 函数在管道为空时的阻塞特性,实现了一个简单的进程同步机制。父进程通过“等待”管道中的信号来确保它在子进程完成特定操作后才继续执行,最终保证了输出的确定顺序。
t4 编写一个调用 fork()的程序,然后调用某种形式的 exec()来运行程序/bin/ls。看看是否可以尝试 exec()的所有变体,包括 execl()、execle()、execlp()、execv()、execvp()和 execvP()。为谁谁同样的基本调用会有这谁多变种
好的,这个问题非常好,它触及了所有类UNIX系统(包括Linux、macOS)中创建新进程和执行新程序的核心机制。我们来一步步拆解它。
第一部分:/bin/ls 是什么?
在回答代码问题之前,我们先搞清楚我们要执行的到底是什么东西。
ls: 这是 "list" 的缩写,是一个非常基础和常用的命令。当你在终端(命令行界面)里输入ls并按回车时,它会列出当前目录下的所有文件和文件夹。/bin: 这是 "binary" 的缩写,是Linux系统中的一个标准目录。它存放着系统运行所必需的二进制可执行文件(也就是程序),比如ls、cp(复制)、mv(移动)、cat(查看文件内容) 等。/bin/ls: 所以,/bin/ls就是ls这个命令程序在文件系统中的完整路径。它告诉操作系统:“请在根目录下的bin目录里,找到一个叫做ls的程序文件。”
简单来说,/bin/ls 就是 ls 这个程序本身。
第二部分:fork() + exec() 的黄金组合
这是在类UNIX系统上启动一个新程序(比如运行 /bin/ls)的标准模式。把它想象成一个“克隆再变身”的过程。
-
fork()- 克隆- 当你的程序调用
fork()时,操作系统会创建一个与当前进程一模一样的子进程。这个子进程拥有和父进程完全相同的代码、数据和内存状态。就像一个完美的克隆人。 fork()调用之后,你就有了两个进程(父进程和子进程),它们会从fork()的下一行代码开始,同时(并发地)往下执行。
- 当你的程序调用
-
exec()- 变身- 子进程虽然是父进程的克隆,但我们通常不希望它和父进程做一样的事情。我们的目标是让它去执行一个全新的程序(比如
/bin/ls)。 exec()系列函数就是用来实现“变身”的。当一个进程调用了任何一个exec()函数,它的整个内存空间(包括代码、数据等)会被完全替换成新程序(/bin/ls)的内容。- 关键点:
exec()执行成功后,原来的代码就消失了,永远不会再回来了。进程的ID(PID)保持不变,但它已经魂穿成了另一个程序。如果exec()失败,它会返回-1,原来的代码会继续执行。
- 子进程虽然是父进程的克隆,但我们通常不希望它和父进程做一样的事情。我们的目标是让它去执行一个全新的程序(比如
所以,流程是:
父进程 fork() -> 创建子进程 -> 子进程调用 exec() -> 子进程变身为 /bin/ls 程序。
第三部分:代码实现与 exec() 家族的变体
下面是一个完整的程序,它演示了如何使用 exec() 家族的所有主要成员来运行 /bin/ls。
#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());
pid_t rc = fork();
if (rc < 0) {
// fork 失败
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// ===== 子进程路径 (pid == 0) =====
// 子进程将会在这里调用 exec() 来变身
printf("hello, I am child (pid:%d)\n", (int) getpid());
// --- 你可以取消下面任何一行的注释来测试不同的 exec ---
// --- 注意:一次只能测试一个!---
// -----------------------------------------------------
// 变体 1:execl()
// l 代表 list (列表):参数是一个个分开的字符串,以 NULL 结尾。
// 需要提供 /bin/ls 的完整路径。
// 第一个参数是程序路径,第二个参数通常重复程序名(它会成为新程序的argv[0]),后面是其他参数。
// execl("/bin/ls", "ls", "-l", NULL);
// -----------------------------------------------------
// 变体 2:execv()
// v 代表 vector (数组/向量):所有参数打包在一个字符串数组中。
// char* myargs[] = {"ls", "-l", NULL}; // argv[0]是程序名, argv[1]是第一个参数, ...
// execv("/bin/ls", myargs);
// -----------------------------------------------------
// 变体 3:execlp()
// p 代表 path (路径):它会自动在系统的 PATH 环境变量所包含的目录中搜索程序。
// 所以你只需要提供程序名 "ls",而不需要写完整的 "/bin/ls"。
// execlp("ls", "ls", "-l", NULL);
// -----------------------------------------------------
// 变体 4:execvp()
// v 和 p 的结合:用数组传递参数,并自动搜索 PATH。
// 这是最常用和最灵活的变体之一。
char* myargs_p[] = {"ls", "-l", NULL};
execvp("ls", myargs_p);
// -----------------------------------------------------
// 变体 5:execle()
// e 代表 environment (环境):允许你为新程序提供一个全新的环境变量列表。
// char* myenv[] = {"MYVAR=hello", NULL}; // 自定义环境变量
// execle("/bin/ls", "ls", "-l", NULL, myenv);
// -----------------------------------------------------
// 变体 6:execvP() (注意大写P,这是一个GNU扩展,不是所有系统都有)
// P 代表自定义的搜索路径 (Path),而不是系统的 PATH 环境变量。
// char* myargs_vp[] = {"ls", "-l", NULL};
// execvP("ls", "/bin:/usr/bin", myargs_vp);
// 如果 exec() 系列函数执行成功,下面的代码永远不会被执行。
// 如果它们失败了,就会执行下面的代码。
fprintf(stderr, "exec failed\n");
exit(1);
} else {
// ===== 父进程路径 (pid > 0) =====
// 等待子进程执行完毕
int wc = wait(NULL);
printf("hello, I am parent of %d (wc:%d) (pid:%d)\n", rc, wc, (int) getpid());
}
return 0;
}
如何编译和运行:
- 将代码保存为
exec_test.c。 - 在终端编译:
gcc exec_test.c -o exec_test - 运行:
./exec_test
你可以通过修改代码,取消不同 exec 函数的注释来观察它们的效果。
第四部分:为什么需要这么多变种?
这个问题问得非常好!答案是:为了提供不同层次的便利性和控制力。
我们可以根据 exec 函数名字的后缀字母来理解它们的功能差异:
-
l(List - 列表):- 代表函数:
execl(),execlp(),execle() - 特点:命令行参数是作为分离的、可变数量的字符串直接写在函数调用里的,最后必须以
NULL结尾。 - 优点:当你预先知道所有参数,并且参数数量固定时,写起来非常直观方便。
- 缺点:如果参数的数量和内容是在程序运行时动态决定的(比如根据用户输入),用
l系列就很笨拙。
- 代表函数:
-
v(Vector - 向量/数组):- 代表函数:
execv(),execvp(),execvP() - 特点:命令行参数被组织在一个字符串数组 (
char*[]) 中,这个数组的最后一个元素必须是NULL。 - 优点:非常灵活。你可以在程序运行时动态地构建这个参数数组,想放多少参数就放多少。
- 缺点:比
l系列稍微麻烦一点,需要先创建一个数组。
- 代表函数:
-
p(Path - 路径):- 代表函数:
execlp(),execvp() - 特点:你不需要提供程序的完整路径(如
/bin/ls)。你只需要提供程序名(如ls),它会自动去系统的PATH环境变量所指定的目录列表(如/bin,/usr/bin等)中去查找这个程序。 - 优点:非常方便,就像你在 shell 命令行里直接输入命令一样,不用关心程序到底装在哪里。
- 缺点:如果
PATH中有同名的不同程序,可能会执行到非预期的那一个。速度比提供完整路径的版本略慢(因为需要搜索)。
- 代表函数:
-
e(Environment - 环境):- 代表函数:
execle() - 特点:允许你手动传入一个新的环境变量数组给即将执行的新程序。
- 优点:提供了最高级别的控制。你可以精确地控制新程序运行时的环境,这对于安全性和隔离性非常重要。
- 缺点:最复杂,因为你必须手动构建整个环境。
- 代表函数:
总结一下:
- 你想方便地写死参数吗?用
l系列。 - 你需要动态地传递参数吗?用
v系列。 - 你想让系统自动帮你找程序在哪吗?用带
p的。 - 你需要完全控制新程序的环境变量吗?用带
e的。
所以,execvp() 是最常用、最灵活的组合之一,因为它既允许你动态传递参数 (v),又能自动搜索路径 (p),非常接近我们在shell中的使用体验。
t5 现在编写一个程序,在父进程中使用wait(),等待子进程完成。wait()返回什么?如果你在子进程中使用 wait()会发生什么?
当然,这是一个非常经典和重要的问题,能帮助你深刻理解进程是如何协作和退出的。我们来一步步分解它。
生活中的比喻
想象一下这个场景:
- 父进程就像一位家长。
- 子进程就像这个家长的孩子。
fork():家长(父进程)对孩子(子进程)说:“你去自己的房间把作业做完。” 于是,孩子就去执行自己的任务了。wait():家长(父进程)不会自己去看电视或做别的事情,而是在客厅的沙发上坐着等待。这个“等待”就是wait()系统调用。家长会一直等到孩子从房间出来说“我做完了!”。wait()的返回值:当孩子出来说“我做完了!”,家长不仅知道了孩子已完成任务,而且明确地知道是哪个孩子完成了任务(如果他有多个孩子的话)。这个“哪个孩子”的信息,就是wait()的返回值。- 孩子调用
wait():这就像那个正在做作业的孩子,他自己并没有孩子,但他却在房间里说:“我要等我的孩子做完作业”。这显然不合逻辑。系统会立刻告诉他:“你根本没有孩子可以等!”,然后让他继续做自己的事。
第一部分:父进程使用 wait() (标准用法)
1. 示例代码
这是一个父进程等待子进程完成的典型程序。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h> // 使用 wait() 必须包含这个头文件
int main(void) {
printf("家长(父进程)开始执行 (pid:%d)\n", (int)getpid());
pid_t rc = fork(); // “生”一个子进程
if (rc < 0) {
// fork 失败
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// ===== 子进程的执行路径 =====
printf(" 孩子(子进程)开始执行 (pid:%d)\n", (int)getpid());
sleep(2); // 模拟孩子在花时间做作业
printf(" 孩子(子进程)作业做完了,准备退出。\n");
exit(0); // 子进程执行完毕,正常退出
} else {
// ===== 父进程的执行路径 =====
printf("家长(父进程)正在等待孩子完成...\n");
// 调用 wait(),父进程会在这里“阻塞”或“暂停”
// 直到它的任何一个子进程结束为止
int status;
pid_t wc = wait(&status);
printf("家长(父进程)等待结束。\n");
printf("家长(父进程)知道,ID为 %d 的孩子已经完成了 (wait()的返回值是: %d)\n", rc, wc);
}
return 0;
}
2. 代码解释与 wait() 的返回值
fork()创建子进程。在父进程中,rc的值是子进程的ID。在子进程中,rc的值是0。- 子进程:打印自己的PID,然后
sleep(2)模拟工作了2秒,最后退出。 - 父进程:进入
else分支后,它调用wait(&status)。- 阻塞 (Blocking):父进程的执行会暂停在
wait()这一行,它不会继续往下执行printf。它会一直等,直到子进程结束。 - 解除阻塞:当子进程调用
exit(0)或执行完毕后,操作系统会通知正在等待的父进程。父进程的wait()调用随即返回,程序继续往下执行。
- 阻塞 (Blocking):父进程的执行会暂停在
wait() 返回什么?
-
成功时:
wait()返回已经结束的那个子进程的ID (PID)。- 在上面的代码中,
fork()返回的子进程ID被存放在rc中。当wait()返回时,它的返回值被存放在wc中。你会发现,程序输出的rc和wc的值是完全一样的。这证明了wait()确实返回了它所等待的那个子进程的ID。
- 在上面的代码中,
-
失败时:如果一个进程没有子进程,或者它的子进程都已经结束了,此时它再调用
wait()就会立即失败。- 失败时,
wait()会返回 -1。
- 失败时,
-
wait(&status)中的status是什么?- 这是一个“输出参数”。操作系统会把子进程的退出状态(例如,是正常退出还是被杀掉的,退出时的返回值是什么)填写到这个
status变量中。如果暂时不关心退出状态,可以像上一个练习题中那样传入NULL,即wait(NULL)。
- 这是一个“输出参数”。操作系统会把子进程的退出状态(例如,是正常退出还是被杀掉的,退出时的返回值是什么)填写到这个
第二部分:子进程使用 wait() 会发生什么?
1. 示例代码
我们修改一下上面的代码,让子进程也尝试调用 wait()。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <errno.h> // 用于检查错误号
int main(void) {
pid_t rc = fork();
if (rc < 0) {
exit(1);
} else if (rc == 0) {
// ===== 子进程的执行路径 =====
printf(" 孩子(子进程) (pid:%d) 开始执行\n", (int)getpid());
// 孩子尝试去等待它自己的孩子
printf(" 孩子(子进程) 尝试调用 wait()...\n");
int wc_child = wait(NULL);
if (wc_child == -1) {
printf(" 孩子(子进程) 调用 wait() 失败,返回值为: %d\n", wc_child);
// perror 会打印出更具体的错误原因
perror(" 孩子 wait 失败的原因");
}
printf(" 孩子(子进程) 退出。\n");
exit(0);
} else {
// ===== 父进程的执行路径 =====
printf("家长(父进程) 正在等待...\n");
wait(NULL);
printf("家长(父进程) 等待结束。\n");
}
return 0;
}
2. 运行结果与解释
当你运行这个程序,你会看到类似这样的输出:
家长(父进程) 正在等待...
孩子(子进程) (pid:54321) 开始执行
孩子(子进程) 尝试调用 wait()...
孩子(子进程) 调用 wait() 失败,返回值为: -1
孩子 wait 失败的原因: No child processes
孩子(子进程) 退出。
家长(父进程) 等待结束。
结论非常明确:
- 调用立即失败:当子进程调用
wait()时,它并不会像父进程那样阻塞等待。因为它自己并没有通过fork()创建任何子进程。 - 返回 -1:
wait()函数立即返回-1,表示调用出错。 - 设置错误码:同时,系统会设置一个全局的错误码
errno为ECHILD,表示“没有子进程可以等待 (No child processes)”。perror()函数就是读取这个错误码并打印出人类可读的错误信息。
总结:一个进程只能等待它自己亲手创建的子进程。如果它没有子进程,或者所有子进程都已结束,那么 wait() 调用就会立即失败并返回-1。
t6 6.对前一个程序稍作修改,这次使用waitpid()而不是wait()。什么时候 waitpid会有用?
太棒了!这个问题直击wait()和waitpid()的核心区别。waitpid()可以看作是wait()的“超级增强版”,它提供了更精细的控制能力。
我们先来修改程序,然后深入探讨waitpid()的用武之地。
第一部分:修改程序以使用 waitpid()
这个程序的目标是让父进程明确地等待它刚刚创建的那个子进程。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
printf("父进程开始 (pid:%d)\n", (int)getpid());
pid_t child_pid = fork(); // 创建子进程,并保存其PID
if (child_pid < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
} else if (child_pid == 0) {
// ===== 子进程的执行路径 =====
printf(" 子进程 (pid:%d) 正在执行...\n", (int)getpid());
sleep(2); // 模拟工作
printf(" 子进程 (pid:%d) 执行完毕。\n", (int)getpid());
exit(0);
} else {
// ===== 父进程的执行路径 =====
printf("父进程 (pid:%d) 将要明确等待子进程 (pid:%d) 结束。\n", (int)getpid(), child_pid);
int status;
// 调用 waitpid()
// 第一个参数: child_pid, 表示只等待这个特定的子进程
// 第二个参数: &status, 用于获取子进程的退出状态
// 第三个参数: 0, 表示使用默认的阻塞方式等待
pid_t wc = waitpid(child_pid, &status, 0);
printf("父进程等待结束。\n");
printf("已结束的子进程ID是: %d (waitpid返回值)\n", wc);
}
return 0;
}
waitpid() 函数详解
我们来仔细看看这个强大的函数:pid_t waitpid(pid_t pid, int *status, int options);
-
pid(第一个参数): 这是它与wait()最大的不同,决定了要等待哪个目标。pid > 0: 等待进程ID为pid的那个特定子进程。 (这是我们例子中的用法)pid == -1: 等待任何一个子进程。在这种情况下,waitpid(-1, &status, 0)的行为和wait(&status)完全一样。pid == 0: 等待同一进程组中的任何子进程。(较高级用法)pid < -1: 等待指定进程组中的任何子进程。(较高级用法)
-
status(第二个参数): 和wait()中的一样,是一个指向整型变量的指针,用于存储子进程的退出状态。 -
options(第三个参数): 提供额外的控制选项,这是waitpid()的另一个强大之处。0: 表示没有任何选项。waitpid()会阻塞(暂停),直到指定的子进程结束。这是最常见的用法。WNOHANG: 如果指定的子进程还没有结束,waitpid()不会阻塞,而是会立即返回0。这个选项非常有用!WUNTRACED: 除了返回已终止的子进程信息外,也返回已暂停的子进程信息。(用于作业控制,如shell)
第二部分:什么时候 waitpid() 会有用?
waitpid() 在需要比简单的“等待任何一个孩子”更复杂、更精细的控制时,就显得至关重要。主要有以下两大场景:
场景一:当父进程有多个子进程,且需要按特定顺序或等待特定子进程时
想象一个“包工头”(父进程)派出了两个“工人”(子进程A和子进程B)去做不同的事。
- 工人A(子进程A)的任务是去买水泥,很快就能完成。
- 工人B(子进程B)的任务是去砌墙,需要很长时间。
包工头的下一个任务是“给墙刷漆”,这个任务必须在“墙砌好”之后才能开始。
-
如果使用
wait(): 包工头只能傻等。工人A买完水泥先回来,wait()就会返回,包- 头以为任务完成了,跑去刷漆,结果发现墙还没砌好!wait()无法区分是哪个工人完成了任务。 -
如果使用
waitpid(pid_of_worker_B, ...): 包工头可以明确地等待工人B。即使工人A先完成了任务,waitpid()也不会返回,包工头会继续等待。直到工人B(砌墙的)完成了任务,waitpid()才会返回,这时包- 头就知道可以去刷漆了。
结论:当你需要管理多个子进程,并等待一个特定的、关键的子进程完成任务时,必须使用waitpid()。
场景二:当父进程不想被阻塞,希望在等待的同时做别的事情时
想象一个繁忙的服务器程序(父进程)。它会创建很多子进程来处理客户端请求。服务器本身不能因为等待某个子进程而停止响应新的客户端。它需要一边处理自己的主循环(比如接受新连接),一边检查是否有子进程已经处理完请求退出了(需要回收资源,避免产生僵尸进程)。
-
如果使用
wait(): 服务器一旦调用wait(),就会完全卡住,无法接受任何新的客户端连接,直到有一个子进程结束。这对于服务器来说是致命的。 -
如果使用
waitpid(-1, &status, WNOHANG): 这是完美的解决方案!- 服务器在主循环中做自己的工作(比如监听新连接)。
- 在循环的某个点,它调用
waitpid(-1, &status, WNOHANG)。 - 这个调用会立即返回:
- 如果返回值大于0,说明有一个子进程结束了,服务器就获取了它的ID并可以清理它的资源。
- 如果返回值是0,说明当前没有任何子进程结束。服务器知道了这个情况后,可以立刻继续去做别的事情,而不会被卡住。
- 如果返回值是-1,说明出错了(比如已经没有子进程了)。
结论:当你需要编写一个非阻塞的、事件驱动的程序(如网络服务器、shell),需要在做其他工作的同时“轮询”检查子进程状态时,waitpid() 配合 WNOHANG 选项是不可或缺的工具。
总结对比
| 特性 | wait() |
waitpid() |
|---|---|---|
| 等待目标 | 只能等待任何一个子进程 | 可以等待特定PID的子进程,也可以等待任何子进程 |
| 阻塞行为 | 总是阻塞,直到有子进程结束 | 可以阻塞,也可以不阻塞 (通过 WNOHANG 选项) |
| 灵活性 | 低,功能单一 | 高,提供精细的控制 |
| 典型用途 | 简单场景:父进程创建单个子进程,然后等待其完成。 | 复杂场景:管理多个子进程、编写非阻塞的服务器程序或Shell。 |
您提的这个问题非常好,因为它触及了两者最本质的区别,也是 waitpid() 真正强大的地方。
wait() 和 waitpid(-1, &status, 0) 的功能是完全一样的。但是,您问的是 waitpid(-1, &status, **WNOHANG**),这个 WNOHANG 选项带来了天壤之别。
核心区别在于四个字:阻塞 (Blocking) vs. 非阻塞 (Non-Blocking)。
一个生动的比喻:打电话 vs. 发短信
想象一下,你想知道你的朋友(子进程)是否已经忙完了。你有两种方式联系他:
方式一:wait() —— 就像“打电话”
- 你拨通朋友的电话。
- 在朋友接起电话并告诉你结果之前,你的电话就一直占线,你什么也做不了,只能举着电话等。你的时间被阻塞了。
- 如果你的朋友正在开会,可能要等很久,你就得一直等下去。
wait() 就是这种“阻塞式”的等待。一旦调用,父进程就会被操作系统暂停(卡住),直到有子进程结束为止。
方式二:waitpid(-1, &status, WNOHANG) —— 就像“发短信”
- 你给朋友发一条短信:“忙完了吗?”
- 发完短信后,你立刻把手机揣回兜里,然后继续做你自己的事:看电视、洗碗、看书……你完全没有被耽误。
- 每隔一会儿,你就掏出手机瞥一眼,看看有没有收到回复。
- 有回复了(
waitpid返回 > 0),你就处理一下。 - 没回复(
waitpid返回 0),你就继续做自己的事。
- 有回复了(
waitpid() 加上 WNOHANG 就是这种“非阻塞式”的轮询。调用它时,它会立即告诉你结果,绝不让你等待。
| 对比 | wait() (打电话) |
waitpid(..., WNOHANG) (发短信) |
|---|---|---|
| 行为 | 强迫你等待,直到有结果 | 从不让你等待,立即给你一个“当前状态” |
| 你的状态 | 被阻塞,暂停一切活动 | 非阻塞,可以继续做自己的事 |
回到“繁忙的服务器”的例子
现在我们再看那个服务器的例子,就非常清楚了。
-
使用
wait()的服务器(打电话模式):- 服务器接收了一个客户端连接,创建了一个子进程去处理。
- 然后服务器调用
wait()等待子进程结束。 - 灾难发生: 在这个子进程结束前(可能要处理几秒钟甚至更久),服务器被完全卡住了。此时,如果有成百上千个新客户端发来连接请求,服务器完全无法响应!因为它的主进程正被
wait()阻塞着。整个服务因此瘫痪。
-
使用
waitpid(-1, &status, WNOHANG)的服务器(发短信模式):
这是一个高效的服务器该有的样子,它在一个主循环里工作:- 第一步: 检查有没有新的客户端连接请求?有就接受,并创建子进程处理。
- 第二步: 处理一些服务器自身的其他任务(比如写日志、检查状态等)。
- 第三步: 调用
waitpid(-1, &status, WNOHANG),像发短信一样快速地问一句:“有哪个孩子忙完了吗?”- 如果返回值大于0:太好了,说明有个子进程结束了,服务器立刻回收它的资源,然后继续主循环。
- 如果返回值是0:说明孩子们都还在忙。服务器知道了这个情况后,毫不停留,立刻回到第一步,继续检查新连接、做其他事。
- 如果返回值是-1:说明出错了(比如一个子进程也没有)。
通过这种方式,服务器的主进程永远不会被卡住,它可以永不停歇地响应新请求和处理内部任务,只是在循环的间隙“瞥一眼”是否有子进程需要“收尸”。
代码胜于雄辩:一个非阻塞的例子
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t child_pid = fork();
if (child_pid == 0) {
// 子进程睡5秒后退出
printf(" 子进程: 我开始工作了,需要5秒...\n");
sleep(5);
printf(" 子进程: 我工作完了!\n");
exit(0);
} else {
// 父进程
int loop_count = 0;
while (1) {
// 非阻塞地检查子进程状态
pid_t wc = waitpid(child_pid, NULL, WNOHANG);
if (wc > 0) {
// 子进程结束了
printf("父进程: 终于等到我的孩子(pid:%d)了!\n", wc);
break; // 退出循环
} else if (wc == 0) {
// 子进程还在运行
printf("父进程: 孩子还在忙,我先做点别的事... (第%d次检查)\n", ++loop_count);
} else {
// 出错了
perror("waitpid error");
break;
}
sleep(1); // 父进程每隔1秒检查一次
}
}
return 0;
}
运行这个程序,你会看到:
父进程会每秒打印一次“我先做点别的事...”,打印大约5次后,才会打印“终于等到我的孩子了!”。这完美地证明了父进程在等待期间没有被阻塞,而是在持续地运行自己的循环。如果把 waitpid 换成 wait(),父进程会直接卡住5秒,中间什么也不会打印。
总结:
简单来说,wait() 是“我停下所有事来等你”,而 waitpid() 加上 WNOHANG 是“我每隔一会儿就瞅你一眼,但不耽误我自己的工作”。
t7 7.编写一个创建子进程的程序,然后在子进程中关闭标准输出(STDOUT_FILENO)。如果子进程在关闭描述符后调用 printf()打印输出,会发生什么?
这是一个非常棒的实验性问题,它能让你深刻理解程序、C标准库(stdio)和操作系统内核之间的关系。
简短的答案
如果子进程关闭了标准输出,然后调用 printf(),那么 屏幕上将不会打印任何内容。printf() 的调用会静默失败或返回一个错误代码。
一个比喻来理解:寄信到已注销的地址
- 标准输出 (
STDOUT_FILENO): 想象它是你一个朋友的家庭住址,这个地址在邮政系统里是登记在案的。默认情况下,所有寄给他的信件都会送到这里。 printf(): 它是写信的服务(比如一个代笔公司)。当你调用printf("你好"),就是委托这个公司写一封内容为“你好”的信,并寄到你朋友的默认家庭住址。close(STDOUT_FILENO): 这个操作相当于你的朋友去邮局注销了他的家庭住址。这个地址在邮政系统里变成了一个无效地址。- 关闭后再调用
printf(): 你再次委托代笔公司寄信。代笔公司写好了信,准备交给邮递员(操作系统)去投递到那个已被注销的地址。邮递员一查,发现“该地址不存在”,于是直接拒绝投递,并把信退回给了代笔公司,告诉它“投递失败”。
最终的结果就是:信(你的字符串)永远也到不了目的地(你的屏幕)。
详细的技术解释与示例代码
1. 示例代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
int main(void) {
pid_t rc = fork();
if (rc < 0) {
fprintf(stderr, "fork failed\n");
exit(1);
} else if (rc == 0) {
// ===== 子进程的执行路径 =====
printf(" 子进程: 我将要关闭标准输出...\n");
fflush(stdout); // 确保上面这行能打印出去
// STDOUT_FILENO 是一个在 <unistd.h> 中定义的常量,它的值就是 1
// 关闭文件描述符 1 (标准输出)
close(STDOUT_FILENO);
// 现在尝试打印一些东西
printf(" 子进程: 这句话你还能看到吗?\n");
// 更进一步:检查 printf 的返回值
int ret = printf(" 子进程: 这句也看不到了吧?\n");
if (ret < 0) {
// 如果 printf 失败,它会返回一个负数。
// 我们不能用 printf 或 perror 来打印这个错误,因为标准输出和标准错误可能都关了!
// 这里我们用一个变通的方法,把错误信息写入一个文件来观察。
FILE *fp = fopen("error_log.txt", "w");
if (fp) {
fprintf(fp, "printf failed with return code: %d\n", ret);
fclose(fp);
}
}
exit(0);
} else {
// ===== 父进程的执行路径 =====
wait(NULL); // 等待子进程结束
printf("父进程: 子进程已经结束。\n");
// 检查一下子进程是否留下了错误日志
FILE *fp = fopen("error_log.txt", "r");
if (fp) {
printf("父进程: 发现子进程的错误日志 'error_log.txt',内容如下:\n");
char buffer[256];
while (fgets(buffer, sizeof(buffer), fp)) {
printf("%s", buffer);
}
fclose(fp);
remove("error_log.txt"); // 清理日志文件
}
}
return 0;
}
2. 发生了什么?一步步分解
-
进程的开始: 当子进程被创建时,它继承了父进程的文件描述符表。其中,文件描述符
1(也就是STDOUT_FILENO)指向了你的终端。这是所有输出能显示在屏幕上的根本原因。 -
printf()的工作机制:printf()是C标准I/O库 (stdio) 中的一个函数,而不是一个直接的系统调用。- 它内部有一个缓冲区。当你调用
printf(),它通常先把内容格式化后放到这个缓冲区里。 - 当遇到换行符
\n、缓冲区满了或者你手动fflush()时,printf()才会调用底层的write()系统调用,将缓冲区的内容真正地写入与它关联的文件描述符(对于printf来说,就是STDOUT_FILENO)。
-
close(STDOUT_FILENO)的致命一击:- 这个调用告诉操作系统内核:“请将我(这个进程)的文件描述符表中索引为
1的条目置为无效。” - 从此以后,对于这个子进程来说,文件描述符
1就不再指向任何东西了,它是一个“坏的文件描述符”。
- 这个调用告诉操作系统内核:“请将我(这个进程)的文件描述符表中索引为
-
关闭后再调用
printf()的连锁反应:- 子进程调用
printf("这句话你还能看到吗?\n")。 printf函数像往常一样工作,格式化字符串,准备写入。- 到了需要真正写入的时候,它在底层调用
write(1, "...", ...)。 - 操作系统内核接到
write请求,检查该进程的文件描述符表,发现文件描述符1是一个无效的、已关闭的描述符。 - 内核拒绝执行这次写入操作,并立即给调用者(
printf内部的逻辑)返回一个错误,通常是-1,同时将全局错误码errno设置为EBADF(Bad file descriptor)。 printf函数收到了来自write的失败信号。它知道写入失败了,所以它也向上返回一个负数,表示自己执行失败。- 因为
write操作被内核拒绝,所以任何数据都没有机会被送到终端驱动程序,屏幕上自然也就什么都看不到了。
- 子进程调用
总结
这个实验清晰地揭示了应用程序、库函数和操作系统内核之间的层次关系:
- 应用程序 (
main函数) 调用 库函数 (printf)。 - 库函数 (
printf) 调用 操作系统内核 (write系统调用)。 - 操作系统内核 管理着真正的资源,比如文件描述符表和到硬件的连接。
通过 close(),我们直接在最底层的操作系统层面切断了连接,这使得上层的库函数 printf 即使想完成它的任务,也无能为力了。
t8 编写一个程序,创建两个子进程,并使用 pipe()系统调用,将一个子进程的标准输出连接到另一个子进程的标准输入。
当然可以!这是一个非常有代表性的练习,它完美地模拟了你在Linux/Unix命令行中用管道符 | 所做的事情,比如 ls -l | wc -l。这个操作的本质就是将 ls -l 命令的输出,作为 wc -l 命令的输入。
我们将用程序来实现这个过程。
核心思想:管道工的视角
想象一下你是管道工(父进程),你的任务是连接两个房间(两个子进程)的水管。
- 房间A(子进程1) 有一个出水口(标准输出),水会从这里流出。
- 房间B(子进程2) 有一个进水口(标准输入),需要有水从这里流入。
你的工作流程是:
- 准备水管 (
pipe()): 你先拿出一根水管,它有一个进口和一个出口。 - 叫来两个工人 (
fork()两次): 你雇佣了工人A和工人B。 - 连接工人A (
dup2()): 你走进房间A,把房间A的出水口(默认是通往外面的下水道——屏幕)给堵上,然后把你的水管的“写端”接了上去。从此,房间A流出的所有水都会进入你的水管。 - 连接工人B (
dup2()): 你走进房间B,把房间B的进水口(默认是来自水塔——键盘)给关掉,然后把你的水管的“读端”接了上去。从此,房间B需要的所有水都将从你的水管里取。 - 收工 (
close()和wait()): 作为管道工,你自己的任务已经完成了。你必须把你手上的水管两端多余的阀门都关掉(父进程close()管道两端),然后就在旁边等着两个工人都下班(wait()两个子进程)。
示例代码
我们将实现 ls -l | wc -l 的功能。
- 子进程1 将执行
ls -l(列出详细文件列表)。 - 子进程2 将执行
wc -l(统计输入的行数)。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
int main(void) {
// 1. 创建管道
int fd[2];
if (pipe(fd) == -1) {
perror("pipe failed");
exit(1);
}
// 2. 创建第一个子进程 (它将成为输出方,比如 ls -l)
pid_t child1_pid = fork();
if (child1_pid < 0) {
perror("fork 1 failed");
exit(1);
}
if (child1_pid == 0) {
// ===== 子进程 1 的代码 =====
// 它的任务是把自己的标准输出重定向到管道的写端
// a. 它不需要从管道读,所以关掉读端
close(fd[0]);
// b. 将标准输出(STDOUT_FILENO, 即1)重定向到管道的写端(fd[1])
// dup2(oldfd, newfd) 会让 newfd 成为 oldfd 的一个拷贝
// 关闭 newfd,然后复制 oldfd。
dup2(fd[1], STDOUT_FILENO);
// c. 现在 fd[1] 已经复制到 STDOUT_FILENO, 原来的 fd[1] 就可以关掉了
close(fd[1]);
// d. 执行 ls -l。它的所有输出现在都会进入管道,而不是屏幕。
printf("子进程1: 我要执行 ls -l 了...\n"); // 这句话会进入管道
execlp("ls", "ls", "-l", NULL);
// 如果 execlp 失败
perror("execlp ls failed");
exit(1);
}
// 3. 创建第二个子进程 (它将成为输入方,比如 wc -l)
pid_t child2_pid = fork();
if (child2_pid < 0) {
perror("fork 2 failed");
exit(1);
}
if (child2_pid == 0) {
// ===== 子进程 2 的代码 =====
// 它的任务是把自己的标准输入重定向到管道的读端
// a. 它不需要往管道写,所以关掉写端
close(fd[1]);
// b. 将标准输入(STDIN_FILENO, 即0)重定向到管道的读端(fd[0])
dup2(fd[0], STDIN_FILENO);
// c. 现在 fd[0] 已经复制到 STDIN_FILENO, 原来的 fd[0] 就可以关掉了
close(fd[0]);
// d. 执行 wc -l。它会从它的标准输入(现在是管道)读取数据并统计行数。
execlp("wc", "wc", "-l", NULL);
// 如果 execlp 失败
perror("execlp wc failed");
exit(1);
}
// ===== 父进程的代码 =====
// 4. 父进程的工作是清理和等待
// a. 管道已经被两个子进程接管,父进程不再需要它。
// !!至关重要!!:必须关闭父进程的管道两端。
// 否则,子进程2(读取方)永远不会读到"文件结束"(EOF),会一直阻塞。
close(fd[0]);
close(fd[1]);
// b. 等待两个子进程都结束
printf("父进程: 等待两个子进程结束...\n");
waitpid(child1_pid, NULL, 0);
waitpid(child2_pid, NULL, 0);
printf("父进程: 两个子进程都已结束。\n");
return 0;
}
关键步骤详解
dup2(): 重定向的魔法
dup2(oldfd, newfd) 是这个程序的核心魔法。它的意思是:
“请让文件描述符 newfd 指向和 oldfd 完全相同的内核文件对象。如果 newfd 原本打开了,就先把它关掉。”
-
对于子进程1:
dup2(fd[1], STDOUT_FILENO)STDOUT_FILENO的值是1,它原来指向屏幕。- 这个调用使得文件描述符
1不再指向屏幕,而是指向管道的写端。 - 因此,之后任何试图写入到文件描述符
1的操作(比如printf或者ls的输出),实际上都是在往管道里写。
-
对于子进程2:
dup2(fd[0], STDIN_FILENO)STDIN_FILENO的值是0,它原来指向键盘。- 这个调用使得文件描述符
0不再指向键盘,而是指向管道的读端。 - 因此,之后任何试图从文件描述符
0读取的操作(比如scanf或者wc的输入),实际上都是在从管道里读。
为什么父进程必须关闭管道?
这是一个非常关键且容易出错的点。
管道有一个规则:只要还有任何一个进程持有着管道的写端文件描述符,那么从读端读取的进程就不会接收到“文件结束”(End-of-File, EOF)的信号。
在我们的程序中:
- 父进程创建了管道,所以它同时持有读端(
fd[0])和写端(fd[1])。 - 子进程1被创建后,也继承了这两个描述符(虽然它很快关掉了读端)。
- 子进程2被创建后,也继承了这两个描述符(虽然它很快关掉了写端)。
现在,考虑子进程2(wc -l)的行为。它会一直从管道里读数据,直到读到 EOF 信号,它才认为数据结束了,然后打印出总行数并退出。
如果父进程不调用 close(fd[1]) 来关闭它持有的写端,那么即使子进程1执行完 ls -l 并退出了(它持有的写端也随之关闭),对于操作系统来说,这个管道的写端依然还有一个所有者(父进程!)。所以,操作系统不会发送 EOF 信号给子进程2。结果就是,子进程2会永远地阻塞在那里,等待永远不会到来的新数据,整个程序就卡住了。
所以,父进程在设置好一切后,必须“放手”,关闭它自己持有的管道两端,把管道的生命周期完全交给子进程们。

浙公网安备 33010602011771号