3. 进程创建:fork 系统调用

进程创建是操作系统的核心能力之一。在 Linux 中,除了 0 号空闲进程和 1 号 init 进程由内核直接创建外,所有后续进程均通过 fork() 系统调用来派生。本章深入剖析 fork() 的工作原理、写时拷贝技术、返回值语义以及典型用法模式。

3.1 fork() 的工作原理

fork() 是 Linux 中唯一用于创建普通进程的系统调用。其核心语义是:以调用进程(父进程)为模板,完整复制一份几乎完全相同的副本(子进程)。复制的内容包括:

  • 进程控制块(task_struct):包括进程状态、优先级、信号处理表等。
  • 地址空间:代码段、数据段、堆、栈、环境变量、命令行参数。
  • 文件描述符表:子进程继承父进程所有打开的文件描述符,共享文件偏移量。
  • 信号处理设置:信号处理函数指针、信号掩码。
  • 资源限制:CPU 时间限制、文件大小限制等。

但子进程拥有独立的 进程 ID(PID)、父进程 ID(PPID)、独立的文件锁 和 独立的未决信号集。子进程不会继承父进程的 文件锁、定时器 和 异步 I/O 操作。

关键理解: fork 之后的父子进程是两个完全独立的进程,各自拥有独立的地址空间和调度实体。它们之间唯一的联系是:子进程是父进程的"副本",且子进程可以通过 getppid() 获取父进程 PID。

3.2 写时拷贝(Copy-on-Write, COW)技术

早期 Unix 实现中,fork() 会立即复制父进程的整个地址空间,这导致两个严重问题:

  1. 性能低下:复制大量内存页需要耗费大量 CPU 时间和内存带宽。
  2. 资源浪费:如果子进程立即执行 exec() 加载新程序,之前复制的内存页全部作废。

现代 Linux 内核采用 写时拷贝(COW) 技术优化 fork():

  • fork 时:内核只复制父进程的页表,并将父子进程的虚拟内存页标记为只读。物理内存页仍被父子进程共享。
  • 写入时:当父子进程中任意一方尝试写入共享页时,CPU 触发缺页异常(page fault)。内核在异常处理中为该进程分配新的物理页,复制原页内容,然后更新页表为可写。此后,该页不再共享。
  • 只读页:代码段(.text)等只读内存页永远不会触发 COW,始终共享。
性能收益: COW 使得 fork 操作的时间复杂度从 O(N)(N 为进程地址空间大小)降低到 O(1)(仅复制页表)。对于大型进程(如数据库、浏览器),fork 速度提升可达 100 倍以上。
写时拷贝(COW)工作原理 写时拷贝(COW)工作原理 阶段 1:fork 之前 父进程地址空间 物理内存页 阶段 2:fork 之后(共享只读) 父进程页表 子进程页表 共享物理页(只读) 所有页标记为只读 阶段 3:写入时 父进程页表 原物理页 新物理页(副本) 子进程写入 → 缺页异常 → 分配新页

3.3 fork() 的返回值与父子进程区分

fork() 的返回值设计是操作系统课程中最精妙的设计之一:一次调用,两次返回。其返回值语义如下:

返回值 含义 说明
0 当前进程是子进程 子进程通过 getpid() 获取自己的 PID,通过 getppid() 获取父进程 PID
> 0 当前进程是父进程,返回值为子进程 PID 父进程可以通过该 PID 管理子进程(如 waitpid())
-1 fork 失败 常见原因:进程数达到上限(errno = EAGAIN)或内存不足(errno = ENOMEM)
常见误区: 初学者常误以为 fork 返回两个值给同一个进程。实际上,fork 创建子进程后,父子进程各自独立执行 fork 调用之后的代码。父进程的 fork 返回值是子进程 PID,子进程的 fork 返回值是 0。这是通过内核在复制进程时,将子进程的返回值寄存器设置为 0 来实现的。

3.4 fork() 的典型用法

3.4.1 基本分支模式

最标准的 fork 用法是通过返回值进行分支判断:

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>

int main() {
    pid_t pid = fork();

    if (pid == -1) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    }

    if (pid == 0) {
        // 子进程代码
        printf("子进程: PID=%d, 父进程 PID=%d\n", getpid(), getppid());
        // 子进程执行具体任务...
        exit(EXIT_SUCCESS);  // 子进程主动退出
    } else {
        // 父进程代码
        printf("父进程: PID=%d, 子进程 PID=%d\n", getpid(), pid);
        // 父进程等待子进程结束
        wait(NULL);
    }

    return 0;
}

3.4.2 fork + exec 模式

这是 Linux 中最常见的进程创建模式:先 fork 创建子进程,然后在子进程中调用 exec() 系列函数加载新程序。

pid_t pid = fork();
if (pid == 0) {
    // 子进程:加载新程序
    execlp("/bin/ls", "ls", "-l", NULL);
    // 如果 exec 失败,才会执行到这里
    perror("exec failed");
    exit(EXIT_FAILURE);
} else if (pid > 0) {
    // 父进程:等待子进程完成
    int status;
    waitpid(pid, &status, 0);
}

3.4.3 多进程并发模式

在服务器或并行计算场景中,父进程可以循环 fork 多个子进程:

#define NUM_WORKERS 4

for (int i = 0; i < NUM_WORKERS; i++) {
    pid_t pid = fork();
    if (pid == 0) {
        // 每个子进程处理一个任务
        printf("Worker %d (PID=%d) 开始工作\n", i, getpid());
        do_work(i);
        exit(EXIT_SUCCESS);
    } else if (pid < 0) {
        perror("fork failed");
        exit(EXIT_FAILURE);
    }
}

// 父进程等待所有子进程
for (int i = 0; i < NUM_WORKERS; i++) {
    wait(NULL);
}

3.5 fork 的常见陷阱与避坑指南

陷阱 现象 解决方案
文件描述符共享 父子进程共享文件偏移量,导致写入混乱 fork 后立即关闭不需要的文件描述符,或使用 O_CLOEXEC 标志
缓冲区重复输出 printf 输出出现两次 fork 前调用 fflush(stdout) 清空缓冲区
信号处理混乱 子进程继承父进程的信号处理函数,可能导致意外行为 子进程在 exec 前重置信号处理为默认
僵尸进程 子进程先退出,父进程未调用 wait,子进程变成僵尸 父进程必须调用 wait() 或 waitpid(),或设置 SIGCHLD 处理
内存泄漏 父进程分配的内存被子进程继承,但子进程未释放 子进程在 exec 前释放不需要的内存,或使用 _exit() 直接退出
最佳实践: fork 之后,父子进程应尽快通过 if-else 分支明确各自职责。子进程要么立即 exec() 加载新程序,要么执行完特定任务后调用 _exit() 退出。避免在子进程中执行复杂的清理逻辑——_exit() 不会调用 atexit() 注册的清理函数,但会关闭所有文件描述符。
fork 典型流程与返回值分支 fork 典型流程与返回值分支 调用 fork() 内核复制进程 返回值 = -1 fork 失败 返回值 = 0 子进程 返回值 = 子 PID 父进程 执行子进程任务 等待子进程 父子进程从 fork 返回后各自独立执行,通过返回值区分角色

3.6 总结

  • fork() 是 Linux 进程创建的核心系统调用,通过复制父进程创建子进程。
  • 写时拷贝(COW) 技术避免了不必要的内存复制,大幅提升 fork 性能。
  • 返回值设计 使得父子进程可以通过简单的 if-else 分支区分角色:父进程得到子进程 PID,子进程得到 0。
  • 典型用法 包括基本分支、fork+exec 加载新程序、多进程并发模式。
  • 常见陷阱 包括文件描述符共享、缓冲区问题、僵尸进程等,需要开发者特别注意。