【前言】
最近复习到"进程控制"这一章,发现 fork、exit、wait 这些函数单独看都懂,一放到一起就很容易晕:为什么 fork 会有两个返回值?为什么子进程不打印 fork 之前的代码?exit 和 _exit 到底差在哪?wait 的 status 为什么不能直接当整数看?
这篇文章就把课件里的"进程创建、进程终止、进程等待"一次性讲透。建议收藏,复习/面试都能用。
一、进程创建:fork 到底干了什么?
1.1 在 Linux 中,fork 是非常重要的系统调用。它从已经存在的进程中创建一个新进程:
原进程:父进程
新进程:子进程
函数原型:

返回值:
(1)子进程中返回 0
(2)父进程中返回子进程的 PID
(3)出错返回 -1
注意:fork 之后,父子进程的二进制代码是一样的,但它们是两个独立的执行流。
内核在调用 fork 之后大致做这些事:
-
给子进程分配新的内存块和内核数据结构(PCB 等)
-
把父进程部分数据结构内容拷贝给子进程
-
把子进程添加到系统进程列表中
-
fork 返回,开始由调度器调度父子进程
💡注:fork 之前只有一个执行流;fork 之后父进程和子进程都从 fork 返回处继续往下执行。也就是说,子进程不会重新执行 fork 之前的代码。
示例代码:

运行结果:

1.2 为什么只有一行 Before,却有两行 After?
因为:
-
父进程先执行,打印 Before
-
父进程调用 fork,创建子进程
-
子进程从 fork 返回处开始执行,所以不会打印 Before
-
父子进程都继续执行后面的 printf,所以有两行 After
注意:fork 之后谁先执行,完全由调度器决定。父进程不一定先跑。
二、三个经典问题:为什么 fork 有两个返回值?
(1)问题1:为什么要给子进程返回0,父进程返回子进程 PID?
答:
-
父进程需要知道子进程的 PID,方便后续 wait、管理子进程。
-
子进程可以通过 getppid() 找到父进程,所以不需要父进程 PID。
-
返回值 0 用来明确告诉子进程:"你是子进程"。
(2)问题2:为什么一个 fork 会有两个返回值?
答:
fork 之后产生了两个执行流。父进程和子进程都从内核中的 fork 返回,所以从用户角度看,fork 函数"返回了两次":
父进程视角:返回子进程 PID
子进程视角:返回 0
(3)问题3:为什么一个 id 既等于0,又大于0?
答:
不是同一个进程里 id 既等于0又大于0,而是不同进程看到的返回值不同:
子进程看到 0
父进程看到子进程 PID
这就是 fork 最容易被绕晕的地方:一次调用,两个执行流,两个返回值。
三、写时拷贝:父子进程如何彻底分离?
fork 之后,父子进程的代码和数据通常看起来是共享的。但进程之间必须保持独立性,不能父进程改一个变量,子进程也跟着变。
3.1 Linux 使用"写时拷贝(Copy-On-Write,COW)"技术:
-
fork 之后,父子进程的页表指向同一份物理内存
-
这些内存页被标记为只读
-
只要任意一方尝试写入,就会触发缺页异常
-
操作系统再为写入方复制一份副本,修改页表指向新副本
-
没有写入的一方仍然共享原数据

修改内容之前,父进程页表和子进程页表都指向相同物理页;
修改内容之后,写入方拥有自己的副本,另一方仍然用原物理页
3.2 写时拷贝的好处:
-
延迟申请内存,提高内存利用率
-
减少 fork 时无意义的大量拷贝
-
保证进程独立性
一句话总结:fork 后"读共享,写复制"。
四、fork 的常规用法与失败原因
4.1fork 常见用法:
-
父进程复制自己,让父子进程执行不同代码段。
例如服务器:父进程等待客户端请求,子进程处理请求。
-
子进程从 fork 返回后调用 exec 函数,执行一个全新的程序。
例如 Shell 执行命令:fork 出子进程,子进程 execvp 执行 ls。
4.2 fork 调用失败常见原因:
-
系统中进程太多
-
实际用户的进程数超过限制
失败返回 -1,并设置 errno。
4.3 vfork 简单了解:
-
vfork 也是创建子进程
-
子进程先运行,父进程阻塞
-
子进程通常马上 exec
-
vfork 共享父进程地址空间,风险较高,现代代码不推荐,优先用 fork
五、进程终止:进程是怎么"死"的?
进程终止的本质:释放系统资源,释放进程申请的内核数据结构、数据和代码。
5.1 进程退出场景:
-
代码运行完毕,结果正确
-
代码运行完毕,结果不正确
-
代码异常终止
5.2 正常终止方法:
-
main 函数 return
-
调用 exit
-
调用 _exit
5.3 异常退出:
-
Ctrl + C,信号终止
-
其他信号杀死进程,如 kill -9
注意:异常终止不是靠 return 或 exit 正常退出,而是被信号终止。
六、退出码:$? 到底怎么看?
退出码(退出状态)用来告诉父进程/Shell:上一次命令是成功还是失败。
6.1 基本约定:
(1) 0:成功
(2)非0:失败
6.2 Linux Shell 常见退出码:
(1)0 命令成功执行
(2)1 通用错误代码
(3)2 命令或参数使用不当
(4)126 权限被拒绝或无法执行
(5)127 未找到命令,或 PATH 错误
(6)128+n 被信号终止,n 是信号编号
(7)130 Ctrl+C / SIGINT 终止
(8)143 SIGTERM 终止
(9)255 退出码超过 0-255 范围后重新计算
查看上一条命令退出码:

例如:

可以用 strerror 获取错误码描述,但退出码和 errno 不是一回事,注意区分。
_exit(-1) 后终端执行 $? 发现是 255。原因是 status 虽然是 int,但父进程只使用低8位。 -1 的低8位是 255。
七、_exit、exit、return 的区别
7.1 _exit 函数

status 定义进程终止状态,父进程通过 wait 获取。虽然 status 是 int,但只有低8位对父进程有意义。
_exit 是直接进入内核终止进程,不刷新用户态缓冲区。
7.2 exit 函数

exit 最后也会调用 _exit,但在调用 _exit 之前还会做:
-
执行用户通过 atexit 或 on_exit 注册的清理函数
-
关闭所有打开的流,刷新缓冲区,把缓存数据写入
例子:

运行结果:

再看:

运行结果:

因为 _exit 不刷新用户态缓冲区,hello 没输出出来。
7.3 return 退出
main 函数中 return 等价于 exit(n),因为调用 main 的运行时函数会把 main 的返回值当作 exit 的参数。
总结:
(1)_exit:直接终止,不刷缓冲区
(2)exit:先清理、刷缓冲区,再 _exit
(3)return:在 main 中等价于 exit
八、进程等待:为什么必须 wait?
子进程退出后,如果父进程不管不顾,子进程可能变成"僵尸进程"。
8.1 僵尸进程:
-
子进程已经退出
-
父进程没有回收子进程资源
-
子进程的 PCB 还保留着退出状态等信息
-
可能造成内存泄漏
而且僵尸进程已经死了,kill -9 也没用,因为不能杀死一个已经死去的进程。必须由父进程 wait/waitpid 回收。
8.2进程等待的作用:
-
回收子进程资源,避免僵尸进程
-
获取子进程退出信息:是否正常退出、退出码、被哪个信号杀死
-
父进程可以知道派给子进程的任务完成得怎么样
父进程通过 wait 或 waitpid 等待子进程。
九、wait 与 waitpid 详解
9.1 wait 方法

(1)返回值:
成功:返回被等待进程 PID
失败:返回 -1
(2)参数:
status 是输出型参数,获取子进程退出状态
不关心可以传 NULL
9.2 waitpid 方法

(1)返回值:
正常返回:收集到的子进程 PID
如果设置了 WNOHANG,且没有已退出子进程可收集,返回 0
出错返回 -1,errno 被设置
(2)参数 pid:
pid = -1:等待任意一个子进程,和 wait 等效
pid > 0:等待进程 ID 等于 pid 的子进程
(3)参数 status:
输出型参数,操作系统填充子进程退出信息
(4)参数 options:
0:阻塞等待
WNOHANG:非阻塞等待。指定子进程没结束时返回 0,不等待;正常结束返回子进程 ID
(5)常见判断宏:
WIFEXITED(status):子进程是否正常终止,正常返回真
WEXITSTATUS(status):如果 WIFEXITED 为真,提取子进程退出码
WIFSIGNALED(status):是否被信号杀死
WTERMSIG(status):如果被信号杀死,提取终止信号
代码例子:

测试结果:

十、status 不能直接当整数看:低16位位图
wait 和 waitpid 的 status 不能简单当整形看,要按位图理解。我们只研究低16位:

(1)正常终止:
bit15 ~ bit8:退出状态(退出码)
bit7:core dump 标志
bit6 ~ bit0:0
(2)被信号所杀:
bit15 ~ bit8:未用
bit7:core dump 标志
bit6 ~ bit0:终止信号
所以:
-
判断是否正常退出:
(status & 0x7F) == 0
或者用 WIFEXITED(status)
-
提取正常退出码:
(status >> 8) & 0xFF
或者用 WEXITSTATUS(status)
-
提取终止信号:
status & 0x7F
或者用 WTERMSIG(status)
十一、阻塞等待与非阻塞等待
11.1 阻塞等待:
父进程调用 wait/waitpid 后,如果子进程还没退出,父进程阻塞,直到子进程退出或出错。
阻塞等待代码示例:

运行结果:

为什么 exit(257) 最后退出码是 1?
因为退出码只取低8位,257 & 0xFF = 1。
11.2 非阻塞等待:
waitpid 第三个参数传 WNOHANG。
如果子进程没结束,立即返回 0,父进程可以继续做别的事。
父进程通常循环轮询,一边做临时任务,一边等待子进程。
非阻塞代码示例:

实验结果:

注意:
-
WNOHANG 下返回 0 表示"子进程还没退出",不是错误。
-
非阻塞等待适合父进程不想卡死、还要处理其他任务的场景。
-
轮询要避免忙等太猛,可以配合 sleep/usleep 或事件机制。
十二、总结
12.1 本篇总结:
-
fork 创建子进程,子进程从 fork 返回处开始执行。
-
子进程返回 0,父进程返回子进程 PID,失败返回 -1。
-
fork 后写时拷贝,读共享写复制。
-
进程终止有正常终止和异常终止,退出码 0 表示成功。
-
exit 会刷新缓冲区并执行清理函数,_exit 直接进内核。
-
父进程必须 wait/waitpid 回收子进程,避免僵尸进程。
-
status 是位图,正常退出看高8位,信号终止看低7位。
-
waitpid 的 WNOHANG 可以实现非阻塞等待。
12.2 易错点:
-
子进程不会执行 fork 之前的代码。
-
fork 后父子谁先执行不确定。
-
exit(257) 对父进程可见的退出码是 1。
-
status 不能直接 printf 成退出码,要用 WEXITSTATUS。
-
僵尸进程 kill -9 无效,必须父进程 wait。
-
_exit 不刷新缓冲区,printf 没换行时可能看不到输出。