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)
这就是我们今天的主角。写时拷贝,说白了就是“不到万不得已,绝不复制”。
它的工作流程是这样的:
fork()时,父子进程共享同一份物理内存- 所有共享页被标记为只读
- 当某个进程试图写入时,触发缺页异常
- 内核分配新的物理页,将内容复制过去
- 更新页表,允许写入
我画了一张图,帮你理解这个过程:
你想想看,如果子进程 fork 出来只是为了执行一个新程序,那之前的拷贝就全浪费了。COW 技术完美解决了这个问题——只有真正需要写入的时候才复制。
四、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