本文档面向复习和查阅。
即使过一段时间忘了,也可以从目录、重点、示例和注意事项中快速恢复理解。
- 环境:Linux + GCC + C 语言。
目录
第一部分:进程基础与 fork
- 进程、程序和 CPU
- 虚拟内存和物理内存映射
- PCB 进程控制块
- 环境变量
- fork 函数原理
- fork 创建子进程
- getpid 和 getppid
- 循环创建多个子进程
- 父子进程共享哪些内容
- 第一部分总结
第二部分:exec、进程回收与 wait
- 复习:核心模型
- 父子进程 GDB 调试
- exec 函数族原理
- execlp 和 execl 函数
- exec 函数族特性
- 孤儿进程和僵尸进程
- wait 回收子进程
- 获取子进程退出值和异常终止信号
- waitpid 回收子进程
- 中午回顾
- 错误解析
- waitpid 回收多个子进程
- wait 和 waitpid 总结
- 综合示例:fork + exec + waitpid
第三部分:面试常问、易错点与难点讲解
- 面试常问问题
- 易错点汇总
- 不易懂的点深入讲解
附录:速查表与复习建议
第一部分:进程基础与 fork
1. 进程、程序和 CPU
重点
- 程序是磁盘上的静态文件。
- 进程是程序的一次运行实例。
- 同一个程序可以运行出多个进程。
- CPU 通过调度器在进程之间切换,实现并发。
讲解
a.out 放在磁盘上,它是程序。运行它,产生一个进程。再运行一次,又产生一个进程。两个进程互不影响,各有各的 PID、内存、文件描述符。
CPU 只有一个或几个核,但进程可以有很多。操作系统让每个进程运行一小段时间,然后切换,看起来像同时运行。
进程常见状态:
| 状态 | 含义 |
|---|---|
| 运行 | 正在 CPU 上执行或就绪 |
| 可中断睡眠 | 等待事件,可被信号唤醒 |
| 不可中断睡眠 | 通常等待 IO,普通信号打不断 |
| 停止 | 被暂停 |
| 僵尸 | 已退出,等待父进程回收 |
| 死亡 | 完全释放 |
示例
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("pid = %d, ppid = %d\n", getpid(), getppid());
return 0;
}注意
- 并发是宏观同时、微观交替。
- 并行是真正同时执行,需要多核 CPU。
- 查看进程常用:
ps -ef、top、cat /proc/<pid>/status。
2. 虚拟内存和物理内存映射
重点
- 每个进程有独立的虚拟地址空间。
- CPU 通过 MMU 和页表把虚拟地址转换为物理地址。
- fork 后父子进程采用写时复制,不是一上来就复制全部内存。
讲解
如果每个进程直接操作物理内存,会互相踩踏,也不安全。虚拟内存让每个进程都以为自己独占内存,实际由内核映射到物理页。
关键角色:
- 页表:记录虚拟页到物理页的映射。
- MMU:CPU 中负责地址转换的硬件。
- TLB:页表缓存,加速地址转换。
- 缺页异常:访问的页不在物理内存时触发,由内核加载。
典型虚拟地址空间:
高地址
+------------------+
| 内核空间 |
+------------------+
| 栈 | 局部变量、函数调用
+------------------+
| mmap 区域 | 共享库、文件映射
+------------------+
| 堆 | malloc/new
+------------------+
| BSS | 未初始化全局变量
+------------------+
| Data | 已初始化全局变量
+------------------+
| Text | 代码段,通常只读
+------------------+
低地址写时复制 COW:
fork 后,父子进程的虚拟页最初指向同一物理页。只要没人写,就一直共享。一旦某一方写入,内核才复制出新页。这样 fork 很快,也省内存。
示例
cat /proc/$$/maps注意
- 父子进程变量看起来一样,但修改互不影响。
- 想真正共享内存,要用
mmap(MAP_SHARED)或共享内存。
3. PCB 进程控制块
重点
- PCB 是内核管理进程的数据结构,Linux 中对应
task_struct。 - 进程退出但未被回收时,PCB 不释放,形成僵尸进程。
讲解
PCB 中通常包含:
- PID、PPID、进程状态
- CPU 寄存器上下文
- 虚拟内存信息
- 打开文件表
- 信号信息
- 调度信息
- 父子进程关系
- 资源限制
内核靠 PCB 找到进程的一切。进程切换时,把当前寄存器保存到 PCB,再从下一个进程的 PCB 恢复寄存器。
示例
cat /proc/<pid>/status
ps -ef注意
- 僵尸进程占 PID 和 PCB。
- 大量僵尸会耗尽系统资源。
4. 环境变量
重点
- 环境变量是
name=value形式的进程运行环境。 - fork 后子进程继承环境变量副本。
- exec 默认传递当前环境,也可以指定新环境。
讲解
常用函数:
char *getenv(const char *name);
int setenv(const char *name, const char *value, int overwrite);
int unsetenv(const char *name);示例
#include <stdio.h>
#include <stdlib.h>
extern char **environ;
int main(void) {
for (char **p = environ; *p; p++)
printf("%s\n", *p);
printf("PATH = %s\n", getenv("PATH"));
setenv("MYVAR", "hello", 1);
printf("MYVAR = %s\n", getenv("MYVAR"));
unsetenv("MYVAR");
return 0;
}注意
setenv第三个参数为 0 时,已存在不覆盖。execle、execvpe可以显式传环境表。
5. fork 函数原理
重点
- fork 调用一次,返回两次。
- 父进程返回子进程 PID,大于 0。
- 子进程返回 0。
- 失败返回 -1。
讲解
函数原型:
#include <sys/types.h>
#include <unistd.h>
pid_t fork(void);内核做了什么:
- 为子进程创建 PCB。
- 复制父进程地址空间信息。
- 复制文件描述符表。
- 数据采用写时复制。
- 子进程加入调度队列。
- 父子进程从 fork 返回处继续执行。
为什么父进程返回子 PID,子进程返回 0?
父进程可能有很多子进程,必须知道新建的是哪个。子进程只有一个父进程,用 getppid 就能拿到,所以返回 0 表示“我是子进程”。
示例
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
printf("child: pid=%d, ppid=%d\n", getpid(), getppid());
} else {
printf("parent: pid=%d, child=%d\n", getpid(), pid);
wait(NULL);
}
return 0;
}注意
- fork 前 printf 若缓冲区未刷新,父子可能各输出一次。
- 解决:
fflush(stdout)或使用write。
6. fork 创建子进程
重点
- 用 fork 返回值区分父子进程,分别执行不同逻辑。
- 子进程退出建议用
_exit,避免刷新父进程 stdio 缓冲区。
示例
pid_t pid = fork();
if (pid == 0) {
// 子进程
_exit(0);
} else {
// 父进程
wait(NULL);
}注意
- 子进程是父进程的副本,从 fork 之后继续执行。
- 父子执行顺序不确定,取决于调度。
7. getpid 和 getppid
重点
getpid()获取当前进程 PID。getppid()获取父进程 PID。
示例
#include <stdio.h>
#include <unistd.h>
int main(void) {
printf("pid=%d, ppid=%d\n", getpid(), getppid());
return 0;
}注意
- 父进程先退出,子进程可能被 init 或 systemd 收养,PPID 变为 1。
- 调试时可用
ps -ef对照。
8. 循环创建多个子进程
重点
- 子进程创建后必须
break或_exit,否则会继续创建孙子进程。 - 父进程保存每个子进程 PID,最后逐个回收。
错误写法
for (int i = 0; i < 5; i++) {
fork();
printf("hello\n");
}这样会产生大量进程,数量不是 5 个,而是指数级。
正确示例
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
#define N 5
int main(void) {
pid_t pids[N];
for (int i = 0; i < N; i++) {
pid_t pid = fork();
if (pid == 0) {
printf("child %d: pid=%d\n", i, getpid());
_exit(0);
} else {
pids[i] = pid;
}
}
for (int i = 0; i < N; i++) {
waitpid(pids[i], NULL, 0);
}
return 0;
}注意
- 子进程用
_exit,不要用exit,避免重复刷新缓冲区。 - 父进程必须回收,否则僵尸。
9. 父子进程共享哪些内容
重点
- 共享或继承:代码段、文件描述符表、打开文件描述、环境变量副本、工作目录、用户 ID、信号处理方式。
- 不共享:PID、PPID、虚拟地址空间、栈、堆、全局变量、寄存器、未决信号、定时器。
讲解
最容易考的是文件描述符:
fork 后,父子进程的 fd 指向同一个打开文件描述。它们共享文件偏移和状态标志。
示例
#include <stdio.h>
#include <unistd.h>
#include <fcntl.h>
#include <sys/wait.h>
int main(void) {
int fd = open("out.txt", O_WRONLY | O_CREAT | O_TRUNC, 0644);
pid_t pid = fork();
if (pid == 0) {
write(fd, "child\n", 6);
_exit(0);
} else {
wait(NULL);
write(fd, "parent\n", 7);
}
close(fd);
return 0;
}注意
- 父子写同一个 fd,偏移会连续。
- 想共享内存数据,用
mmap(MAP_SHARED)。
10. 第一部分总结
重点
- 程序静态,进程动态。
- 虚拟内存隔离进程,物理内存由页表映射。
- PCB 是内核管理进程的核心。
- fork 父返回子 PID,子返回 0。
- 循环 fork 子进程要退出循环。
- 父子共享文件描述符,但地址空间写时复制。
- 子进程必须回收。
第二部分:exec、进程回收与 wait
11. 复习:核心模型
重点
核心模型:
fork 创建子进程
exec 替换子进程程序
wait/waitpid 回收子进程示例
pid_t pid = fork();
if (pid == 0) {
execlp("ls", "ls", "-l", NULL);
_exit(1);
} else {
wait(NULL);
}注意
- exec 成功不返回。
- exec 失败才返回 -1。
12. 父子进程 GDB 调试
重点
- GDB 默认跟踪父进程,子进程被 detach。
- 用
follow-fork-mode和detach-on-fork控制。
常用命令
set follow-fork-mode parent
set follow-fork-mode child
set detach-on-fork off
catch fork
run
info inferiors
inferior 2说明:
set follow-fork-mode child:跟踪子进程。set detach-on-fork off:父子都保留在 GDB 控制下。info inferiors:查看被调试进程。inferior 2:切换进程。
注意
- 不设置的话,子进程直接跑掉,断点打不到。
13. exec 函数族原理
重点
- exec 不创建新进程,而是替换当前进程的用户空间。
- PID 不变,父进程关系不变。
- 成功不返回,失败返回 -1。
讲解
替换内容:
- 代码段
- 数据段
- 堆
- 栈
- 程序计数器
不改变:
- PID
- PPID
- 进程组、会话
- 打开的文件描述符,除非设置
FD_CLOEXEC
示例
#include <unistd.h>
int main(void) {
execl("/bin/ls", "ls", "-l", NULL);
perror("execl");
return 1;
}注意
- exec 成功后原程序后续代码不再执行。
- 失败必须处理错误。
14. execlp 和 execl 函数
重点
execl:需要完整路径。execlp:使用 PATH 环境变量查找。- 参数列表以
NULL结尾。
原型
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);示例
execl("/bin/ls", "ls", "-l", NULL);
execlp("ls", "ls", "-l", NULL);注意
argv[0]通常是程序名。- 忘了
NULL会导致未定义行为。
15. exec 函数族特性
重点
命名规律:
l:list,参数逐个列出。v:vector,参数用数组。p:PATH,自动查找。e:environment,指定环境变量。
常见函数
execl, execlp, execle,
execv, execvp, execvpe示例
char *argv[] = {"ls", "-l", NULL};
execv("/bin/ls", argv);
char *envp[] = {"PATH=/bin:/usr/bin", NULL};
execle("/bin/ls", "ls", "-l", NULL, envp);注意
- 被捕获的信号处理函数通常重置为默认。
- 被忽略的信号仍然忽略。
- fd 默认保留,可设置
FD_CLOEXEC关闭。
16. 孤儿进程和僵尸进程
重点
- 孤儿进程:父进程先退出,子进程还在运行。被 init 或 systemd 收养。
- 僵尸进程:子进程退出,父进程没回收。PCB 不释放,状态 Z。
孤儿进程示例
if (fork() == 0) {
sleep(5);
printf("ppid=%d\n", getppid());
} else {
_exit(0);
}僵尸进程示例
if (fork() == 0) {
_exit(0);
} else {
sleep(100); // 不回收
}查看
ps aux | grep Z注意
- 孤儿进程一般无害。
- 僵尸进程占资源,必须避免。
- 解决:wait、waitpid、SIGCHLD。
17. wait 回收子进程
重点
- wait 阻塞等待任意一个子进程退出。
- 返回被回收子进程 PID,失败返回 -1。
原型
#include <sys/wait.h>
pid_t wait(int *status);示例
int status;
pid_t pid = wait(&status);
if (WIFEXITED(status)) {
printf("exit=%d\n", WEXITSTATUS(status));
}注意
status不是退出码,必须用宏解析。- 没有子进程时返回 -1,
errno = ECHILD。
18. 获取子进程退出值和异常终止信号
重点
status 编码了正常退出码、终止信号、core dump 等信息。
常用宏:
WIFEXITED(status) // 是否正常退出
WEXITSTATUS(status) // 正常退出码
WIFSIGNALED(status) // 是否被信号终止
WTERMSIG(status) // 终止信号编号
WCOREDUMP(status) // 是否产生 core示例
#include <signal.h>
#include <sys/wait.h>
pid_t pid = fork();
if (pid == 0) {
raise(SIGSEGV);
_exit(1);
} else {
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
printf("normal exit: %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("killed by signal: %d\n", WTERMSIG(status));
}
}注意
- 不要直接打印
status。 - 异常终止时退出码无意义。
19. waitpid 回收子进程
重点
- waitpid 可以指定回收哪个子进程。
- 可以非阻塞回收。
- 可以按进程组回收。
原型
pid_t waitpid(pid_t pid, int *status, int options);pid 参数:
| 值 | 含义 |
|---|---|
> 0 | 等待指定子进程 |
-1 | 等待任意子进程 |
0 | 等待同进程组任意子进程 |
< -1 | 等待进程组 ID 为 pid 的子进程 |
options:
0:阻塞
(进程休眠/暂停的阻塞,等待唤醒)WNOHANG:非阻塞
(非阻塞。调用后如果没有可报告的子进程事件,立刻返回 0,父进程不睡眠。)WUNTRACED:除了返回终止的子进程,也返回那些"被暂停且尚未被报告过"的子进程。
(比如:Ctrl+Z 暂停)WCONTINUED:也返回那些"从暂停态被恢复运行"的子进程。
(暂停的子进程,收到 SIGCONT 恢复运行后,waitpid 会返回)
示例
int status;
pid_t ret = waitpid(pid, &status, 0);
if (ret == pid && WIFEXITED(status)) {
printf("exit=%d\n", WEXITSTATUS(status));
}注意
WNOHANG返回 0 表示暂时没有子进程退出,不是错误。- 返回 -1 才是错误。
20. 回顾
重点
主线:
fork 创建子进程
exec 替换子进程程序
wait/waitpid 回收子进程容易混淆:
- 程序 vs 进程
- 虚拟内存 vs 物理内存
- 孤儿进程 vs 僵尸进程
- wait vs waitpid
- execl vs execlp
21. 错误解析
重点
常见错误和解决:
1. fork 前 printf 重复输出
- 原因:stdio 缓冲区被复制。
- 解决:
fflush(stdout)或write。
2. 把 status 当退出码
- 错误:
printf("%d", status); - 正确:
WEXITSTATUS(status)。
3. waitpid 返回 0 当错误
WNOHANG时返回 0 表示无子进程退出。
4. ECHILD
- 没有可等待子进程。
5. EINTR
- 被信号中断,可重试:
while (waitpid(pid, &status, 0) < 0) {
if (errno != EINTR) break;
}6. 循环 fork 不 break
- 子进程继续循环,进程爆炸。
7. exec 失败后继续执行
- exec 失败才返回,必须
perror并_exit。
8. 僵尸进程
- 父进程不回收。用 wait/waitpid 或 SIGCHLD。
9. GDB 默认跟父进程
- 需
set follow-fork-mode child。
注意
- 所有系统调用都要检查返回值。
22. waitpid 回收多个子进程
重点
- 已知 PID:逐个 waitpid。
- 未知顺序:
waitpid(-1, ...)循环。 - 非阻塞:
WNOHANG。
已知 PID
for (int i = 0; i < N; i++) {
int status;
waitpid(pids[i], &status, 0);
}任意顺序
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, 0)) > 0) {
if (WIFEXITED(status)) {
printf("child %d exit=%d\n", pid, WEXITSTATUS(status));
}
}非阻塞
while (1) {
int status;
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
printf("reaped %d\n", pid);
} else if (pid == 0) {
break;
} else {
if (errno == EINTR) continue;
break;
}
}注意
- SIGCHLD 处理函数中应循环调用 waitpid,因为多个子进程退出可能合并为一个信号。
23. wait 和 waitpid 总结
重点
| 对比项 | wait | waitpid |
|---|---|---|
| 原型 | pid_t wait(int *status) | pid_t waitpid(pid_t pid, int *status, int options) |
| 等待对象 | 任意子进程 | 可指定子进程/进程组 |
| 阻塞 | 默认阻塞 | 可选 WNOHANG |
| 返回 | 子 PID 或 -1 | 子 PID、0 或 -1 |
| 灵活性 | 简单 | 更灵活 |
怎么选:
- 单个子进程、简单场景:wait。
- 多子进程、指定回收、非阻塞:waitpid。
注意
- 父进程必须回收子进程,避免僵尸。
24. 综合示例:fork + exec + waitpid
重点
这是 Linux 最常用的多进程模型。
流程:
父进程 fork
子进程 exec 执行新程序
父进程 waitpid 回收示例
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void) {
pid_t pid = fork();
if (pid < 0) {
perror("fork");
return 1;
}
if (pid == 0) {
execlp("ls", "ls", "-l", NULL);
perror("execlp");
_exit(1);
} else {
int status;
pid_t ret = waitpid(pid, &status, 0);
if (ret == pid) {
if (WIFEXITED(status)) {
printf("child exited with %d\n", WEXITSTATUS(status));
} else if (WIFSIGNALED(status)) {
printf("child killed by signal %d\n", WTERMSIG(status));
}
}
}
return 0;
}注意
- 这是 shell 执行外部命令的简化模型。
- 实际 shell 还要处理管道、重定向、作业控制、信号等。
第三部分:面试常问、易错点与难点讲解
25. 面试常问问题
25.1 fork 的返回值为什么这样设计?
问题:为什么父进程返回子 PID,子进程返回 0?
答:
父进程可能有多个子进程,必须知道新建的是哪一个,所以返回子 PID。子进程只有一个父进程,用 getppid() 就能拿到,不需要额外返回。返回 0 表示“创建成功,且我是子进程”。
如果返回 -1,说明创建失败,errno 会说明原因,比如 EAGAIN(进程数超限)、ENOMEM(内存不足)。
25.2 fork 后父子进程谁先执行?
问题:fork 后父进程和子进程谁先运行?
答:
不确定,取决于内核调度器。不要假设父进程或子进程一定先执行。如果需要顺序控制,必须用同步机制,如 wait、管道、信号、信号量。
这是一个常见陷阱:有人写代码时默认子进程先跑,结果在不同机器上表现不同。
25.3 为什么 fork 要用写时复制?
问题:为什么 fork 不直接复制全部内存?
答:
直接复制全部内存很慢,而且很多情况下子进程马上调用 exec 替换程序,复制的内存根本用不上。写时复制让父子进程先共享物理页,只有某一方写入时才复制,大幅提高 fork 效率。
25.4 fork 和 vfork 有什么区别?
问题:fork 和 vfork 有什么区别?
答:
| 对比项 | fork | vfork |
|---|---|---|
| 地址空间 | 写时复制,父子独立 | 子进程共享父进程地址空间 |
| 父进程 | 不阻塞 | 父进程阻塞,直到子进程 exec 或 exit |
| 使用场景 | 通用 | 子进程马上 exec |
| 风险 | 较低 | 子进程修改数据会影响父进程,容易出错 |
现在 vfork 基本不推荐使用,fork 加 COW 已经足够高效。
25.5 僵尸进程和孤儿进程有什么区别?
问题:僵尸进程和孤儿进程有什么区别?
答:
| 对比项 | 僵尸进程 | 孤儿进程 |
|---|---|---|
| 谁先退出 | 子进程先退出 | 父进程先退出 |
| 子进程状态 | 已退出,PCB 未释放 | 还在运行 |
| 是否占用资源 | 占用 PID 和 PCB | 被 init 收养,正常 |
| 危害 | 大量僵尸耗尽资源 | 一般无害 |
| 解决 | wait/waitpid 回收 | 无需特别处理 |
25.6 wait 和 waitpid 有什么区别?
问题:wait 和 waitpid 有什么区别?
答:
wait 只能等待任意一个子进程,阻塞。waitpid 可以指定子进程、进程组,可以非阻塞(WNOHANG),更灵活。多子进程或需要非阻塞时用 waitpid。
25.7 exec 成功后原进程还在吗?
问题:exec 成功后,原进程还在吗?
答:
在,PID 不变,但用户空间内容被新程序替换。代码段、数据段、堆、栈都被替换,原程序后续代码不再执行。所以 exec 成功不返回。
25.8 为什么 exec 失败后要 _exit?
问题:exec 失败后为什么通常写 _exit(1) 而不是 return 1?
答:
exec 失败后,子进程还在运行。如果 return,可能回到调用者继续执行父进程逻辑,导致重复执行。应该 _exit 立即退出,避免刷新父进程的 stdio 缓冲区。
25.9 父子进程共享文件描述符会怎样?
问题:fork 后父子进程共享文件描述符,会有什么效果?
答:
父子 fd 指向同一个打开文件描述,共享文件偏移和状态标志。父子都写同一个 fd,内容会连续追加,不会互相覆盖。如果想各自独立偏移,需要重新 open。
25.10 SIGCHLD 处理函数里为什么要循环 waitpid?
问题:在 SIGCHLD 处理函数中回收子进程,为什么必须循环调用 waitpid?
答:
因为多个子进程可能几乎同时退出,内核可能把它们合并成一个 SIGCHLD 信号。如果只调用一次 waitpid,只能回收一个子进程,其他子进程会变成僵尸。必须用 while (waitpid(-1, &status, WNOHANG) > 0) 循环回收。
25.11 什么是守护进程?
问题:守护进程是什么?怎么创建?
答:
守护进程是脱离终端、在后台长期运行的进程。创建步骤:
- fork 后父进程退出,子进程被 init 收养。
setsid创建新会话,脱离控制终端。- 再次 fork,确保不是会话首进程。
chdir("/")改变工作目录。- 重定向标准输入、输出、错误到
/dev/null。 - 关闭不需要的文件描述符。
25.12 为什么 fork 前要 fflush?
问题:为什么 fork 前要 fflush(stdout)?
答:
fork 会复制父进程的 stdio 缓冲区。如果缓冲区里有未刷新的数据,父子进程各有一份,退出时各自刷新,导致重复输出。解决:fork 前 fflush(stdout),或使用 write 无缓冲输出。
25.13 为什么子进程要用 _exit 而不是 exit?
问题:子进程退出为什么推荐 _exit?
答:
exit 会刷新 stdio 缓冲区、调用 atexit 注册的函数。子进程继承的缓冲区如果有数据,exit 会把它刷新出来,可能导致重复输出。_exit 直接进入内核退出,不刷新缓冲区,更安全。
25.14 什么是进程组和会话?
问题:进程组和会话是什么?
答:
- 进程组:一组相关进程的集合,通常一个作业对应一个进程组。
- 会话:一个或多个进程组的集合,通常一个终端登录对应一个会话。
waitpid 的 pid 参数可以按进程组等待。setsid 用于创建新会话,是守护进程的关键步骤。
25.15 fork 失败的原因有哪些?
问题:fork 失败常见原因?
答:
EAGAIN:进程数达到用户限制,或系统进程总数限制。ENOMEM:内存不足,无法创建 PCB 和页表。ENOSYS:平台不支持 fork。
处理方式:检查返回值,打印 errno,做降级或退出。
26. 易错点汇总
26.1 fork 前 printf 重复输出
现象:父子进程各输出一次相同内容。
原因:stdio 缓冲区被 fork 复制。
解决:
printf("hello\n");
fflush(stdout); // 先刷新
pid_t pid = fork();或者使用 write(1, "hello\n", 6);。
26.2 循环 fork 不 break
现象:进程数量指数增长。
原因:子进程继续执行循环,创建孙子进程。
解决:
for (int i = 0; i < N; i++) {
pid_t pid = fork();
if (pid == 0) {
// 子进程任务
_exit(0); // 立即退出,不再循环
}
// 父进程继续循环
}26.3 把 status 当退出码
错误:
printf("exit = %d\n", status);正确:
if (WIFEXITED(status)) {
printf("exit = %d\n", WEXITSTATUS(status));
}status 是编码值,不是退出码。
26.4 waitpid 返回 0 当错误
现象:使用 WNOHANG 时返回 0,程序报错。
原因:0 表示暂时没有子进程退出,不是错误。
正确判断:
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
// 回收成功
} else if (pid == 0) {
// 暂无子进程退出,稍后再试
} else {
// 出错
}26.5 exec 失败后继续执行原代码
错误:
execlp("ls", "ls", NULL);
printf("这行还会执行\n"); // exec 失败才会到这里正确:
execlp("ls", "ls", NULL);
perror("execlp");
_exit(1);26.6 忘记回收子进程
现象:ps aux | grep Z 看到僵尸。
原因:父进程没调用 wait / waitpid。
解决:
wait(NULL);
// 或
waitpid(pid, &status, 0);26.7 误以为 fork 后父子共享变量
错误想法:子进程修改全局变量,父进程能看到。
实际:地址空间写时复制,修改互不影响。
要共享数据:使用 mmap(MAP_SHARED)、共享内存、管道、socket 等。
26.8 误以为 exec 会创建新进程
错误想法:exec 创建新进程。
实际:exec 替换当前进程的用户空间,PID 不变。
26.9 忘记 waitpid 的第三个参数
错误:
waitpid(pid, &status); // 参数不够正确:
waitpid(pid, &status, 0);26.10 GDB 调试 fork 后找不到子进程
原因:GDB 默认跟踪父进程,子进程被 detach。
解决:
set follow-fork-mode child
set detach-on-fork off26.11 exec 参数忘记 NULL 结尾
错误:
execlp("ls", "ls", "-l"); // 缺少 NULL正确:
execlp("ls", "ls", "-l", NULL);缺少 NULL 会导致未定义行为,可能崩溃或读取垃圾参数。
26.12 信号处理函数中调用非异步安全函数
错误:在 SIGCHLD 处理函数中调用 printf、malloc。
原因:这些函数不是异步信号安全的,可能死锁。
正确:只调用 waitpid、write 等异步安全函数。复杂处理放到主循环。
27. 不易懂的点深入讲解
27.1 写时复制 COW 到底怎么工作?
背景:很多人知道 fork 用 COW,但说不清楚细节。
深入:
fork 时,内核为子进程创建新的页表,但页表项指向父进程相同的物理页,同时把这些页标记为只读。父子进程的虚拟地址不同,但物理页相同。
当某一方尝试写入时,CPU 触发页保护异常。内核捕获后,分配一个新物理页,复制原页内容,修改页表指向新页,并恢复可写权限。然后重新执行写指令。
关键点:
- COW 以页为单位,通常 4KB。
- 只读代码段不需要复制,一直共享。
- 如果父子都不写,物理内存不增加。
- COW 让 fork 很快,但写入时会有额外开销。
示例:
int x = 10;
pid_t pid = fork();
if (pid == 0) {
x = 20; // 触发 COW,子进程有自己的 x
printf("child x = %d\n", x); // 20
} else {
wait(NULL);
printf("parent x = %d\n", x); // 10
}27.2 为什么 fork 后父子进程的变量地址相同但值独立?
背景:打印 &x,父子地址一样,但修改互不影响,很迷惑。
深入:
父子打印的是虚拟地址,虚拟地址相同,但通过各自页表映射到不同物理页(写之后)。写之前映射到同一物理页,但都是只读。写之后子进程页表指向新物理页。
所以“地址相同”是虚拟地址相同,“值独立”是物理页不同。这正是虚拟内存的威力。
27.3 文件描述符共享和文件偏移
背景:父子共享 fd,但“共享”到底共享什么?
深入:
进程的 fd 表是独立的,但 fd 表项指向同一个“打开文件描述”(struct file)。打开文件描述中包含文件偏移、状态标志、文件引用计数。
所以:
- 父子 fd 数字可能相同,也可能不同。
- 但它们指向同一个打开文件描述。
- 共享文件偏移。
- 共享
O_APPEND等状态标志。 - 如果子进程
close(fd),只是减少引用计数,父进程的 fd 仍然有效。 - 如果子进程重新 open 同一文件,会得到新的打开文件描述,偏移独立。
图示:
父进程 fd 表 打开文件描述 文件
fd 3 -----------> offset=0 -------> out.txt
flags=O_WRONLY
子进程 fd 表 refcount=2
fd 3 ----------->27.4 exec 后文件描述符会怎样?
背景:exec 替换了进程,fd 还在吗?
深入:
默认情况下,exec 保留所有未设置 FD_CLOEXEC 的文件描述符。这是 shell 实现重定向的基础:shell 先 open 文件,dup2 到标准输入输出,然后 exec 新程序,新程序继承这些 fd。
如果设置 FD_CLOEXEC:
int fd = open("file", O_RDONLY);
fcntl(fd, F_SETFD, FD_CLOEXEC);
execvp("prog", argv); // fd 会被关闭这可以防止 fd 泄漏到新程序。
27.5 为什么 wait 能拿到子进程退出状态?
背景:子进程退出后,状态怎么传给父进程?
深入:
子进程退出时,内核把退出状态保存在子进程 PCB 中,并把子进程状态改为僵尸。父进程调用 wait / waitpid 时,内核从 PCB 中取出状态,写入 status 参数,然后释放 PCB。
如果父进程一直不调用 wait,PCB 一直保留,这就是僵尸进程。
status 不是简单退出码,而是编码了多种信息:
位 0-6:终止信号编号
位 7:是否 core dump
位 8-15:退出码所以必须用 WIFEXITED、WEXITSTATUS 等宏解析。
27.6 SIGCHLD 信号为什么会合并?
背景:多个子进程退出,为什么只收到一个 SIGCHLD?
深入:
信号不是队列,而是位图。如果同一个信号在未处理时再次产生,内核只记录一次。所以多个子进程几乎同时退出,父进程可能只收到一个 SIGCHLD。
解决:在处理函数中循环调用 waitpid,直到没有更多子进程可回收。
void handler(int sig) {
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// 回收一个
}
}27.7 为什么 fork 后子进程不能用父进程的锁?
背景:父进程持有互斥锁,fork 后子进程会怎样?
深入:
fork 只复制调用线程。如果父进程其他线程持有锁,子进程中的锁状态是“已锁定”,但持有者线程不存在,子进程尝试加锁会死锁。
这就是 fork 和多线程的经典问题。解决:
- fork 后子进程立即 exec,不碰锁。
- 使用
pthread_atfork注册处理函数。 - 避免在多线程程序中 fork。
27.8 为什么 exec 后信号处理会重置?
背景:exec 后原信号处理函数还在吗?
深入:
exec 替换了用户空间,原信号处理函数的代码地址在新程序中可能无效,所以内核把被捕获的信号处理方式重置为默认。被忽略的信号(SIG_IGN)保持不变,因为忽略不需要函数地址。
这也是为什么 shell 执行命令时,信号处理会恢复默认。
27.9 进程组、会话和控制终端
背景:为什么 Ctrl+C 能杀死一组进程?
深入:
- 进程组:一组进程,通常一个作业一个组。
setpgid设置。 - 会话:一个或多个进程组,通常一个登录会话。
setsid创建。 - 控制终端:会话关联的终端。前台进程组接收终端信号。
Ctrl+C 时,终端驱动向前台进程组发送 SIGINT,组内所有进程都收到。
waitpid 的 pid 为负时,可以等待整个进程组的子进程。
27.10 守护进程为什么要 fork 两次?
背景:创建守护进程为什么通常 fork 两次?
深入:
- 第一次 fork 后父进程退出,子进程被 init 收养,确保不是进程组组长。
- 然后
setsid创建新会话,子进程成为会话首进程和进程组组长。 - 第二次 fork 后父进程(第一次的子进程)退出,新子进程不再是会话首进程。这样确保守护进程永远不会获得控制终端,即使后来打开终端设备。
步骤:
pid_t pid = fork();
if (pid > 0) _exit(0); // 第一次 fork,父退出
setsid(); // 创建新会话
pid = fork();
if (pid > 0) _exit(0); // 第二次 fork,父退出
chdir("/");
umask(0);
// 重定向标准 fd 到 /dev/null27.11 waitpid 的 WNOHANG 到底怎么用?
背景:很多人知道 WNOHANG 是非阻塞,但不知道怎么用。
深入:
WNOHANG 让 waitpid 立即返回:
- 有子进程退出:返回子 PID。
- 没有子进程退出:返回 0。
- 出错:返回 -1。
典型用法是轮询:
while (1) {
int status;
pid_t pid = waitpid(-1, &status, WNOHANG);
if (pid > 0) {
// 回收一个
} else if (pid == 0) {
// 没有子进程退出,做其他事
break; // 或 sleep 后再试
} else {
if (errno == EINTR) continue;
break;
}
}注意:返回 0 不是错误,不要当错误处理。
27.12 为什么父进程先退出子进程会被 init 收养?
背景:父进程退出后,子进程的 getppid 为什么变成 1?
深入:
每个进程必须有父进程。父进程退出后,子进程成为孤儿。内核会把孤儿进程的父进程改为 init(PID 1)或 systemd,确保它们有人负责回收。
init 会定期调用 wait 回收被收养的孤儿进程,所以孤儿进程不会变成僵尸。
27.13 fork 后父子进程的 stdio 缓冲区
背景:为什么 fork 前 printf 会重复输出?
深入:
printf 不是直接写系统调用,而是先写到用户空间缓冲区。缓冲区在进程的用户空间内存中。fork 复制地址空间时,缓冲区内容也被复制。
父子进程退出时,各自刷新自己的缓冲区,导致同一内容输出两次。
如果输出到终端,通常是行缓冲,遇到 \n 就刷新,问题不明显。如果重定向到文件,是全缓冲,问题就出现了。
解决:
- fork 前
fflush(stdout)。 - 使用
write直接系统调用。 - 子进程用
_exit不刷新缓冲区。
27.14 为什么 exec 后 PID 不变但程序变了?
背景:exec 替换了程序,为什么 PID 不变?
深入:
PID 属于进程,不属于程序。exec 只是替换进程的用户空间内容,内核中的 PCB 不变,所以 PID、PPID、进程组、会话、打开文件等都保留。
这就像换了一个人的灵魂,但身份证号没变。
27.15 为什么 fork 是重量级操作?
背景:都说 fork 重,但 COW 不是很快吗?
深入:
fork 的开销包括:
- 创建 PCB。
- 创建页表。
- 复制文件描述符表。
- 复制信号处理信息。
- 复制各种内核结构。
- 设置 COW 页表项。
虽然 COW 避免了复制物理内存,但页表和 PCB 的复制仍然有开销。对于大进程,页表很大,fork 开销明显。
所以高性能服务器常用线程池或 epoll,而不是每个连接 fork 一次。
27.16 为什么说 fork 和多线程是危险的组合?
背景:多线程程序中 fork 会有什么问题?
深入:
fork 只复制调用 fork 的线程,其他线程在子进程中消失。但其他线程持有的锁、条件变量、内存状态被复制。
如果子进程尝试获取一个已被“消失线程”持有的锁,会永久死锁。
安全做法:
- fork 后子进程立即 exec。
- 使用
pthread_atfork在 fork 前后加锁解锁。 - 避免在多线程程序中 fork。
附录:速查表与复习建议
速查表
fork 返回值
| 进程 | 返回值 |
|---|---|
| 父进程 | 子进程 PID,大于 0 |
| 子进程 | 0 |
| 失败 | -1 |
进程状态
| 状态 | 含义 |
|---|---|
| R | 运行或就绪 |
| S | 可中断睡眠 |
| D | 不可中断睡眠 |
| T | 停止 |
| Z | 僵尸 |
| X | 死亡 |
wait 宏
| 宏 | 作用 |
|---|---|
WIFEXITED | 是否正常退出 |
WEXITSTATUS | 正常退出码 |
WIFSIGNALED | 是否被信号终止 |
WTERMSIG | 终止信号编号 |
WCOREDUMP | 是否 core dump |
WIFSTOPPED | 是否停止 |
WSTOPSIG | 停止信号 |
WIFCONTINUED | 是否继续 |
exec 函数命名
| 字母 | 含义 |
|---|---|
| l | list,参数逐个列出 |
| v | vector,参数数组 |
| p | PATH,自动查找 |
| e | environment,指定环境 |
常见错误码
| 错误 | 含义 |
|---|---|
ECHILD | 没有可等待子进程 |
EINTR | 被信号中断 |
EAGAIN | 资源暂时不可用 |
ENOMEM | 内存不足 |
fork 后共享与独立
| 内容 | 是否共享 |
|---|---|
| 代码段 | 共享 |
| 文件描述符表 | 复制,但指向同一打开文件描述 |
| 文件偏移 | 共享 |
| 环境变量 | 副本 |
| 工作目录 | 继承 |
| PID | 独立 |
| 虚拟地址空间 | 写时复制 |
| 栈、堆、全局变量 | 写时复制 |
| 未决信号 | 不继承 |
| 定时器 | 不继承 |
复习建议
- 第一遍看重点,建立框架。
- 第二遍看示例,自己敲一遍。
- 第三遍看注意、面试题和易错点,避开坑。
- 复习时只看重点、表格、面试题和示例,快速恢复记忆。
一句话总结
fork 创建子进程,exec 替换程序,wait/waitpid 回收子进程。
父子共享文件描述符,地址空间写时复制。
子进程必须回收,否则变僵尸。
exec 成功不返回,失败要 _exit。
SIGCHLD 要循环 waitpid,返回 0 不是错误。 