目录
[一、进程创建:fork () /vfork () /exec 系列](#一、进程创建:fork () /vfork () /exec 系列)
[1.1 fork () ------ 创建子进程](#1.1 fork () —— 创建子进程)
[1.2 vfork () ------ 过时的 "轻量 fork"](#1.2 vfork () —— 过时的 "轻量 fork")
[第三层:对比级 ------ fork vs vfork](#第三层:对比级 —— fork vs vfork)
[Demo :vfork 的危险行为](#Demo :vfork 的危险行为)
[1.3 exec 系列 ------ 替换进程映像](#1.3 exec 系列 —— 替换进程映像)
[二、进程终止与等待:exit () /wait () /waitpid ()](#二、进程终止与等待:exit () /wait () /waitpid ())
[2.1 exit () 系列 ------ 进程终止](#2.1 exit () 系列 —— 进程终止)
[2.2 wait () ------ 等待子进程终止](#2.2 wait () —— 等待子进程终止)
[第三层:对比级 ------ wait vs waitpid](#第三层:对比级 —— wait vs waitpid)
[2.3 waitpid () ------ 更灵活的等待](#2.3 waitpid () —— 更灵活的等待)
[第三层:对比级 & 项目实战场景](#第三层:对比级 & 项目实战场景)
一、进程创建:fork () /vfork () /exec 系列
1.1 fork () ------ 创建子进程
第一层:定义级
是什么 :fork() 是 Linux 中创建新进程的系统调用,调用一次返回两次。 有什么用 :从一个已存在的进程(父进程)复制出一个几乎完全相同的新进程(子进程),父子进程从 fork() 返回处继续执行。
返回值:
- 子进程中返回 0
- 父进程中返回 子进程的 PID(>0)
- 失败返回 -1
第二层:原理级
底层实现:
- 分配新的 task_struct:内核为子进程分配进程描述符
- 复制进程资源 (写时复制 COW):
- 早期 fork 是完整复制父进程地址空间,效率极低
- 现代 Linux 采用 写时复制(Copy-On-Write) :父子进程共享物理页,页表标记为只读;当任一方尝试写入时,才触发缺页异常,真正复制一页物理内存
- 资源继承与差异化 :
- 子进程继承:地址空间、文件描述符表、信号处理方式、当前工作目录、用户 / 组 ID
- 子进程独有:PID、PPID(设为父进程 PID)、挂起信号集合被清空、文件锁不继承、资源利用统计清零
- 放入运行队列:子进程被调度器接管,开始竞争 CPU
第三层:对比级
| 对比维度 | fork() |
|---|---|
| 地址空间 | 写时复制共享,子进程有独立地址空间 |
| 返回语义 | 调用一次返回两次 |
| 执行起点 | 从 fork 返回处继续执行 |
| 典型用途 | 需要子进程独立运行、保留父进程上下文的场景 |
- fork 之后父子进程谁先执行?A: 不确定,由调度器决定,可通过信号同步
- fork 后文件描述符共享吗?A: 共享文件表项(文件偏移量、文件状态标志共享),但每个进程有自己独立的文件描述符表
- 多线程下 fork 会怎样?A: 子进程中只有调用 fork 的那个线程存在,其他线程都消失,且锁状态可能残留导致死锁 ------ 多线程环境慎用 fork
fork基础语法
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
int num = 100;
pid_t pid = fork();
if (pid == -1) {
perror("fork failed");
return 1;
} else if (pid == 0) {
// ===== 子进程 =====
printf("[子进程] PID=%d, 父PID=%d, num=%d\n", getpid(), getppid(), num);
num = 200; // 触发写时复制
printf("[子进程] 修改后 num=%d\n", num);
_exit(0);
} else {
// ===== 父进程 =====
printf("[父进程] PID=%d, 子PID=%d, num=%d\n", getpid(), pid, num);
wait(NULL); // 等待子进程结束
printf("[父进程] 子进程结束后 num=%d (互不影响)\n", num);
}
return 0;
}

1.2 vfork () ------ 过时的 "轻量 fork"
第一层:定义级
是什么 :vfork() 创建子进程,但子进程直接共享父进程的地址空间 (不复制),且子进程先运行,直到子进程调用 exec 或 _exit 后父进程才恢复运行。 有什么用:历史上为了解决 fork + exec 场景下 fork 全复制的浪费问题而设计。
第二层:原理级
底层实现:
- 不复制页表,子进程直接使用父进程的内存空间
- 父进程被挂起阻塞 ,直到子进程执行
exec或_exit - 子进程在父进程地址空间里跑,修改数据父进程能直接看到
为什么这么设计,又为什么被淘汰:
- 设计初衷:在 COW 出现之前,vfork 比 fork 轻量得多
- 被淘汰原因:
- 语义危险:子进程随便改变量都会影响父进程,极易出 bug
- 现代 fork 有 COW,开销已经很低,vfork 的性能优势不再明显
- POSIX 标准已将 vfork 标记为过时,推荐用 fork 替代
第三层:对比级 ------ fork vs vfork
| 对比项 | fork() | vfork() |
|---|---|---|
| 地址空间 | COW 共享,写时复制 | 完全共享,不复制 |
| 父子执行顺序 | 调度器决定,并发执行 | 子进程先跑,父进程阻塞 |
| 子进程修改数据 | 不影响父进程 | 直接修改父进程数据 |
| 安全性 | 安全,进程隔离 | 危险,容易踩坑 |
| 标准状态 | 标准核心 API | POSIX 标记为 obsolete(过时) |
vfork 本质是 "历史遗留产物",COW 出现后 vfork 存在意义不大 ,且 vfork 子进程不能随便 return,必须用 _exit() 退出
Demo :vfork 的危险行为
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int global = 10;
int main() {
pid_t pid = vfork();
if (pid == -1) {
perror("vfork");
return 1;
} else if (pid == 0) {
// 子进程:直接修改父进程的变量!
global = 999;
printf("[子进程] global = %d\n", global);
_exit(0); // 必须用 _exit,不能用 return 或 exit()
} else {
// 父进程:vfork 保证子进程先跑完才到这里
printf("[父进程] global = %d (被子进程改了!)\n", global);
wait(NULL);
}
return 0;
}

父进程的 global 变量被子进程直接改掉了 ------ 这就是地址空间共享的证明。
1.3 exec 系列 ------ 替换进程映像
第一层:定义级
是什么 :exec 不是一个函数,是一族函数 (execl/execlp/execle/execv/execvp/execvpe),作用是用一个全新的程序替换当前进程的地址空间 ,从新程序的 main 开始执行。 有什么用:fork 出子进程后,通常紧接着调用 exec,让子进程去跑另一个程序(shell 执行命令的经典模式:fork + exec)。
六个函数的记忆法:
l= list(参数列表逐个传)v= vector(参数用数组传)p= path(自动在 PATH 环境变量中查找程序)e= environment(传入自定义环境变量)
| 函数 | 参数形式 | 找程序方式 | 环境变量 |
|---|---|---|---|
| execl | 列表 | 路径全名 | 继承 |
| execlp | 列表 | PATH 搜索 | 继承 |
| execle | 列表 | 路径全名 | 自定义 |
| execv | 数组 | 路径全名 | 继承 |
| execvp | 数组 | PATH 搜索 | 继承 |
| execvpe | 数组 | PATH 搜索 | 自定义 |
第二层:原理级
底层执行流程:
- 验证权限:检查文件是否可执行、ELF 格式是否合法
- 释放旧地址空间:释放原进程的代码段、数据段、堆、栈、共享库映射
- 加载新程序 :
- 读取 ELF 文件头,建立新的虚拟地址空间映射
- 代码段、数据段按段映射到虚拟地址(按需调页,不会全部加载到内存)
- 分配新的用户栈
- 重置进程状态 :
- 信号处理重置为默认(SIG_DFL)
- 清除挂起信号
- 关闭设置了 FD_CLOEXEC 的文件描述符
- 线程属性、内存映射等全部重置
- 跳转到新程序入口 :把 PC 指针指向
_start(C 库入口,最终调用 main)
为什么 exec 不创建新进程:
- 进程是 "资源容器",exec 只换 "内容" 不换 "容器"------PID 不变、PPID 不变、打开的文件描述符(没设 CLOEXEC 的)保留
- 这样设计的好处:fork 负责 "创建容器",exec 负责 "装新内容",职责分离,组合灵活
第三层:对比级
fork + exec 组合 vs 直接创建进程:
- Windows 的 CreateProcess 是 "一步到位" 创建并加载新程序
- Unix 哲学是两步走:fork(复制容器)+ exec(换装内容)
- 两步走的优势:fork 和 exec 之间可以做很多预处理------ 修改文件描述符(重定向)、修改用户权限、设置信号屏蔽、调整环境变量等
二、进程终止与等待:exit () /wait () /waitpid ()
2.1 exit () 系列 ------ 进程终止
第一层:定义级
是什么 :终止当前进程,把退出状态码返回给父进程。 相关函数:
exit(status):标准 C 库函数,正常终止,会做清理工作_exit(status)/_Exit(status):系统调用,直接进入内核,不做 C 库清理return:从 main 返回,编译器会自动调用 exit
有什么用:正常结束进程、返回退出码供父进程判断执行结果。
第二层:原理级
exit () 的完整执行路径:
cpp
用户调用 exit()
↓
1. 调用所有用 atexit() / on_exit() 注册的退出处理函数
2. 刷新并关闭所有打开的标准 I/O 流(printf 缓冲区被刷出)
3. 删除临时文件(tmpfile 创建的)
↓
调用 _exit() 进入内核
↓
4. 关闭所有打开的文件描述符
5. 释放地址空间、释放内存
6. 进程进入 **僵尸状态(Zombie)**:保留 task_struct 和退出状态,等待父进程收尸
7. 给父进程发 SIGCHLD 信号
为什么会有僵尸进程:
- 子进程先于父进程结束,内核不能直接把 task_struct 全释放 ------ 因为父进程可能还需要通过 wait 拿退出状态
- 所以保留最小信息(PID、退出状态、资源使用统计),等父进程 wait 后才彻底释放
- 父进程不 wait → 僵尸进程越积越多 → PID 耗尽
_exit () vs exit () 的区别:
_exit是系统调用,直接进内核,不处理 C 层清理exit是库函数,先做 C 层清理再调_exit- vfork 子进程必须用 _exit,否则会把父进程的 stdio 缓冲区也刷了
第三层:对比级
| 终止方式 | 触发场景 | 是否清理 C 库 | 是否进入僵尸 |
|---|---|---|---|
| return 0 | main 正常返回 | 是(编译器插 exit) | 是 |
| exit() | 代码主动调用 | 是 | 是 |
| _exit() | 需要跳过 C 清理时 | 否 | 是 |
| 被信号杀死(如 SIGKILL) | 外部终止 | 否 | 是 |
Demo :atexit + _exit 对比
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
void cleanup1() { printf("清理函数1 被调用\n"); }
void cleanup2() { printf("清理函数2 被调用\n"); }
int main() {
atexit(cleanup1);
atexit(cleanup2); // 注册顺序与调用顺序相反
printf("printf 缓冲区里的内容"); // 故意不加 \n
// 试试分别注释下面两行,观察区别:
exit(0); // 会调用清理函数 + 刷新缓冲区
// _exit(0); // 直接进内核,啥清理都不做
}
用 _exit 时,那句没换行的 printf 直接消失了 ------ 缓冲区没被刷新。
2.2 wait () ------ 等待子进程终止
第一层:定义级
是什么 :wait(&status) 阻塞等待任意一个 子进程终止,把子进程的退出状态存入 status。 有什么用:父进程回收子进程资源,避免僵尸进程;同时获取子进程的退出信息判断执行结果。
返回值:成功返回收尸的子进程 PID,失败返回 -1。
第二层:原理级
底层做了什么:
- 检查当前进程有没有已终止的子进程(僵尸)
- 有 → 立刻返回那个子进程的 PID 和状态,释放 task_struct
- 没有 → 父进程进入可中断睡眠状态(TASK_INTERRUPTIBLE),挂起等待
- 当有子进程终止时,内核给父进程发 SIGCHLD 信号,唤醒父进程执行收尸
- 解析子进程的退出原因,填充 status(用宏判断):
WIFEXITED(status):正常退出?→ 用WEXITSTATUS(status)取退出码WIFSIGNALED(status):被信号杀死?→ 用WTERMSIG(status)取信号编号WIFSTOPPED(status):被暂停(如 SIGSTOP)?
第三层:对比级 ------ wait vs waitpid
| 特性 | wait() | waitpid() |
|---|---|---|
| 等待对象 | 任意子进程 | 可指定 PID / 进程组 |
| 阻塞行为 | 一定阻塞 | 可设 WNOHANG 非阻塞 |
| 功能 | 基础收尸 | 还能等待暂停的子进程(WUNTRACED) |
| 灵活性 | 低 | 高,生产环境基本都用 waitpid |
2.3 waitpid () ------ 更灵活的等待
第一层:定义级
是什么 :waitpid(pid, &status, options) ------ wait 的增强版。 参数:
pid > 0:等待指定 PID 的子进程pid = -1:等待任意子进程(等价于 wait)pid = 0:等待和当前进程同组的任意子进程pid < -1:等待进程组 ID 等于 pid 绝对值的任意子进程options:WNOHANG:非阻塞,没有子进程结束就立刻返回 0WUNTRACED:子进程暂停也返回(用于调试器)WCONTINUED:子进程从暂停恢复也返回
第二层:原理级
- 父进程不能一直傻等子进程,自己也要干活
- 非阻塞轮询模式:定期检查一下有没有子进程挂了,有就收,没有就继续干自己的
SIGCHLD + waitpid 的经典模式:
- 子进程终止 → 内核发 SIGCHLD 给父进程
- 信号处理函数里循环调用
waitpid(-1, &status, WNOHANG)收尸 - 必须循环调用:因为信号不排队,多个子进程同时退出可能只触发一次信号
第三层:对比级 & 项目实战场景
- 并发服务器:主进程 fork 多个 worker,用 waitpid + SIGCHLD 回收死掉的 worker 并重启
- Shell:前台命令阻塞 waitpid,后台命令不阻塞(& 符号)
- 进程池:父进程管理多个子进程,指定 PID 等待特定子进程
Demo 5: WNOHANG 非阻塞回收
cpp
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdlib.h>
#define CHILD_NUM 3
int main() {
pid_t pids[CHILD_NUM];
// 创建 3 个子进程,各自睡不同时间
for (int i = 0; i < CHILD_NUM; i++) {
pids[i] = fork();
if (pids[i] == 0) {
printf("子进程 %d 启动,休眠 %d 秒\n", getpid(), i + 1);
sleep(i + 1);
printf("子进程 %d 退出\n", getpid());
_exit(i + 1); // 退出码 = i+1
}
}
// 父进程:非阻塞轮询回收
int reaped = 0;
while (reaped < CHILD_NUM) {
int status;
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
reaped++;
if (WIFEXITED(status)) {
printf("回收子进程 %d,退出码 = %d\n", pid, WEXITSTATUS(status));
}
} else if (pid == 0) {
// 没有子进程结束,父进程干点别的
printf("父进程工作中... 暂无子进程退出\n");
sleep(1);
} else {
perror("waitpid");
break;
}
}
printf("所有子进程回收完毕\n");
return 0;
}
父进程不会卡住,每隔 1 秒检查一次,子进程按休眠时长依次被回收。
三、整体对比总结(面试速查表)
| 系统调用 | 核心作用 | 关键特性 | 常见坑 |
|---|---|---|---|
| fork | 创建子进程 | COW、一次调用两次返回 | 多线程 fork 锁问题、文件偏移共享 |
| vfork | 轻量创建子进程 | 共享地址空间、父阻塞 | 已过时、必须 _exit 退出、不能改栈 |
| exec | 替换进程映像 | PID 不变、换装不换壳 | 成功不返回、参数必须 NULL 结尾 |
| exit | 正常终止进程 | 调 atexit、刷 IO 缓冲区 | vfork 子进程不能用 |
| _exit | 直接进内核终止 | 不做 C 层清理 | 缓冲区数据丢失 |
| wait | 阻塞收尸任意子进程 | 简单、阻塞 | 无法指定 PID、无法非阻塞 |
| waitpid | 灵活收尸 | 指定 PID、WNOHANG 非阻塞 | SIGCHLD 处理里必须循环调用 |