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 通常会执行以下步骤:

  1. fork():创建一个子进程。
  2. 在子进程中 (exec() 之前):子进程在此时是父进程(Shell)的精确副本。这提供了一个关键的机会,让 Shell 在新程序开始执行前修改子进程的环境
  3. exec():子进程调用 exec() 来加载并执行你输入的命令(如 ls)。
  4. wait():父进程(Shell)调用 wait() 来等待子进程执行完毕。
  5. 返回提示符:子进程完成后,父进程(Shell)从 wait() 返回,并再次显示命令提示符,等待下一个用户输入。

这种分离带来的强大功能:

  • I/O 重定向 (Redirection)
    当你在 Shell 中输入 wc p3.c > newfile.txt 时,Shell 会在 fork() 之后、exec() 之前在子进程中做手脚:
    1. 关闭子进程的标准输出文件描述符 (STDOUT_FILENO)。
    2. 打开 newfile.txt 文件。UNIX 系统会寻找第一个可用的文件描述符来分配给新打开的文件,而 STDOUT_FILENO (通常是 1) 正是第一个可用的,因此 newfile.txt 的文件描述符变成了 1。
    3. 子进程随后调用 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() 系统调用。
    1. Shell 创建两个子进程。
    2. 使用 pipe() 创建一个内核缓冲区(管道)。
    3. 在第一个子进程中,将其标准输出连接到管道的写入端,然后 exec() 执行 grep 命令。
    4. 在第二个子进程中,将其标准输入连接到管道的读取端,然后 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 手册来探索这些系统调用的更多细节和变体。

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