文章目录
-
- 进程等待与资源回收:父进程的责任
- 一、进程终止方式回顾与深化
-
- [1.1 回顾:为什么需要进程等待](#1.1 回顾:为什么需要进程等待)
- [1.2 进程退出的三种方式](#1.2 进程退出的三种方式)
-
- [1.2.1 return退出](#1.2.1 return退出)
- [1.2.2 exit()函数](#1.2.2 exit()函数)
- [1.2.3 _exit()函数](#1.2.3 _exit()函数)
- [1.3 三种方式的关键区别:缓冲区刷新](#1.3 三种方式的关键区别:缓冲区刷新)
- [1.4 退出码的含义](#1.4 退出码的含义)
- 二、进程等待机制
-
- [2.1 进程等待的必要性](#2.1 进程等待的必要性)
- [2.2 wait函数详解](#2.2 wait函数详解)
- [2.3 waitpid函数详解](#2.3 waitpid函数详解)
- [2.4 status参数的位图解析](#2.4 status参数的位图解析)
- [2.5 阻塞等待 vs 非阻塞等待](#2.5 阻塞等待 vs 非阻塞等待)
-
- [2.5.1 阻塞等待](#2.5.1 阻塞等待)
- [2.5.2 非阻塞等待](#2.5.2 非阻塞等待)
- 三、实战案例
-
- [3.1 非阻塞等待:一边等待一边工作](#3.1 非阻塞等待:一边等待一边工作)
- [3.2 完整的进程回收示例](#3.2 完整的进程回收示例)
- 四、总结与展望
进程等待与资源回收:父进程的责任
💬 欢迎讨论 :这是Linux系统编程系列的第五篇文章。在第二篇中,我们学习了僵尸进程的产生和危害------当子进程退出而父进程不回收时,子进程就会变成僵尸。那么,父进程如何正确地回收子进程呢?这就是本篇要深入讲解的进程等待机制。如果有任何疑问,欢迎在评论区交流!
👍 点赞、收藏与分享:这篇文章包含了大量实战代码和原理分析,如果对你有帮助,请点赞、收藏并分享给更多的朋友!
🚀 承上启下:建议先阅读本系列前四篇文章,理解进程的创建、状态、调度和虚拟内存,这样学习进程等待会更轻松。
一、进程终止方式回顾与深化
在深入学习进程等待之前,我们先来系统地理解进程的退出方式。这将帮助我们更好地理解wait/waitpid获取的退出信息。
1.1 回顾:为什么需要进程等待
在第二篇文章中,我们详细学习了僵尸进程的概念。让我们快速回顾一下核心要点:
僵尸进程的产生条件:
- 子进程已经执行结束
- 父进程仍在运行
- 父进程没有调用wait()或waitpid()读取子进程的退出状态
僵尸进程的危害:
- 占用内核内存(PCB无法释放)
- 占用进程号(PID资源耗尽)
- 大量僵尸进程会导致无法创建新进程
因此,父进程必须通过进程等待来回收子进程,避免产生僵尸进程。
1.2 进程退出的三种方式
进程正常退出有三种方式,它们看起来相似,但有重要的区别。
1.2.1 return退出
这是最常见的退出方式:
cpp
int main()
{
printf("hello world\n");
return 0; // 返回退出码0
}
当main函数执行return n时,等同于调用exit(n)。这是因为调用main函数的运行时库返回值作为exit的参数。
1.2.2 exit()函数
cpp
#include <stdlib.h>
void exit(int status);
exit是C标准库函数,它在终止进程前会做一些清理工作:
- 调用用户通过atexit()或on_exit()注册的清理函数
- 刷新所有标准I/O流的缓冲区
- 关闭所有打开的 标准 I/O 流(FILE)
- 删除tmpfile()创建的临时文件
- 最后调用_exit()
1.2.3 _exit()函数
cpp
#include <unistd.h>
void _exit(int status);
_exit是系统调用,它会立即终止进程,
- _exit 不做用户态清理(不刷新 stdio 缓冲、不调用 atexit 回调等);进程结束后内核会回收资源并关闭 FD。
1.3 三种方式的关键区别:缓冲区刷新
让我们通过实验来观察三种退出方式的区别。
实验1:使用exit()
cpp
#include <stdio.h>
#include <stdlib.h>
int main()
{
printf("hello"); // 注意:没有\n
exit(0);
}
编译运行:
bash
gcc test.c -o test
./test
输出:
bash
hello
可以看到hello被正常输出了。
实验2:使用_exit()
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello"); // 注意:没有\n
_exit(0);
}
运行结果:
bash
./test
# 什么都没有输出!
为什么会这样?
这涉及到C标准库的缓冲机制:
bash
printf("hello") → 数据进入缓冲区 → 等待刷新
exit(0) → 刷新缓冲区 → 输出到终端 → 进程退出
_exit(0) → 直接退出 → 缓冲区数据丢失
缓冲区的刷新时机:
- 缓冲区满了
- 遇到换行符
\n - 程序正常结束(调用exit或return)
- 手动调用fflush()
让我们验证一下:
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello\n"); // 加上\n
_exit(0);
}
这次会输出hello,因为\n触发了缓冲区刷新。
注意:
当 stdout 连接到终端时,stdout 通常是行缓冲,\n 会触发刷新,所以能看到输出。
当 stdout 重定向到文件/管道时,stdout 往往变成全缓冲,\n 不一定立刻刷新,这时 _exit() 可能仍然导致输出丢失。
或者手动刷新:
cpp
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("hello");
fflush(stdout); // 手动刷新
_exit(0);
}
1.4 退出码的含义
无论使用哪种退出方式,都需要传递一个退出码(exit code):
cpp
exit(0); // 退出码为0
exit(1); // 退出码为1
_exit(10); // 退出码为10
return 5; // 退出码为5
- 0表示成功:程序正常执行完毕,没有错误
- 非0表示失败 :不同的非0值可以表示不同的错误类型
注意 :虽然退出码是int类型,但只有低8位有效。所以退出码的范围是0-255。
cpp
exit(256); // 实际退出码是0(256 % 256 = 0)
exit(257); // 实际退出码是1(257 % 256 = 1)
在shell中可以通过$?查看上一个程序的退出码:
bash
./test
echo $? # 查看test的退出码
常见的退出码:
- 0:成功
- 1:通用错误
- 2:误用shell命令
- 126:命令不可执行
- 127:命令未找到
- 130:通过Ctrl+C终止(信号2)
- 143:通过kill终止(信号15)
可以使用strerror()函数获取错误码的描述:
cpp
#include <stdio.h>
#include <string.h>
#include <errno.h>
int main()
{
for(int i = 0; i < 10; i++) {
printf("错误码%d: %s\n", i, strerror(i));
}
return 0;
}
输出:
bash
错误码0: Success
错误码1: Operation not permitted
错误码2: No such file or directory
错误码3: No such process
...
二、进程等待机制
理解了进程的退出方式后,我们来学习父进程如何获取子进程的退出信息。
2.1 进程等待的必要性
父进程需要进行进程等待,主要有三个原因:
1. 回收子进程资源
子进程退出后,虽然用户空间内存被释放了,但PCB(task_struct)仍然占用内核内存。父进程通过wait/waitpid来释放这部分资源。
2. 获取子进程的退出信息
父进程往往需要知道子进程的执行结果:
- 子进程是否正常退出?
- 退出码是多少?
- 如果异常退出,是被哪个信号终止的?
3. 避免僵尸进程堆积
如果父进程不回收子进程,系统中会堆积大量僵尸进程,最终导致:
- 内核内存耗尽
- PID资源耗尽
- 无法创建新进程
2.2 wait函数详解
wait是最简单的进程等待函数:
cpp
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
返回值:
- 成功:返回被回收的子进程的PID
- 失败:返回-1
参数status:
- 输出型参数,用于获取子进程的退出状态
- 如果不关心退出状态,可以传NULL
wait的行为:
- 如果所有子进程都还在运行,wait会阻塞等待
- 如果有子进程已经退出变成僵尸,wait立即返回并回收
- 如果没有子进程,wait返回-1
让我们看一个简单的例子:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0) {
perror("fork");
return 1;
}
else if(id == 0) {
// 子进程
printf("子进程[%d]开始运行\n", getpid());
sleep(3);
printf("子进程[%d]即将退出,退出码=10\n", getpid());
exit(10);
}
else {
// 父进程
printf("父进程[%d]等待子进程[%d]\n", getpid(), id);
int status = 0;
pid_t ret = wait(&status); // 阻塞等待
if(ret > 0) {
printf("等待成功!回收了进程%d\n", ret);
printf("子进程的退出码: %d\n", (status >> 8) & 0xFF);
}
}
return 0;
}
运行结果:
bash
父进程[12800]等待子进程[12801]
子进程[12801]开始运行
子进程[12801]即将退出,退出码=10
等待成功!回收了进程12801
子进程的退出码: 10
2.3 waitpid函数详解
waitpid是wait的增强版,提供了更多的控制选项:
cpp
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
参数pid:
pid > 0:等待进程ID为pid的子进程pid = -1:等待任意子进程(与wait相同)pid = 0:等待同一进程组的任意子进程pid < -1:等待进程组ID为|pid|的任意子进程
参数status:
- 与wait相同,输出型参数
- 传NULL表示不关心退出状态
参数options:
0:阻塞等待(默认行为)WNOHANG:非阻塞等待,如果没有子进程退出则立即返回0
返回值:
- 成功回收子进程:返回子进程的PID
- 使用WNOHANG且没有子进程退出:返回0
- 出错:返回-1
waitpid相比wait的优势:
- 可以等待指定的子进程
- 支持非阻塞等待
- 更灵活的控制
2.4 status参数的位图解析
status参数不是简单的整数,而是一个位图,包含了丰富的信息。我们需要理解它的结构:
bash
status (32位整数)
┌─────────┬─────────┬──────────┬──────────┐
│ 高16位 │ 退出码 │ core dump│ 信号编号 │
│ (不关心) │ 8位 │ 1位 │ 7位 │
└─────────┴─────────┴──────────┴──────────┘
15-8位 第7位 6-0位
低16位的含义:
- 0-6位:导致进程终止的信号编号(如果是信号终止)
- 第7位:core dump标志(1表示产生了core文件)
- 8-15位:进程的退出码(如果是正常退出)
不要依赖固定位布局,因为不同平台实现可能不一样,务必使用 wait.h 提供的宏解析。
Linux提供了一组宏来解析status:
1. WIFEXITED(status)
检查进程是否正常退出(调用exit/_exit/return)
cpp
if(WIFEXITED(status)) {
printf("进程正常退出\n");
}
2. WEXITSTATUS(status)
获取退出码(仅当WIFEXITED为真时有效)
cpp
if(WIFEXITED(status)) {
int code = WEXITSTATUS(status);
printf("退出码: %d\n", code);
}
3. WIFSIGNALED(status)
检查进程是否被信号终止
cpp
if(WIFSIGNALED(status)) {
printf("进程被信号终止\n");
}
4. WTERMSIG(status)
获取终止信号编号(仅当WIFSIGNALED为真时有效)
cpp
if(WIFSIGNALED(status)) {
int sig = WTERMSIG(status);
printf("终止信号: %d\n", sig);
}
让我们写一个完整的例子来演示:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0) {
perror("fork");
return 1;
}
else if(id == 0) {
// 子进程:运行20秒后退出
printf("子进程[%d]开始运行\n", getpid());
sleep(20);
exit(10);
}
else {
// 父进程:等待子进程
printf("父进程[%d]等待子进程[%d]\n", getpid(), id);
printf("你可以在20秒内kill掉子进程来观察信号终止\n");
int status = 0;
pid_t ret = waitpid(id, &status, 0);
if(ret > 0) {
if(WIFEXITED(status)) {
// 正常退出
printf("子进程正常退出,退出码: %d\n",
WEXITSTATUS(status));
}
else if(WIFSIGNALED(status)) {
// 信号终止
printf("子进程被信号%d终止\n",
WTERMSIG(status));
}
}
}
return 0;
}
测试场景1:让子进程正常退出
bash
./test
# 等待20秒
输出:
bash
父进程[12900]等待子进程[12901]
子进程[12901]开始运行
你可以在20秒内kill掉子进程来观察信号终止
子进程正常退出,退出码: 10
测试场景2:在另一个终端kill子进程
bash
# 终端1
./test
# 终端2
ps aux | grep test # 找到子进程PID
kill -9 12901 # 发送SIGKILL信号
终端1输出:
bash
父进程[12900]等待子进程[12901]
子进程[12901]开始运行
你可以在20秒内kill掉子进程来观察信号终止
子进程被信号9终止
2.5 阻塞等待 vs 非阻塞等待
wait和waitpid默认都是阻塞等待,但waitpid支持非阻塞模式。
2.5.1 阻塞等待
阻塞等待的特点:
cpp
pid_t ret = waitpid(-1, &status, 0); // options=0,阻塞模式
- 如果子进程还在运行,父进程会一直等待,无法执行其他代码
- 直到子进程退出,waitpid才返回
示例:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
pid_t id = fork();
if(id < 0) {
perror("fork");
return 1;
}
else if(id == 0) {
// 子进程:运行5秒
printf("子进程[%d]开始运行\n", getpid());
sleep(5);
printf("子进程[%d]退出\n", getpid());
exit(0);
}
else {
// 父进程:阻塞等待
printf("父进程开始等待...\n");
int status = 0;
pid_t ret = waitpid(id, &status, 0); // 阻塞在这里
printf("等待结束!回收了进程%d\n", ret);
}
return 0;
}
运行结果:
bash
父进程开始等待...
子进程[13000]开始运行
子进程[13000]退出
等待结束!回收了进程13000
可以看到,父进程在waitpid处阻塞了5秒,期间无法做任何事情。
2.5.2 非阻塞等待
非阻塞等待允许父进程在等待的同时做其他事情:
cpp
pid_t ret = waitpid(-1, &status, WNOHANG); // WNOHANG:非阻塞
- 如果子进程还在运行,waitpid立即返回0
- 如果子进程已经退出,waitpid返回子进程PID并回收
- 父进程可以在循环中检查子进程状态,同时执行其他任务
这在实际应用中非常有用。让我们来写一个更实用的例子:
三、实战案例
3.1 非阻塞等待:一边等待一边工作
假设父进程需要等待子进程完成任务,但在等待期间还有自己的工作要做。这时就需要非阻塞等待:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <vector>
#include <time.h>
typedef void (*handler_t)(); // 函数指针类型
std::vector<handler_t> tasks; // 任务队列
// 模拟父进程的任务
void task_one() {
printf(">>> 执行任务1:检查系统日志\n");
}
void task_two() {
printf(">>> 执行任务2:更新配置文件\n");
}
void task_three() {
printf(">>> 执行任务3:发送心跳包\n");
}
// 加载任务
void load_tasks() {
if(tasks.empty()) {
tasks.push_back(task_one);
tasks.push_back(task_two);
tasks.push_back(task_three);
}
}
// 执行所有任务
void do_tasks() {
load_tasks();
for(auto task : tasks) {
task();
}
}
int main()
{
pid_t id = fork();
if(id < 0) {
perror("fork");
return 1;
}
else if(id == 0) {
// 子进程:模拟长时间任务
printf("子进程[%d]开始工作...\n", getpid());
sleep(10);
printf("子进程[%d]完成工作\n", getpid());
exit(0);
}
else {
// 父进程:非阻塞等待 + 处理自己的任务
printf("父进程[%d]创建了子进程[%d]\n", getpid(), id);
printf("父进程在等待的同时会处理自己的任务\n\n");
int status = 0;
pid_t ret = 0;
// 轮询检查子进程状态
do {
ret = waitpid(id, &status, WNOHANG); // 非阻塞
if(ret == 0) {
// 子进程还在运行,父进程可以做自己的事
printf("[时间: %d秒] 子进程还在运行中...\n",
(int)time(NULL) % 100);
do_tasks(); // 执行父进程的任务
printf("\n");
sleep(2); // 每2秒检查一次
}
} while(ret == 0);
// 子进程退出了
if(ret > 0) {
printf("===================================\n");
printf("子进程已退出!\n");
if(WIFEXITED(status)) {
printf("正常退出,退出码: %d\n",
WEXITSTATUS(status));
}
}
}
return 0;
}
运行结果:
bash
父进程[13100]创建了子进程[13101]
父进程在等待的同时会处理自己的任务
子进程[13101]开始工作...
[时间: 45秒] 子进程还在运行中...
>>> 执行任务1:检查系统日志
>>> 执行任务2:更新配置文件
>>> 执行任务3:发送心跳包
[时间: 47秒] 子进程还在运行中...
>>> 执行任务1:检查系统日志
>>> 执行任务2:更新配置文件
>>> 执行任务3:发送心跳包
[时间: 49秒] 子进程还在运行中...
>>> 执行任务1:检查系统日志
>>> 执行任务2:更新配置文件
>>> 执行任务3:发送心跳包
子进程[13101]完成工作
===================================
子进程已退出!
正常退出,退出码: 0
可以看到,父进程在等待子进程的同时,每2秒就执行一次自己的任务,实现了并发处理。
3.2 完整的进程回收示例
让我们写一个综合示例,演示进程等待的各种场景:
cpp
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <string.h>
// 打印status的详细信息
void print_status(int status)
{
printf("status = %d (0x%X)\n", status, status);
printf("低7位(信号): %d\n", status & 0x7F);
printf("第7位(core): %d\n", (status >> 7) & 1);
printf("高8位(退出码): %d\n", (status >> 8) & 0xFF);
if(WIFEXITED(status)) {
printf("→ 进程正常退出,退出码=%d\n", WEXITSTATUS(status));
}
else if(WIFSIGNALED(status)) {
printf("→ 进程被信号%d终止\n", WTERMSIG(status));
}
}
int main()
{
printf("====== 测试1:正常退出 ======\n");
pid_t id1 = fork();
if(id1 == 0) {
printf("子进程1[%d]退出,退出码=5\n", getpid());
exit(5);
}
else {
int status = 0;
waitpid(id1, &status, 0);
print_status(status);
}
printf("\n====== 测试2:异常退出(除0) ======\n");
//这是演示用,除 0 属于未定义行为,不建议在工程代码中依赖它触发信号。
pid_t id2 = fork();
if(id2 == 0) {
printf("子进程2[%d]执行除0操作\n", getpid());
int a = 1 / 0; // 触发SIGFPE信号
(void)a;
}
else {
int status = 0;
waitpid(id2, &status, 0);
print_status(status);
}
printf("\n====== 测试3:使用WNOHANG ======\n");
pid_t id3 = fork();
if(id3 == 0) {
printf("子进程3[%d]将运行5秒\n", getpid());
sleep(5);
exit(0);
}
else {
int count = 0;
int status = 0;
pid_t ret;
while(1) {
ret = waitpid(id3, &status, WNOHANG);
if(ret == 0) {
printf("第%d次检查:子进程还在运行\n", ++count);
sleep(1);
}
else if(ret > 0) {
printf("第%d次检查:子进程退出了\n", ++count);
print_status(status);
break;
}
else {
perror("waitpid");
break;
}
}
}
return 0;
}
运行结果:
bash
====== 测试1:正常退出 ======
子进程1[13200]退出,退出码=5
status = 1280 (0x500)
低7位(信号): 0
第7位(core): 0
高8位(退出码): 5
→ 进程正常退出,退出码=5
====== 测试2:异常退出(除0) ======
子进程2[13201]执行除0操作
status = 8 (0x8)
低7位(信号): 8
第7位(core): 0
高8位(退出码): 0
→ 进程被信号8终止
====== 测试3:使用WNOHANG ======
子进程3[13202]将运行5秒
第1次检查:子进程还在运行
第2次检查:子进程还在运行
第3次检查:子进程还在运行
第4次检查:子进程还在运行
第5次检查:子进程还在运行
第6次检查:子进程退出了
status = 0 (0x0)
低7位(信号): 0
第7位(core): 0
高8位(退出码): 0
→ 进程正常退出,退出码=0
这个示例完整演示了:
- 正常退出的status解析
- 信号终止的status解析
- 非阻塞等待的使用方式
四、总结与展望
通过本篇文章,我们系统地学习了进程等待与资源回收的核心知识:
进程退出方式:
- 理解了return、exit()、_exit()三种退出方式
- 掌握了它们的关键区别:缓冲区刷新
- 学会了退出码的使用和含义
进程等待机制:
- 掌握了wait()和waitpid()的使用
- 理解了阻塞等待与非阻塞等待的区别
- 学会了解析status参数获取子进程退出信息
- 实现了"一边等待一边工作"的并发处理模式
核心要点回顾:
- 父进程必须回收子进程,否则产生僵尸进程
- status是位图,包含退出码和信号信息
- WNOHANG实现非阻塞等待,提高程序并发能力
- 使用宏解析status:WIFEXITED、WEXITSTATUS等
在下一篇文章中,我们将学习进程程序替换(exec函数族)。我们会理解:为什么fork出的子进程只能执行父进程的代码?如何让子进程执行一个全新的程序?以及如何结合fork+exec+wait实现一个完整的命令行解释器(mini-shell)。
💡 思考题:
- 为什么_exit()不刷新缓冲区,而exit()要刷新?这样设计的目的是什么?
- 如果父进程创建了5个子进程,如何确保所有子进程都被回收?
- 在什么场景下应该使用阻塞等待?什么场景下应该使用非阻塞等待?
- 如果子进程的退出码是256,父进程通过WEXITSTATUS获取到的值是多少?为什么?
以上就是关于进程等待与资源回收的内容,下一篇我们将揭开程序替换的神秘面纱,实现属于自己的shell!