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)”的工具,来巧妙地解决这个同步问题。

整体概览

这段代码的核心思想是:

  1. 创建一个通信管道:就像在两个房间之间安装一个只能单向传话的传声筒。
  2. 创建子进程:这样我们就有了两个独立的执行单元(父进程和子进程)。
  3. 利用管道进行“阻塞”:父进程在说“goodbye”之前,会一直趴在传声筒的接收端听。只要子进程没有通过传声筒发送信号,父进程就会一直等待(这就是所谓的“阻塞”)。
  4. 子进程发送信号:子进程说完“hello”后,就通过传声筒的发送端发一个信号(比如“咳”一声)。
  5. 父进程接收信号并继续:父进程一听到信号,就知道子进程那边完事了,于是它就放心地说出“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系统中的一个标准目录。它存放着系统运行所必需的二进制可执行文件(也就是程序),比如 lscp (复制)、mv (移动)、cat (查看文件内容) 等。
  • /bin/ls: 所以,/bin/ls 就是 ls 这个命令程序在文件系统中的完整路径。它告诉操作系统:“请在根目录下的 bin 目录里,找到一个叫做 ls 的程序文件。”

简单来说,/bin/ls 就是 ls 这个程序本身。

第二部分:fork() + exec() 的黄金组合

这是在类UNIX系统上启动一个新程序(比如运行 /bin/ls)的标准模式。把它想象成一个“克隆再变身”的过程。

  1. fork() - 克隆

    • 当你的程序调用 fork() 时,操作系统会创建一个与当前进程一模一样的子进程。这个子进程拥有和父进程完全相同的代码、数据和内存状态。就像一个完美的克隆人。
    • fork() 调用之后,你就有了两个进程(父进程和子进程),它们会从 fork() 的下一行代码开始,同时(并发地)往下执行。
  2. 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;
}

如何编译和运行:

  1. 将代码保存为 exec_test.c
  2. 在终端编译:gcc exec_test.c -o exec_test
  3. 运行:./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()会发生什么?

当然,这是一个非常经典和重要的问题,能帮助你深刻理解进程是如何协作和退出的。我们来一步步分解它。

生活中的比喻

想象一下这个场景:

  • 父进程就像一位家长
  • 子进程就像这个家长的孩子
  1. fork():家长(父进程)对孩子(子进程)说:“你去自己的房间把作业做完。” 于是,孩子就去执行自己的任务了。
  2. wait():家长(父进程)不会自己去看电视或做别的事情,而是在客厅的沙发上坐着等待。这个“等待”就是 wait() 系统调用。家长会一直等到孩子从房间出来说“我做完了!”。
  3. wait() 的返回值:当孩子出来说“我做完了!”,家长不仅知道了孩子已完成任务,而且明确地知道是哪个孩子完成了任务(如果他有多个孩子的话)。这个“哪个孩子”的信息,就是 wait() 的返回值。
  4. 孩子调用 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() 的返回值

  1. fork() 创建子进程。在父进程中,rc 的值是子进程的ID。在子进程中,rc 的值是 0
  2. 子进程:打印自己的PID,然后 sleep(2) 模拟工作了2秒,最后退出。
  3. 父进程:进入 else 分支后,它调用 wait(&status)
    • 阻塞 (Blocking):父进程的执行会暂停wait() 这一行,它不会继续往下执行 printf。它会一直等,直到子进程结束。
    • 解除阻塞:当子进程调用 exit(0) 或执行完毕后,操作系统会通知正在等待的父进程。父进程的 wait() 调用随即返回,程序继续往下执行。

wait() 返回什么?

  • 成功时wait() 返回已经结束的那个子进程的ID (PID)

    • 在上面的代码中,fork() 返回的子进程ID被存放在 rc 中。当 wait() 返回时,它的返回值被存放在 wc 中。你会发现,程序输出的 rcwc 的值是完全一样的。这证明了 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
  孩子(子进程) 退出。
家长(父进程) 等待结束。

结论非常明确:

  1. 调用立即失败:当子进程调用 wait() 时,它并不会像父进程那样阻塞等待。因为它自己并没有通过 fork() 创建任何子进程。
  2. 返回 -1wait() 函数立即返回 -1,表示调用出错。
  3. 设置错误码:同时,系统会设置一个全局的错误码 errnoECHILD,表示“没有子进程可以等待 (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): 这是完美的解决方案!

    1. 服务器在主循环中做自己的工作(比如监听新连接)。
    2. 在循环的某个点,它调用 waitpid(-1, &status, WNOHANG)
    3. 这个调用会立即返回
      • 如果返回值大于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() —— 就像“打电话”

  1. 你拨通朋友的电话。
  2. 在朋友接起电话并告诉你结果之前,你的电话就一直占线,你什么也做不了,只能举着电话等。你的时间被阻塞了。
  3. 如果你的朋友正在开会,可能要等很久,你就得一直等下去。

wait() 就是这种“阻塞式”的等待。一旦调用,父进程就会被操作系统暂停(卡住),直到有子进程结束为止。

方式二:waitpid(-1, &status, WNOHANG) —— 就像“发短信”

  1. 你给朋友发一条短信:“忙完了吗?”
  2. 发完短信后,你立刻把手机揣回兜里,然后继续做你自己的事:看电视、洗碗、看书……你完全没有被耽误
  3. 每隔一会儿,你就掏出手机瞥一眼,看看有没有收到回复。
    • 有回复了(waitpid 返回 > 0),你就处理一下。
    • 没回复(waitpid 返回 0),你就继续做自己的事。

waitpid() 加上 WNOHANG 就是这种“非阻塞式”的轮询。调用它时,它会立即告诉你结果,绝不让你等待。

对比 wait() (打电话) waitpid(..., WNOHANG) (发短信)
行为 强迫你等待,直到有结果 从不让你等待,立即给你一个“当前状态”
你的状态 阻塞,暂停一切活动 非阻塞,可以继续做自己的事

回到“繁忙的服务器”的例子

现在我们再看那个服务器的例子,就非常清楚了。

  • 使用 wait() 的服务器(打电话模式):

    1. 服务器接收了一个客户端连接,创建了一个子进程去处理。
    2. 然后服务器调用 wait() 等待子进程结束。
    3. 灾难发生: 在这个子进程结束前(可能要处理几秒钟甚至更久),服务器被完全卡住了。此时,如果有成百上千个新客户端发来连接请求,服务器完全无法响应!因为它的主进程正被 wait() 阻塞着。整个服务因此瘫痪。
  • 使用 waitpid(-1, &status, WNOHANG) 的服务器(发短信模式):
    这是一个高效的服务器该有的样子,它在一个主循环里工作:

    1. 第一步: 检查有没有新的客户端连接请求?有就接受,并创建子进程处理。
    2. 第二步: 处理一些服务器自身的其他任务(比如写日志、检查状态等)。
    3. 第三步: 调用 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() 的调用会静默失败或返回一个错误代码。


一个比喻来理解:寄信到已注销的地址

  1. 标准输出 (STDOUT_FILENO): 想象它是你一个朋友的家庭住址,这个地址在邮政系统里是登记在案的。默认情况下,所有寄给他的信件都会送到这里。
  2. printf(): 它是写信的服务(比如一个代笔公司)。当你调用 printf("你好"),就是委托这个公司写一封内容为“你好”的信,并寄到你朋友的默认家庭住址。
  3. close(STDOUT_FILENO): 这个操作相当于你的朋友去邮局注销了他的家庭住址。这个地址在邮政系统里变成了一个无效地址。
  4. 关闭后再调用 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. 进程的开始: 当子进程被创建时,它继承了父进程的文件描述符表。其中,文件描述符 1(也就是 STDOUT_FILENO)指向了你的终端。这是所有输出能显示在屏幕上的根本原因。

  2. printf() 的工作机制:

    • printf() 是C标准I/O库 (stdio) 中的一个函数,而不是一个直接的系统调用。
    • 它内部有一个缓冲区。当你调用 printf(),它通常先把内容格式化后放到这个缓冲区里。
    • 当遇到换行符 \n、缓冲区满了或者你手动 fflush() 时,printf() 才会调用底层的write() 系统调用,将缓冲区的内容真正地写入与它关联的文件描述符(对于 printf 来说,就是 STDOUT_FILENO)。
  3. close(STDOUT_FILENO) 的致命一击:

    • 这个调用告诉操作系统内核:“请将我(这个进程)的文件描述符表中索引为 1 的条目置为无效。”
    • 从此以后,对于这个子进程来说,文件描述符 1 就不再指向任何东西了,它是一个“坏的文件描述符”。
  4. 关闭后再调用 printf() 的连锁反应:

    1. 子进程调用 printf("这句话你还能看到吗?\n")
    2. printf 函数像往常一样工作,格式化字符串,准备写入。
    3. 到了需要真正写入的时候,它在底层调用 write(1, "...", ...)
    4. 操作系统内核接到 write 请求,检查该进程的文件描述符表,发现文件描述符 1 是一个无效的、已关闭的描述符。
    5. 内核拒绝执行这次写入操作,并立即给调用者(printf 内部的逻辑)返回一个错误,通常是 -1,同时将全局错误码 errno 设置为 EBADF (Bad file descriptor)。
    6. printf 函数收到了来自 write 的失败信号。它知道写入失败了,所以它也向上返回一个负数,表示自己执行失败。
    7. 因为 write 操作被内核拒绝,所以任何数据都没有机会被送到终端驱动程序,屏幕上自然也就什么都看不到了。

总结

这个实验清晰地揭示了应用程序、库函数和操作系统内核之间的层次关系:

  • 应用程序 (main函数) 调用 库函数 (printf)
  • 库函数 (printf) 调用 操作系统内核 (write系统调用)
  • 操作系统内核 管理着真正的资源,比如文件描述符表和到硬件的连接。

通过 close(),我们直接在最底层的操作系统层面切断了连接,这使得上层的库函数 printf 即使想完成它的任务,也无能为力了。

t8 编写一个程序,创建两个子进程,并使用 pipe()系统调用,将一个子进程的标准输出连接到另一个子进程的标准输入。

当然可以!这是一个非常有代表性的练习,它完美地模拟了你在Linux/Unix命令行中用管道符 | 所做的事情,比如 ls -l | wc -l。这个操作的本质就是将 ls -l 命令的输出,作为 wc -l 命令的输入。

我们将用程序来实现这个过程。

核心思想:管道工的视角

想象一下你是管道工(父进程),你的任务是连接两个房间(两个子进程)的水管。

  • 房间A(子进程1) 有一个出水口(标准输出),水会从这里流出。
  • 房间B(子进程2) 有一个进水口(标准输入),需要有水从这里流入。

你的工作流程是:

  1. 准备水管 (pipe()): 你先拿出一根水管,它有一个进口和一个出口。
  2. 叫来两个工人 (fork() 两次): 你雇佣了工人A和工人B。
  3. 连接工人A (dup2()): 你走进房间A,把房间A的出水口(默认是通往外面的下水道——屏幕)给堵上,然后把你的水管的“写端”接了上去。从此,房间A流出的所有水都会进入你的水管。
  4. 连接工人B (dup2()): 你走进房间B,把房间B的进水口(默认是来自水塔——键盘)给关掉,然后把你的水管的“读端”接了上去。从此,房间B需要的所有水都将从你的水管里取。
  5. 收工 (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)的信号。

在我们的程序中:

  1. 父进程创建了管道,所以它同时持有读端(fd[0])和写端(fd[1])。
  2. 子进程1被创建后,也继承了这两个描述符(虽然它很快关掉了读端)。
  3. 子进程2被创建后,也继承了这两个描述符(虽然它很快关掉了写端)。

现在,考虑子进程2(wc -l)的行为。它会一直从管道里读数据,直到读到 EOF 信号,它才认为数据结束了,然后打印出总行数并退出。

如果父进程不调用 close(fd[1]) 来关闭它持有的写端,那么即使子进程1执行完 ls -l 并退出了(它持有的写端也随之关闭),对于操作系统来说,这个管道的写端依然还有一个所有者(父进程!)。所以,操作系统不会发送 EOF 信号给子进程2。结果就是,子进程2会永远地阻塞在那里,等待永远不会到来的新数据,整个程序就卡住了。

所以,父进程在设置好一切后,必须“放手”,关闭它自己持有的管道两端,把管道的生命周期完全交给子进程们。

posted @ 2025-07-25 15:24  粉色奶龙东京阿诺  阅读(26)  评论(0)    收藏  举报