前面已经通过 PID、PPID 认识父子进程,也观察过父子进程中“变量地址相同、值却不同”的现象。把这些现象串起来,需要准确理解 fork 的执行位置和返回值。
本文用一个可以自行结束的短实验,重点分析:哪些语句执行一次,哪些语句会被两个进程分别执行,以及父子分支之后的代码会发生什么。写时复制只作简短回顾。
一、fork 创建的是进程,不是一次普通函数分支
接口原型为:
#include<unistd.h>pid_tfork(void);调用前只有原来的进程;创建成功后,有父、子两个进程从 fork 调用返回处继续执行。
这里的“一次调用、两次返回”,指成功后两个进程分别获得返回结果,不是同一个进程把同一条语句执行两遍,也不是子进程重新从 main 开头运行。
| 所在进程或结果 | fork 返回值 | 含义 |
|---|---|---|
| 父进程,创建成功 | 大于 0 | 新创建的子进程 PID |
| 子进程 | 0 | 标识当前处于子进程这条执行路径 |
| 创建失败 | -1 | 没有子进程产生,原进程处理错误 |
返回类型是pid_t。子进程获得 0,并不代表它自己的 PID 是 0;自己的 PID 仍应通过getpid()获取。返回值规则可核对 Linux fork(2) 手册。
二、一张图看清执行路径
这张图表达控制流,不表达谁先执行,也不表示两条路径会在分支结束后重新合成一个进程。
if/else决定每个进程执行哪个分支;分支本身不会自动结束进程。若分支内没有 return、退出或其他改变控制流的操作,那么父子进程都会继续执行后面的共同代码。
三、用短实验观察“分叉点”
保存为fork_flow.c:
#include<errno.h>#include<stdio.h>#include<sys/types.h>#include<sys/wait.h>#include<unistd.h>intmain(void){intvalue=10;printf("before: pid=%ld, value=%d\n",(long)getpid(),value);fflush(stdout);pid_tid=fork();if(id<0){perror("fork");return1;}if(id==0){value=20;printf("child branch: id=%ld, pid=%ld, ppid=%ld, value=%d\n",(long)id,(long)getpid(),(long)getppid(),value);}else{printf("parent branch: id=%ld, pid=%ld, value=%d\n",(long)id,(long)getpid(),value);}printf("after branches: pid=%ld, value=%d\n",(long)getpid(),value);fflush(stdout);if(id>0){while(waitpid(id,NULL,0)<0){if(errno==EINTR)continue;perror("waitpid");return1;}}return0;}编译、运行:
gcc-Wall-Wextra-O0-gfork_flow.c-ofork_flow ./fork_flow与持续打印的实验不同,这里只创建一个子进程,打印后自行结束。末尾的 waitpid 用于让父进程等待并回收自己的子进程;遇到信号中断时重试。它不参与前面两个分支的选择,也不保证父进程的打印先于子进程。进程等待的接口细节可以在后续单独整理。
本例是单线程教学程序,不应原样套用到多线程程序的 fork 后处理逻辑中。
四、按语句位置分析输出
假设 fork 成功,并且各输出操作正常完成:
| 输出位置 | 执行次数 | 执行者 |
|---|---|---|
before | 1 次 | 创建子进程前的原进程 |
child branch | 1 次 | 子进程 |
parent branch | 1 次 | 父进程 |
after branches | 2 次 | 父子进程各 1 次 |
这里讨论的是这个程序中的语句执行次数,而不是所有 fork 程序都必定如此。
可以用 P 表示父进程 PID,C 表示子进程 PID,分析各字段的预期关系:
父进程:id=C,pid=P,value=10 子进程:id=0,pid=C,ppid=P,value=20P、C 只是符号,不是实际运行日志。实际 PID 和父子输出先后由运行环境决定。
每个进程内部仍然遵循自己的顺序:先执行自己的分支,再执行自己的after branches。但是不同进程之间没有因此形成“父分支、子分支、共同代码”的统一顺序。
为什么 fork 前要 fflush
stdio 有用户态输出缓冲区。fork 前若有尚未刷出的内容,子进程会继承这部分缓冲状态;父子进程后续刷新时,可能让同一段旧内容显示多次,尤其是在输出被重定向到文件时。
示例在 fork 前调用fflush(stdout),是为了将“打印语句执行次数”和“缓冲内容刷新次数”区分开。不能因为屏幕或文件里出现两份文本,就直接认定 fork 前的 printf 被重新执行了。
五、两个进程中的 id 为什么可以不同
pid_tid=fork();成功后,父进程和子进程各自继续执行,并把各自获得的返回值用于自己的 id 对象。
父进程保存子 PID,子进程保存 0。不要把它理解成两个进程共同使用一个 id,然后某个进程把另一个进程的值覆盖掉了。
value 也有类似的独立性:在 fork 时子进程继承它的初始内容,之后子进程赋值 20,父进程的普通局部变量仍保持 10。变量是否是全局变量,并不是判断它能否跨进程共享的依据。
六、谁先运行,不能靠代码上下位置推断
下面这些推断都不成立:
- 子分支写在前面,所以子进程先运行。
- 父进程创建子进程,所以父进程一定先打印。
- 父进程 sleep 一秒,就能严格保证子进程完成任务。
父子进程的执行先后取决于调度等因素。sleep 只能让调用者暂时休眠,不能代替可靠的进程同步。
本例末尾的 waitpid 只保证父进程在那里等待子进程结束,不会倒过来约束已经执行过的 printf。如果某个操作必须发生在子进程结束之后,应将该操作放在成功等待之后。
七、写时复制:回顾实现,不重新展开地址空间
从程序可见行为看,父子进程的普通私有数据修改互不影响;从 Linux 的实现看,fork 不必立即复制全部物理数据页。
Linux 使用写时复制机制:相关页面可以暂时共用,当进程写入需要复制的页面时,再由内核处理复制和映射更新。这解释了为什么“数据独立”不等于“创建瞬间把所有物理内存完整拷贝一遍”。
本实验输出只能观察到变量值的独立性,不能直接证明某一时刻的物理页共享情况。写时复制与虚拟地址映射的详细示意,已在上一篇中整理。
同时,不要把“普通数据修改独立”扩大为“所有资源都完全独立”。例如父子进程继承的文件描述符可能引用同一个打开文件描述,显式共享映射也有不同的语义;这些需要另行讨论。
八、写 fork 判断时常见的错误
1. 只写 if 和 else,漏掉失败路径
if(id==0){/* 子进程 */}else{/* 这里既可能 id > 0,也可能 id == -1 */}应先检查id < 0,或明确分成< 0、== 0、> 0三种情况,不能把创建失败当成创建成功的父分支。
2. 认为父子分支结束后只剩一个进程
花括号只结束代码块,不会把两个进程合并。后面的普通语句仍可能被两个进程分别执行。
3. 把 fork 放入无界循环
如果不断创建子进程,并让子进程也继续创建,进程数量可能迅速增加,耗尽资源。入门实验应明确限制创建次数,不运行无界创建进程的代码。
fork 也可能因进程数量限制或内存资源等原因失败;失败时应检查返回值并处理,而不是假定创建总会成功。
九、复习时如何判断一段 fork 代码
先找到 fork 的位置,再依次回答:
- 调用前有几个进程,哪些进程会执行到这次调用?
- 创建是否成功?失败路径是否单独处理?
- 成功后,每个进程分别获得什么返回值?
- 各自进入哪个分支?分支内会不会提前退出?
- 分支后的代码,会被哪些仍存活的进程继续执行?
核心结论是:fork 成功后是两个进程沿各自的路径继续执行,不是一个进程依次执行两套角色。