【Linux 系统编程】初识 fork:一次调用,两次返回,父子进程如何“分道扬镳”
一、为什么需要 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 打印了两次:
- 调用
fork()之前,系统中只有原来的父进程; - 父进程进入内核执行
fork(); - 内核创建子进程,并为它准备独立的进程描述信息和执行上下文;
- 父、子两个进程都从
fork()返回处继续向后执行; - 因此
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() 前面的代码被重新执行了,而是:
printf()的内容可能还停留在用户态缓冲区中;fork()创建子进程时,缓冲区状态也被子进程继承;- 父子进程退出时各自刷新缓冲区,于是同一段内容可能被输出两次。
常用处理方式有:
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):
fork()刚完成时,父子进程的部分虚拟页面可以暂时映射同一物理页;- 页表权限和内核状态保证这些页面不能被双方随意直接修改;
- 当某一方尝试写入 COW 页面时,触发缺页异常;
- 内核为写入方准备私有物理页,复制原内容并更新页表;
- 从进程视角看,双方数据始终保持独立。
因此,写时拷贝不是“读取变量时复制”,而是对共享的 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;
}
这段代码比“只创建、不回收”多做了几件重要的事:
- 子进程完成任务后调用
_exit(),不会继续执行下一轮创建循环; - 父进程保存每个子进程 PID;
- 父进程通过
waitpid()等待并读取退出状态; - 即使某次
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 返回
↓
通过不同返回值进行代码分流
↓
调度器决定各自运行时机
↓
写时拷贝保证数据隔离与创建效率
↓
父进程最终回收子进程
理解了这条链,后续学习进程等待、进程替换、管道和进程间通信时就有了清晰的基础。
更多推荐



所有评论(0)