18、进程控制(一):fork() 系统调用详解,父子进程的地址空间、写时拷贝技术

进程控制,是嵌入式系统里绕不开的核心话题。今天我们来聊聊 fork()——这个在 Unix/Linux 世界里最基础、也最容易被误解的系统调用。

说实话,我第一次接触 fork() 时,也被它“一次调用,两次返回”的特性搞懵过。后来在项目中调试一个多进程数据采集系统时,才真正理解了它的设计哲学。嗯,咱们今天就把这块硬骨头啃下来。

一、fork() 的基本行为

fork() 的作用很简单:创建一个新进程。新进程是调用进程的副本,我们称原进程为父进程,新进程为子进程。

它的返回值很有意思:

  • 在父进程中,返回子进程的 PID(大于0)
  • 在子进程中,返回 0
  • 如果创建失败,返回 -1

来看一个最基础的例子:

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

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

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

    if (pid == 0) {
        // 子进程
        printf("我是子进程,PID=%d,父进程PID=%d\n", getpid(), getppid());
    } else {
        // 父进程
        printf("我是父进程,PID=%d,子进程PID=%d\n", getpid(), pid);
    }

    return 0;
}

运行后你会看到两个输出。注意,它们的执行顺序是不确定的——这取决于内核的调度策略。我在项目中就遇到过因为假设子进程先执行而导致的竞态问题,后来老老实实加了同步机制。

二、父子进程的地址空间

很多人以为子进程会“复制”父进程的整个地址空间。其实不然。

fork() 之后,子进程拥有独立的地址空间。但它并不是真的把父进程的内存全部拷贝一份——那样太慢了,也太浪费内存了。

实际上,内核只是把父进程的页表复制一份给子进程。两个进程的虚拟地址空间指向相同的物理内存页。这些页被标记为“只读”。

为什么会这样?

因为 fork() 之后通常紧接着就是 exec()——子进程会加载新的程序,之前的地址空间全部作废。如果提前拷贝,那完全是白费功夫。

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

这就是我们今天的主角。写时拷贝,说白了就是“不到万不得已,绝不复制”。

它的工作流程是这样的:

  1. fork() 时,父子进程共享同一份物理内存
  2. 所有共享页被标记为只读
  3. 当某个进程试图写入时,触发缺页异常
  4. 内核分配新的物理页,将内容复制过去
  5. 更新页表,允许写入

我画了一张图,帮你理解这个过程:

写时拷贝(COW)技术原理 父进程地址空间 代码段(只读) 数据段(只读) 堆(只读) 栈(只读) fork() 子进程地址空间 代码段(只读) 数据段(只读) 堆(只读) 栈(只读) 物理内存 代码页 数据页 堆页 栈页 共享 共享 共享 共享 写操作触发 → 缺页异常 → 分配新物理页 → 复制内容 → 更新页表 只有被写入的页才会被复制,其他页继续共享

你想想看,如果子进程 fork 出来只是为了执行一个新程序,那之前的拷贝就全浪费了。COW 技术完美解决了这个问题——只有真正需要写入的时候才复制。

四、fork() 之后,哪些东西是共享的?

写时拷贝主要针对的是内存数据。但有些资源是直接继承的:

资源类型 继承方式 说明
文件描述符 共享文件表项 父子进程共享文件偏移量
信号处理 继承 子进程继承父进程的信号处理函数
环境变量 复制 子进程有独立的环境变量副本
工作目录 继承 子进程继承父进程的当前工作目录
进程组 继承 子进程属于父进程的进程组
⚠️ 注意: 文件描述符的共享是个大坑。我曾经在项目中遇到一个问题:父进程打开了一个日志文件,fork 之后父子进程同时写日志,结果日志内容互相覆盖。原因就是它们共享了同一个文件偏移量。解决方案是在 fork 之后,让子进程重新打开文件,或者使用文件锁。

五、fork() 的典型应用场景

在实际项目中,fork() 通常用于以下几种情况:

  • 网络服务器:父进程监听端口,每来一个连接就 fork 一个子进程处理
  • 并行计算:将大任务拆分成多个子任务,由子进程并行处理
  • 执行外部程序:fork 之后调用 exec 系列函数加载新程序
  • 守护进程:fork 之后父进程退出,子进程脱离终端控制

我个人习惯在嵌入式系统中使用 fork 来创建监控进程。主进程负责业务逻辑,子进程负责心跳检测和异常恢复。这样即使主进程挂了,子进程也能及时重启它。

六、避坑指南

这里分享几个我踩过的坑:

💡 经验之谈:
  • 缓冲区问题:printf 等标准 I/O 函数有缓冲区。fork 之前如果缓冲区没刷新,子进程会继承未刷新的缓冲区内容,导致输出重复。我建议在 fork 之前调用 fflush(NULL) 刷新所有缓冲区。
  • 文件描述符泄漏:fork 之后,子进程继承了父进程的所有文件描述符。如果不需要,记得关闭。否则文件描述符资源会被耗尽。
  • 锁状态:子进程不会继承父进程持有的锁。如果父进程在 fork 时持有锁,子进程会继承锁对应的文件描述符,但锁的状态是未定义的。
  • 信号处理:子进程继承父进程的信号处理函数。如果父进程忽略了某些信号,子进程也会忽略。这可能导致子进程无法被正常终止。

我曾经在一个数据采集项目中,因为 fork 之前没有刷新缓冲区,导致日志文件里出现了大量重复记录。排查了半天才发现是缓冲区的问题。从那以后,我每次 fork 之前都会习惯性地调用 fflush(NULL)

七、性能考量

写时拷贝虽然高效,但也不是没有代价的:

  • 缺页异常开销:每次写入都会触发缺页异常,这个开销比普通内存访问大得多
  • 内存碎片:频繁的写时拷贝可能导致物理内存碎片化
  • TLB 抖动:页表更新会导致 TLB 失效,影响性能

不过对于大多数应用场景来说,COW 的收益远大于开销。你想想看,如果每次 fork 都要完整拷贝整个地址空间,那一个 100MB 的进程 fork 一次就要拷贝 100MB 数据,这谁受得了?

好了,关于 fork() 的核心机制就讲到这里。写时拷贝技术是理解进程控制的关键,也是 Unix 系统设计精妙之处的体现。下一节我们会继续深入进程控制的其他方面。


公众号:蓝海资料掘金营,微信 deep3321