程序和进程

ZaynPei Lv6

fork(): 创建新进程的“克隆”技术

fork() 是一个在类-UNIX 操作系统(如 Linux, macOS)中使用的系统调用,其唯一的功能就是创建一个新的进程。

这个新创建的进程被称为“子进程”(Child Process),而调用 fork() 的那个进程则被称为“父进程”(Parent Process)。

整个过程类似细胞分裂, 当调用 fork() 时,操作系统会拿父进程作为“模板”,几乎原封不动地“克隆”出一个一模一样的副本,这个副本就是子进程。

fork() 的工作机制

当 fork() 被调用时,子进程会获得父进程在调用那一刻的几乎所有资源的副本。

  • 内存空间:子进程会得到父进程整个虚拟地址空间的精确副本,包括代码段、数据段、堆和栈。这意味着父进程中的所有变量和数据,在 fork() 的瞬间,子进程也有一份一模一样的。

  • 程序计数器(Program Counter):子进程的程序计数器被设置为与父进程相同的值。它意味着子进程和父进程一样将从 fork() 调用返回之后的那条指令开始执行,而不是从程序的开头。

  • 文件描述符:父进程打开的所有文件描述符都会被复制到子进程中。如果父进程打开了一个文件,那么子进程也同样“持有”这个打开的文件。它们共享同一个文件表项,这意味着它们对文件的读写指针是同步的。

  • 其他资源:还包括用户和组ID、环境变量、工作目录、I/O 缓冲区等。

尽管是克隆,但父子进程并非100%相同,它们有各自独立的属性:

  • 进程ID (PID):每个进程在系统中都有一个唯一的ID。子进程会获得一个新的、不同于父进程的PID。

  • 父进程ID (PPID):子进程的PPID被设置为其父进程的PID。

  • 资源利用信息:如CPU使用时间等统计信息,子进程会从零开始计算。

  • fork() 的返回值:这是区分父子进程的最关键机制

fork() 返回值: 一次调用,两次返回

fork() 最独特的地方在于它被调用一次,却在两个进程(父进程和子进程)中各返回一次。并且,这两次的返回值是不同的,这使得我们的程序能够根据返回值来区分当前代码是在父进程中运行还是在子进程中运行。

  • 在父进程中:fork() 返回新创建的子进程的PID(一个正整数)。

    • 原因:父进程需要知道它创建的子进程的ID,以便后续可以管理它(例如,等待它结束)。
  • 在子进程中:fork() 返回 0。

    • 原因:子进程可以通过 getppid() 函数轻易地获取其父进程的ID,所以 fork() 返回0就足以让它知道自己是一个子进程。
  • 如果出错:fork() 会在父进程中返回 -1,并且不会创建子进程。

    • 原因:通常是因为系统资源不足(如内存耗尽或进程数量达到上限)。

下面是一个示例

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
#include <stdio.h>
#include <unistd.h> // 包含 fork(), getpid(), getppid() 的头文件
#include <sys/types.h> // 包含 pid_t 类型的头文件

int main() {
pid_t pid; // pid_t 是专门用来存储进程ID的数据类型

printf("程序开始:我是父进程,我的PID是 %d\n", getpid());

// 关键步骤:调用 fork() 创建新进程
pid = fork();

// fork() 之后,这里的代码会被父子两个进程同时执行
// 我们需要通过 pid 的值来区分它们

if (pid < 0) {
// 步骤1:检查 fork 是否失败
// 解释:如果返回值小于0,说明进程创建失败,需要进行错误处理。
fprintf(stderr, "Fork 失败\n");
return 1;

} else if (pid == 0) {
// 步骤2:判断是否为子进程
// 解释:如果返回值为0,那么当前代码在子进程中运行。
printf("--- 我是子进程 ---\n");
printf("我的PID是 %d, 我的父进程PID是 %d\n", getpid(), getppid());
// 子进程可以执行独立的任务,例如在这里休眠几秒钟
sleep(2);
printf("--- 子进程执行完毕 ---\n");

} else {
// 步骤3:判断是否为父进程
// 解释:如果返回值大于0,那么当前代码在父进程中运行,
// 且 pid 变量的值就是刚刚创建的子进程的ID。
printf("+++ 我是父进程 +++\n");
printf("我创建的子进程PID是 %d\n", pid);
printf("+++ 父进程继续执行任务... +++\n");
}

// 这行代码父子进程都会执行,但执行时间点不同
printf("fork() 调用之后,我是PID为 %d 的进程\n", getpid());

return 0;
}
编译并运行后输出如下:
1
2
3
4
5
6
7
8
9
程序开始:我是父进程,我的PID是 54321
+++ 我是父进程 +++
我创建的子进程PID是 54322
+++ 父进程继续执行任务... +++
fork() 调用之后,我是PID为 54321 的进程
--- 我是子进程 ---
我的PID是 54322, 我的父进程PID是 54321
fork() 调用之后,我是PID为 54322 的进程
--- 子进程执行完毕 ---
> (注意:由于操作系统调度的不确定性,父子进程的输出顺序可能会交错)

fork() 经典示例

下面是一个非常经典的 fork() 习题,它巧妙地结合了进程创建和I/O缓冲区的知识。

1
2
3
4
5
6
7
8
9
10
#include <stdio.h>
#include <unistd.hh>

int main() {
for (int i = 0; i < 2; i++) {
fork();
printf("Hello\n");
}
return 0;
}
这个程序的运行结果取决于它的输出是如何被处理的,这直接影响到 printf 的缓冲策略。

当直接在终端执行 (./a.out) 时
  1. 循环开始前: 只有一个进程 (P0)。

  2. i = 0:

    • P0 调用 fork(),创建了子进程 P1。现在有 2 个进程 (P0, P1)。

    • P0 执行 printf(“Hello”),打印一次。

    • P1 执行 printf(“Hello”),打印一次。

    • 此轮循环结束时,共打印了 2 次 “Hello”。

  3. i = 1:

    • 此时,P0 和 P1 两个进程都进入了第二次循环。

    • P0 调用 fork(),创建了子进程 P2。

    • P1 调用 fork(),创建了子进程 P3。

    • 现在总共有 4 个进程 (P0, P1, P2, P3)。

    • 这 4 个进程各自执行 printf(“Hello”),每个打印一次。

    • 此轮循环结束时,又打印了 4 次 “Hello”。

  4. 总计: 2 + 4 = 6 次 “Hello”。

如果通过管道执行 (./a.out | cat)时
  1. 循环开始前: 1 个进程 (P0), 且P0 的输出缓冲区是空的。

  2. i = 0:

    • P0 调用 fork(),创建子进程 P1。

    • 缓冲区状态: P0 和 P1 的缓冲区都是空的。

    • P0 执行 printf,“Hello” 被放入 P0 的缓冲区。

    • P1 执行 printf,“Hello” 被放入 P1 的缓冲区。

    • 此时没有任何内容被打印到屏幕上,它们都在各自进程的内存缓冲区里。

  3. i = 1:

    • P0 继续执行: 它调用 fork(),创建子进程 P2。

      • 关键: P2 被创建时,它完整地复制了 P0 的内存,因此 P2 的缓冲区初始内容就是 “Hello”。
    • P1 继续执行: 它调用 fork(),创建子进程 P3。同样,P3 被创建时,复制了 P1 的内存,P3 的缓冲区初始内容也是 “Hello”。

    • 现在我们有 4 个进程:P0, P1, P2, P3。缓冲区初始状态为:

      • P0: “Hello”

      • P1: “Hello”

      • P2: “Hello” (继承自 P0)

      • P3: “Hello” (继承自 P1)

    • 接下来,这 4 个进程各自执行 printf(“Hello”)。执行后,缓冲区最终状态:

      • P0 缓冲区: “Hello”

      • P1 缓冲区: “Hello”

      • P2 缓冲区: “Hello”

      • P3 缓冲区: “Hello”

  4. 程序结束:

    • 所有 4 个进程执行完毕,它们在退出前会冲刷各自的 stdout 缓冲区。

    • 每个进程都将打印出其缓冲区中的两个 “Hello”。

    • 总计: 4 个进程 × 每个进程打印 2 次 = 8 次 “Hello”。

要理解结果的差异,必须先理解 printf 函数的标准输出流 (stdout) 缓冲机制。

  • 行缓冲 (Line-Buffered): 当输出设备是交互式终端时(即我们直接在命令行运行 ./a.out),stdout 默认为行缓冲。

    • printf 的内容会先暂存到一块内存缓冲区里。当遇到**换行符 *,或者缓冲区满了,或者程序结束时,缓冲区的内容才会被真正“冲刷”(flush)到屏幕上。

    • 对于本题:printf(“Hello”) 中的 会立即触发冲刷,所以每次调用 printf 都会立刻看到输出。

  • 全缓冲 (Fully-Buffered): 当输出被重定向到文件或管道时(如 ./a.out | cat),stdout 会变为全缓冲。

    • 在这种模式下,只有当缓冲区被写满,或者程序正常结束时,内容才会被冲刷。不再能触发立即冲刷。

    • 对于本题:printf 的内容会一直留在内存缓冲区中,直到进程结束时才被一次性打印出来。这是导致两种结果不同的根本原因。

这个问题深刻地揭示了 fork() 的一个核心特性:它创建的是父进程在调用那一刻的“快照”。这个快照不仅包括代码和数据,还包括像 I/O 缓冲区这样的用户态资源。

fork() 的主要用途

execve() : 一个复位状态机

在操作系统中,我们经常将 fork() 和 execve() 放在一起讨论。

  • fork() 是新生:它像细胞分裂一样,从一个父进程完整地克隆出一个新的子进程。这个子进程拥有全新的进程ID(PID),但继承了父进程的全部内存状态。

  • execve() 是重生:它不会创建新进程。相反,它会将调用它的那个进程彻底“变身”,用一个全新的程序来完全替换当前进程的内存空间。进程的ID(PID)保持不变,但它内部运行的程序已经焕然一新。

或者从状态机的视角来看, execve 是一个复位状态机 (Reset State Machine)。它按下了一个“重置按钮”,将当前进程的“状态”——主要是内存中的内容——重置为目标可执行文件所描述的初始状态。