mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4
2051 字
9 分钟
Linux 服务器高阶编程(6):fork、写时复制与进程数量分析

Linux 服务器高阶编程(6):fork、写时复制与进程数量分析#

一、进程原语与 fork#

进程原语:操作系统提供给程序员的进程操作接口

接口定位
fork创建子进程
exec 系列用新程序替换当前进程运行的程序
wait等待并回收子进程
waitpid按条件等待、回收子进程

调用 fork() 的现有进程称为父进程,新产生的进程称为子进程。一次成功的调用使原本的执行流分成两条。

子进程不是把程序从 main() 第一行重新执行一遍,而是从 fork() 返回后继续运行;父、子进程都可能执行后续语句。

二、一次 fork,两条执行流#

getpid() 返回“调用它的这个进程”的 PID,因此父、子进程打印出的值不同

#include <stdio.h>
#include <sys/types.h>
#include <unistd.h>
int main(void)
{
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
printf("current pid=%ld, fork return=%ld\n",
(long)getpid(), (long)pid);
return 0;
}

可用 gcc fork.c -o app 编译,再运行 ./app

进程观察命令可用 ps aux

不要用两行输出的先后顺序判断哪个进程一定先执行:创建顺序与获得 CPU 时间片的顺序不是一回事

三、fork() 的三种返回结果与父子分工#

在终端查阅 man fork 后强调:同一次 fork() 调用,父子进程看到的返回值不同

所在执行流返回值意义
父进程> 0新建子进程的 PID。
子进程0说明当前在子进程分支。
调用失败-1没有成功创建子进程,应处理错误。

因此可写成如下分工模板:

pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
} else if (pid == 0) {
/* 子进程任务 */
_exit(0);
} else {
/* 父进程任务;pid 是子进程的 PID */
}

若子进程完成自己的任务后继续往下走,可能误入原本希望由父进程执行的后续代码

因此要明确子进程的工作区,完成后退出,或让它留在自己的循环中;不要只靠缩进和注释来“隔开”执行流

四、循环创建进程:为什么会从 3 次变成 8 个#

1. 不限制子进程继续循环#

for (int i = 0; i < 3; ++i) {
fork();
}

第一次迭代后有 2 个进程

第二次每个进程都再调用一次 fork(),变成 4 个

第三次变成 8 个。若每次调用都成功,总数为 2^3 = 8,新增子进程数为 7

这里并非原父进程直接生出 7 个孩子:后创建的子进程也可能由先前的子进程创建,所以形成多级进程关系

一般地,执行 n 轮这种无分支限制的循环,总进程数为 2^n(包含最初那个进程)

2. 创建“一父多子”#

若目标是一个父进程直接创建多个子进程,子进程在循环里应立即 break,不要回到下一轮继续 fork()

先以 3 个子进程解释,再演示 5 个子进程的任务区分。整理后的结构如下:

int i;
pid_t pid = -1;
for (i = 0; i < 5; ++i) {
pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
break; /* 子进程不再继续创建下一轮 */
}
}
if (pid == 0) {
printf("child index=%d, pid=%ld\n", i, (long)getpid());
_exit(0);
} else {
printf("parent pid=%ld\n", (long)getpid());
/* 后续应等待、回收创建成功的子进程。 */
}

父子先用 fork() 的返回值区分;多个子进程之间再用各自继承到的循环变量 i 区分。老师把 i=0、i=1 等分给不同任务。各子进程打印的先后由调度决定,不能要求和 i 的顺序一致;检查运行结果应看进程数量和各自任务是否正确。

五、父子进程的“继承”:从完整复制到写时复制#

1. 早期的完整复制模型#

父进程有 PCB 与用户空间:创建子进程时,其 PCB 的 PID、状态等必须与父进程不同;而用户态代码与数据若被整体复制,子进程就能看到父进程的变量初值。

此后子进程修改自己的变量,父进程对应变量不会跟着改变。问题是:子进程如果马上加载另一程序,预先完成的大量复制可能白费。

2. vfork 与 exec 的动机#

execl 说明“子进程加载别的程序,替换原有程序映像”的场景,并介绍 vfork 作为避免不必要复制的过渡方案

实际 vfork() 的子进程在 exec 或 _exit 前与父进程共享地址空间,父进程通常会被挂起,因此子进程不能像普通 fork() 后那样任意修改共享数据或从当前函数返回

3. 现代 fork:读时共享、写时复制#

fork 后父子进程的虚拟地址空间彼此独立,但未修改的物理页面可以暂时共享;当一方尝试写入共享页面时,系统再为修改方准备独立页面,使修改不影响另一方。如果子进程很快执行 exec,许多原数据页便无需完整复制。

“不立刻复制全部数据页”不等于创建进程完全零成本:进程元数据、页表等仍需处理

重点是把昂贵的数据页复制延后到真正需要写入时。

4. 谁写,谁需要独立副本#

子进程写、父进程写,父子最初可看到同样的变量初值;若子进程把 a=30 改为 50,父进程仍能看到自己的 30。反过来,父进程修改共享过的页面,也不能让子进程原先看到的值跟着变。触发复制的是对共享页面的写入,不限定写入者一定是子进程。

这也解释了“一父多子”示例中为什么各子进程能按自己的 i 执行不同任务:每次 fork() 时,子进程继承当时的执行状态;父进程之后为了下一轮循环改变 i,不能反过来改变已创建子进程的私有逻辑状态。课堂把这称为“父写复制”与“子写复制”的两个角度。更严格地说,COW 按内存页触发,而非只复制某一个 int 变量;具体由哪一方保留原页、哪一方取得新页是实现细节。

注意 i++ 的执行时机:for 循环体结束后,当前执行流会执行递增表达式。如果没有在子进程分支 break,子进程也会进入下一轮并继续改变自己的 i。画进程树时,应标明“这次 fork() 发生时 i 是多少”,不要把某一时刻打印的值直接当成最终循环结束后的值。对 for (i=0; i<3; ++i) fork();,所有正常执行完循环的进程最终都会离开循环;计数关键是每轮活跃进程都再次分叉。

六、两道进程数量题#

1. 不看公式,画三轮 fork 的树#

for (int i = 0; i < 3; ++i) {
fork();
}

不能只背 2^3 - 1 = 7。应从最初的 1 个进程出发,在每轮给当时所有仍会继续循环的进程各画出一条新分支:

迭代完成后总进程数本轮新增
开始前10
i=021
i=142
i=284

2. &&、|| 短路与 fork#

假设每次 fork() 都成功,不考虑返回 -1,问下面代码总共创建多少个子进程

fork();
fork() && fork() || fork();
fork();

分析时先把第二行看作 (A && B) || C,其中 A、B、C 都是一次 fork():

  • A 在父进程分支返回正数,才需要计算 B;A 在新子进程分支返回 0,跳过 B,转而计算 C。
  • B 在其父进程分支返回正数,A && B 已为真,不再计算 C;B 在新子进程分支返回 0,则继续计算 C。
  • 最后一行的 fork() 由执行到它的每个进程调用,所以把此前的总进程数再翻倍。

若只有 1 个进程进入第二行,A 的父分支继续 B;B 的父分支贡献 1 条最终路径,B 的子分支再经 C 变成 2 条;A 的子分支也经 C 变成 2 条。因此第二行使每个进入它的进程对应 5 个离开它的进程。第一行 1→2,第二行 2→10,第三行 10→20,所以最终有 20 个进程,新增子进程 19 个。

分享

如果这篇文章对你有帮助,欢迎分享给更多人!

Linux 服务器高阶编程(6):fork、写时复制与进程数量分析
https://joyanblog.com/posts/linux-server-advanced-programming-06-fork-cow-process-count/
作者
Joyan
发布于
2026-10-11
许可协议
CC BY-NC-SA 4.0

目录