一、为什么需要 fork

前面学习进程概念时,我们运行程序的方式通常是:先把源代码编译成可执行文件,再通过 ./程序名 启动它。此时,Shell 负责创建并运行这个新进程。

但是,一个已经运行起来的进程能不能主动创建另一个进程?

在 Linux 中,答案是可以。进程可以调用 fork() 系统调用创建一个子进程。调用者称为父进程,新创建的进程称为子进程。

先给出本篇最重要的一句话:

fork() 调用一次,但成功后会产生父、子两个执行流,并在两个进程中各返回一次。


二、从手册认识 fork

在 Linux 中查看系统调用,应当查第 2 章手册:

man 2 fork

手册给出的函数原型是:

#include <unistd.h>

pid_t fork(void);

接口很短,却包含几个关键信息:

  • fork() 不接收参数;
  • 返回类型是 pid_t,它是用于表示进程 ID 的整数类型;
  • 成功后,父进程和子进程会得到不同的返回值;
  • 失败时,原进程得到 -1,同时设置 errno,并不会产生子进程。

2.1 三种返回结果

fork() 返回值 当前执行流身份 含义
ret > 0 父进程 返回新创建的子进程 PID
ret == 0 子进程 表明当前执行流是子进程
ret == -1 原进程 创建失败,没有子进程产生

为什么父进程要得到子进程 PID,而子进程只得到 0?

  • 一个父进程可能有多个子进程,父进程需要具体 PID 才能等待、发送信号或管理某个子进程;
  • 子进程可以通过 getpid() 获得自己的 PID,通过 getppid() 获得父进程 PID,所以用 0 作为身份标志既简单又不会与合法 PID 冲突。

三、第一个实验:fork 前一个进程,fork 后两个进程

3.1 完整代码

#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);

    printf("before fork: pid=%ld, ppid=%ld\n",
           (long)getpid(), (long)getppid());

    pid_t ret = fork();
    if (ret < 0) {
        perror("fork");
        return 1;
    }

    printf("after fork : role=%s, pid=%ld, ppid=%ld, ret=%ld\n",
           ret == 0 ? "child" : "parent",
           (long)getpid(), (long)getppid(), (long)ret);

    if (ret > 0 && waitpid(ret, NULL, 0) < 0) {
        perror("waitpid");
        return 1;
    }

    return 0;
}

运行结果:

在这里插入图片描述

3.2 如何读懂输出

实验中,before fork 只打印了一次,而 after fork 打印了两次:

  1. 调用 fork() 之前,系统中只有原来的父进程;
  2. 父进程进入内核执行 fork()
  3. 内核创建子进程,并为它准备独立的进程描述信息和执行上下文;
  4. 父、子两个进程都从 fork() 返回处继续向后执行;
  5. 因此 fork() 之后的 printf() 被两个进程各执行一次。

需要特别纠正一个常见误解:

子进程并不是从 main() 第一行重新运行,而是从 fork() 返回的位置继续执行。

父子进程看到的是同一套程序代码,但它们拥有不同的 PID、不同的内核进程描述结构,并且逻辑上拥有彼此独立的地址空间。


四、为什么 fork 调用一次,却返回两次

调用 fork() 时,只有父进程存在,因此确实只有父进程发起了一次系统调用。但系统调用结束之前,子进程已经创建完成。

可以把过程简化为:

父进程调用 fork()
        ↓
进入内核,创建子进程 task_struct、地址空间等运行上下文
        ↓
父进程和子进程都具备继续运行的条件
        ↓
父进程从 fork() 返回,得到子进程 PID
子进程从 fork() 返回,得到 0

所以,“返回两次”不是一个函数在同一个进程里连续执行两条 return,而是父子两个进程分别从系统调用返回到用户态。

这也解释了为什么同一个局部变量 ret 能呈现两个值:

  • 父进程地址空间中的 ret 保存子进程 PID;
  • 子进程地址空间中的 ret 保存 0;
  • 两者虚拟地址布局可能相似,但已经属于两个不同进程的资源视图。

五、利用返回值让父子进程执行不同代码

最常见的分流模板是:

pid_t ret = fork();

if (ret < 0) {
    // 创建失败
} else if (ret == 0) {
    // 子进程代码
} else {
    // 父进程代码,ret 是子进程 PID
}

下面让父子进程分别执行三轮任务:

#define _POSIX_C_SOURCE 200809L

#include <errno.h>
#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <time.h>
#include <unistd.h>

static void sleep_ms(long milliseconds)
{
    struct timespec remaining = {
        .tv_sec = milliseconds / 1000,
        .tv_nsec = (milliseconds % 1000) * 1000000L
    };

    while (nanosleep(&remaining, &remaining) == -1 && errno == EINTR) {
    }
}

int main(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);

    pid_t ret = fork();
    if (ret < 0) {
        perror("fork");
        return 1;
    }

    if (ret == 0) {
        for (int i = 1; i <= 3; ++i) {
            printf("child : pid=%ld, round=%d\n", (long)getpid(), i);
            sleep_ms(120);
        }
        return 0;
    }

    for (int i = 1; i <= 3; ++i) {
        printf("parent: pid=%ld, child=%ld, round=%d\n",
               (long)getpid(), (long)ret, i);
        sleep_ms(100);
    }

    if (waitpid(ret, NULL, 0) < 0) {
        perror("waitpid");
        return 1;
    }

    return 0;
}

在这里插入图片描述

运行结果中,父子进程的输出会交替出现,说明两个执行流都在一段时间内不断推进。

5.1 谁先运行是不确定的

fork() 成功后,父子进程都可能处于可运行状态。谁先获得 CPU、两者如何交替,由调度器决定。

因此:

  • 不能根据某一次输出顺序认定父进程永远先运行;
  • 也不能依赖 sleep() 保证严格先后关系;
  • 如果业务需要确定的顺序,应使用 waitpid()、管道、信号量等明确的同步机制。

本例中的短暂停顿只用于让交替现象更容易观察,不是可靠的进程同步方案。


六、容易忽略的 printf 缓冲问题

有时会遇到一个奇怪现象:明明 printf() 写在 fork() 之前,输出却出现了两次。

例如:

printf("before fork");  // 没有换行,也没有 fflush
fork();

原因不是 fork() 前面的代码被重新执行了,而是:

  1. printf() 的内容可能还停留在用户态缓冲区中;
  2. fork() 创建子进程时,缓冲区状态也被子进程继承;
  3. 父子进程退出时各自刷新缓冲区,于是同一段内容可能被输出两次。

常用处理方式有:

printf("before fork\n");
fflush(stdout);
fork();

或者像本文实验一样,把标准输出设置为无缓冲:

setvbuf(stdout, NULL, _IONBF, 0);

这属于 fork() 与 C 标准 I/O 缓冲共同造成的现象,不能误判为代码从头执行。


七、父子进程的数据为什么互不影响

7.1 写时拷贝实验

#include <stdio.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

int main(void)
{
    int value = 100;

    printf("before: pid=%ld, value=%d, address=%p\n",
           (long)getpid(), value, (void *)&value);
    fflush(stdout);

    pid_t ret = fork();
    if (ret < 0) {
        perror("fork");
        return 1;
    }

    if (ret == 0) {
        value = 200;
        printf("child: pid=%ld, value=%d, address=%p\n",
               (long)getpid(), value, (void *)&value);
        return 0;
    }

    if (waitpid(ret, NULL, 0) < 0) {
        perror("waitpid");
        return 1;
    }

    printf("parent: pid=%ld, value=%d, address=%p\n",
           (long)getpid(), value, (void *)&value);
    return 0;
}

实验中可以观察到:

  • 父子进程打印的 value 虚拟地址相同;
  • 子进程把 value 改为 200;
  • 父进程中的 value 仍然是 100。

这说明它们看到的地址不是用户能够直接观察到的物理地址,而是各自进程地址空间中的虚拟地址。

7.2 Copy-On-Write 的准确理解

如果 fork() 一开始就复制父进程全部物理内存,成本会非常高,而且很多子进程创建后很快就会调用 exec 替换程序,之前的复制会成为浪费。

Linux 通常采用写时拷贝(Copy-On-Write,COW):

  1. fork() 刚完成时,父子进程的部分虚拟页面可以暂时映射同一物理页;
  2. 页表权限和内核状态保证这些页面不能被双方随意直接修改;
  3. 当某一方尝试写入 COW 页面时,触发缺页异常;
  4. 内核为写入方准备私有物理页,复制原内容并更新页表;
  5. 从进程视角看,双方数据始终保持独立。

因此,写时拷贝不是“读取变量时复制”,而是对共享的 COW 页面发生写操作时按需复制

fork 刚结束:
父虚拟页 ─┐
           ├──> 同一物理页(只读/COW)
子虚拟页 ─┘

子进程写入后:
父虚拟页 ───> 原物理页(value = 100)
子虚拟页 ───> 新物理页(value = 200)

八、父子进程对比

项目 fork 后的关系
程序代码 通常看到同一程序代码,从 fork() 返回处继续执行
PID 不同,子进程获得新的 PID
PPID 子进程的 PPID 通常是父进程 PID
fork() 返回值 父进程得到子进程 PID,子进程得到 0
虚拟地址空间 布局初始相似,但逻辑上彼此独立
普通变量 初始内容相同,修改不会直接改变另一进程的副本
打开的文件描述符 子进程继承描述符副本,通常指向相同的内核打开文件对象
执行顺序 不确定,由调度器决定

“父子进程代码共享、数据私有”是很实用的入门总结,但要知道它描述的是进程可观察到的效果。内核底层会利用只读代码页、页表映射和写时拷贝等机制提高效率。


九、一次创建多个子进程:不要不小心变成 2 的 n 次方

如果父子进程都继续执行下一轮 fork(),进程数量可能按 2 的幂增长:

第 1 次 fork:2 个进程
第 2 次 fork:4 个进程
第 3 次 fork:8 个进程
...

所以循环创建“固定数量子进程”时,子进程必须离开创建循环,只让原父进程继续创建。

9.1 安全示例

#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>

enum { CHILD_COUNT = 4 };

int main(void)
{
    setvbuf(stdout, NULL, _IONBF, 0);

    pid_t children[CHILD_COUNT];
    int created = 0;

    for (int i = 0; i < CHILD_COUNT; ++i) {
        pid_t ret = fork();

        if (ret < 0) {
            perror("fork");
            break;
        }

        if (ret == 0) {
            printf("child[%d]: pid=%ld, ppid=%ld\n",
                   i, (long)getpid(), (long)getppid());
            _exit(i);
        }

        children[created++] = ret;
        printf("parent: created child[%d], pid=%ld\n", i, (long)ret);
    }

    for (int i = 0; i < created; ++i) {
        int status = 0;
        pid_t done = waitpid(children[i], &status, 0);

        if (done < 0) {
            perror("waitpid");
            return 1;
        }

        if (WIFEXITED(status)) {
            printf("parent: reaped pid=%ld, exit=%d\n",
                   (long)done, WEXITSTATUS(status));
        }
    }

    return created == CHILD_COUNT ? 0 : 1;
}

这段代码比“只创建、不回收”多做了几件重要的事:

  1. 子进程完成任务后调用 _exit(),不会继续执行下一轮创建循环;
  2. 父进程保存每个子进程 PID;
  3. 父进程通过 waitpid() 等待并读取退出状态;
  4. 即使某次 fork() 失败,已经成功创建的子进程仍会被回收。

这里使用 _exit(),是为了让子进程直接结束,不重复刷新从父进程继承来的用户态标准 I/O 缓冲。


十、fork 为什么会失败

实际程序不能假设 fork() 永远成功。比如:

  • 当前用户或系统的进程数量达到限制;
  • 系统内存或内核资源不足;
  • 受到容器、控制组或其他资源策略限制。

因此必须保留错误分支:

pid_t ret = fork();
if (ret < 0) {
    perror("fork");
    return 1;
}

不要在失败后继续把 ret 当作合法子进程 PID 使用。


十一、常见误区总结

误区 1:子进程从 main 开头重新执行

错误。子进程从 fork() 返回处继续执行,fork() 之前已经执行完的代码不会重新执行。

误区 2:fork 调用了两次

错误。系统调用只由原父进程发起一次,但父子两个进程各自从系统调用返回一次。

误区 3:父进程一定比子进程先运行

错误。谁先运行由调度器决定,输出顺序可能变化。

误区 4:父子进程打印相同地址,说明它们共享同一个变量

错误。打印的是虚拟地址;相同虚拟地址可以通过不同页表映射到不同物理页。

误区 5:写时拷贝在读取变量时发生

错误。通常是对 COW 页面发生写入时,内核才按需建立私有副本。

误区 6:循环 fork 时父子都可以继续循环

要看目标。如果只想让一个父进程创建 N 个子进程,就必须让子进程退出创建循环,否则进程数量可能指数增长。

误区 7:创建完子进程就不用管了

错误。父进程通常需要调用 wait()waitpid() 获取退出状态并完成回收,否则可能形成僵尸进程。


十二、学习后的理解

学习 fork() 之前,我会把“创建进程”理解为重新运行一个程序。实验之后才发现,fork() 更像是在某个执行位置把当前进程分成父、子两个执行流:它们从相同位置继续,拥有相似的初始内容,却由不同的 PID 和地址空间进行管理。

这部分最值得建立的不是“父进程返回大于 0、子进程返回 0”这一条记忆,而是下面这条完整逻辑:

一次 fork 系统调用
        ↓
内核创建子进程及其运行上下文
        ↓
父子进程分别从 fork 返回
        ↓
通过不同返回值进行代码分流
        ↓
调度器决定各自运行时机
        ↓
写时拷贝保证数据隔离与创建效率
        ↓
父进程最终回收子进程

理解了这条链,后续学习进程等待、进程替换、管道和进程间通信时就有了清晰的基础。


Logo

汇聚全球AI编程工具,助力开发者即刻编程。

更多推荐