Linux进程控制(上):fork、写时拷贝、进程终止与进程等待,一篇吃透!

【前言】

最近复习到"进程控制"这一章,发现 fork、exit、wait 这些函数单独看都懂,一放到一起就很容易晕:为什么 fork 会有两个返回值?为什么子进程不打印 fork 之前的代码?exit 和 _exit 到底差在哪?wait 的 status 为什么不能直接当整数看?

这篇文章就把课件里的"进程创建、进程终止、进程等待"一次性讲透。建议收藏,复习/面试都能用。

一、进程创建:fork 到底干了什么?

1.1 在 Linux 中,fork 是非常重要的系统调用。它从已经存在的进程中创建一个新进程:

原进程:父进程

新进程:子进程

函数原型:

返回值:

(1)子进程中返回 0

(2)父进程中返回子进程的 PID

(3)出错返回 -1

注意:fork 之后,父子进程的二进制代码是一样的,但它们是两个独立的执行流。

内核在调用 fork 之后大致做这些事:

  1. 给子进程分配新的内存块和内核数据结构(PCB 等)

  2. 把父进程部分数据结构内容拷贝给子进程

  3. 把子进程添加到系统进程列表中

  4. fork 返回,开始由调度器调度父子进程

💡注:fork 之前只有一个执行流;fork 之后父进程和子进程都从 fork 返回处继续往下执行。也就是说,子进程不会重新执行 fork 之前的代码。

示例代码:

运行结果:

1.2 为什么只有一行 Before,却有两行 After?

因为:

  1. 父进程先执行,打印 Before

  2. 父进程调用 fork,创建子进程

  3. 子进程从 fork 返回处开始执行,所以不会打印 Before

  4. 父子进程都继续执行后面的 printf,所以有两行 After

注意:fork 之后谁先执行,完全由调度器决定。父进程不一定先跑。

二、三个经典问题:为什么 fork 有两个返回值?

(1)问题1:为什么要给子进程返回0,父进程返回子进程 PID?

答:

  1. 父进程需要知道子进程的 PID,方便后续 wait、管理子进程。

  2. 子进程可以通过 getppid() 找到父进程,所以不需要父进程 PID。

  3. 返回值 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)"技术:

  1. fork 之后,父子进程的页表指向同一份物理内存

  2. 这些内存页被标记为只读

  3. 只要任意一方尝试写入,就会触发缺页异常

  4. 操作系统再为写入方复制一份副本,修改页表指向新副本

  5. 没有写入的一方仍然共享原数据

修改内容之前,父进程页表和子进程页表都指向相同物理页;

修改内容之后,写入方拥有自己的副本,另一方仍然用原物理页

3.2 写时拷贝的好处:

  1. 延迟申请内存,提高内存利用率

  2. 减少 fork 时无意义的大量拷贝

  3. 保证进程独立性

一句话总结:fork 后"读共享,写复制"。

四、fork 的常规用法与失败原因

4.1fork 常见用法:

  1. 父进程复制自己,让父子进程执行不同代码段。

    例如服务器:父进程等待客户端请求,子进程处理请求。

  2. 子进程从 fork 返回后调用 exec 函数,执行一个全新的程序。

    例如 Shell 执行命令:fork 出子进程,子进程 execvp 执行 ls。

4.2 fork 调用失败常见原因:

  1. 系统中进程太多

  2. 实际用户的进程数超过限制

失败返回 -1,并设置 errno。

4.3 vfork 简单了解:

  1. vfork 也是创建子进程

  2. 子进程先运行,父进程阻塞

  3. 子进程通常马上 exec

  4. vfork 共享父进程地址空间,风险较高,现代代码不推荐,优先用 fork

五、进程终止:进程是怎么"死"的?

进程终止的本质:释放系统资源,释放进程申请的内核数据结构、数据和代码。

5.1 进程退出场景:

  1. 代码运行完毕,结果正确

  2. 代码运行完毕,结果不正确

  3. 代码异常终止

5.2 正常终止方法:

  1. main 函数 return

  2. 调用 exit

  3. 调用 _exit

5.3 异常退出:

  1. Ctrl + C,信号终止

  2. 其他信号杀死进程,如 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 之前还会做:

  1. 执行用户通过 atexit 或 on_exit 注册的清理函数

  2. 关闭所有打开的流,刷新缓冲区,把缓存数据写入

例子:

运行结果:

再看:

运行结果:

因为 _exit 不刷新用户态缓冲区,hello 没输出出来。

7.3 return 退出

main 函数中 return 等价于 exit(n),因为调用 main 的运行时函数会把 main 的返回值当作 exit 的参数。

总结:

(1)_exit:直接终止,不刷缓冲区

(2)exit:先清理、刷缓冲区,再 _exit

(3)return:在 main 中等价于 exit

八、进程等待:为什么必须 wait?

子进程退出后,如果父进程不管不顾,子进程可能变成"僵尸进程"。

8.1 僵尸进程:

  1. 子进程已经退出

  2. 父进程没有回收子进程资源

  3. 子进程的 PCB 还保留着退出状态等信息

  4. 可能造成内存泄漏

而且僵尸进程已经死了,kill -9 也没用,因为不能杀死一个已经死去的进程。必须由父进程 wait/waitpid 回收。

8.2进程等待的作用:

  1. 回收子进程资源,避免僵尸进程

  2. 获取子进程退出信息:是否正常退出、退出码、被哪个信号杀死

  3. 父进程可以知道派给子进程的任务完成得怎么样

父进程通过 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:终止信号

所以:

  1. 判断是否正常退出:

    (status & 0x7F) == 0

    或者用 WIFEXITED(status)

  2. 提取正常退出码:

    (status >> 8) & 0xFF

    或者用 WEXITSTATUS(status)

  3. 提取终止信号:

    status & 0x7F

    或者用 WTERMSIG(status)

十一、阻塞等待与非阻塞等待

11.1 阻塞等待:

父进程调用 wait/waitpid 后,如果子进程还没退出,父进程阻塞,直到子进程退出或出错。

阻塞等待代码示例:

运行结果:

为什么 exit(257) 最后退出码是 1?

因为退出码只取低8位,257 & 0xFF = 1。

11.2 非阻塞等待:

waitpid 第三个参数传 WNOHANG。

如果子进程没结束,立即返回 0,父进程可以继续做别的事。

父进程通常循环轮询,一边做临时任务,一边等待子进程。

非阻塞代码示例:

实验结果:

注意:

  1. WNOHANG 下返回 0 表示"子进程还没退出",不是错误。

  2. 非阻塞等待适合父进程不想卡死、还要处理其他任务的场景。

  3. 轮询要避免忙等太猛,可以配合 sleep/usleep 或事件机制。

十二、总结

12.1 本篇总结:

  1. fork 创建子进程,子进程从 fork 返回处开始执行。

  2. 子进程返回 0,父进程返回子进程 PID,失败返回 -1。

  3. fork 后写时拷贝,读共享写复制。

  4. 进程终止有正常终止和异常终止,退出码 0 表示成功。

  5. exit 会刷新缓冲区并执行清理函数,_exit 直接进内核。

  6. 父进程必须 wait/waitpid 回收子进程,避免僵尸进程。

  7. status 是位图,正常退出看高8位,信号终止看低7位。

  8. waitpid 的 WNOHANG 可以实现非阻塞等待。

12.2 易错点:

  1. 子进程不会执行 fork 之前的代码。

  2. fork 后父子谁先执行不确定。

  3. exit(257) 对父进程可见的退出码是 1。

  4. status 不能直接 printf 成退出码,要用 WEXITSTATUS。

  5. 僵尸进程 kill -9 无效,必须父进程 wait。

  6. _exit 不刷新缓冲区,printf 没换行时可能看不到输出。

相关推荐
MicrosoftCloud1 小时前
操作系统 02|进程到底被谁推着走?
操作系统·线程·进程·pcb·408·进程状态·调度
小此方2 小时前
Linux网络(二十九):从内网穿透到P2P打洞:NAT转换、代理机制与局域网通信的底层全景解析
linux·网络·p2p
Brilliantwxx2 小时前
【Linux】基础IO(下):输出重定向、dup2 与 minishell 重定向实战
linux·运维·服务器·github·bash
学linux的QQ蛋2 小时前
IMX6 嵌入式多传感器系统整体架构梳理
linux·嵌入式硬件·架构
小HANN2 小时前
(架构 + InnoDB 事务 MVCC):《MySQL 底层探秘①:拆解 InnoDB,读懂事务、MVCC 与日志的底层逻辑》
linux·运维·服务器·经验分享
kyle~2 小时前
Linux --- 虚拟内存 1.fork、2.COW、3.mmap、4.page fault、5.TLB、6.页表
linux·运维·服务器
Techblog of HaoWANG2 小时前
Ubuntu 下将 Ollama 服务配置为监听 0.0.0.0:11434 完整实践
linux·数据库·ubuntu·ollama
小HANN2 小时前
Ceph分布式存储深度解析-下篇
linux·运维·服务器·经验分享
张文君3 小时前
ubuntu iscsi客户端 关闭磁盘
linux·运维·ubuntu