进程等待
1、为什么要进程等待
父进程一定要等待子进程。原因有二:
-
子进程的僵尸状态需要被回收。
- 子进程因为某种原因而退出,状态变成z,子进程本身变成僵尸进程。如果不处理僵尸进程就会引发内存泄漏问题。同时由于僵尸进程已经"死了",再怎么执行
kill -9 [pid]命令也无法杀死一个已经"死了"的进程。
- 子进程因为某种原因而退出,状态变成z,子进程本身变成僵尸进程。如果不处理僵尸进程就会引发内存泄漏问题。同时由于僵尸进程已经"死了",再怎么执行
-
子进程的退出信息需要被获取。
- 父进程需要知道子进程把父进程交付的任务完成得怎么样,所以父进程就必须知道子进程的退出信息。
2、什么是进程等待
进程等待,就是父进程通过系统调用wait waitpid,对已退出的子进程做两件事:
- 释放子进程的僵尸状态;
- 获取子进程的退出信息。
2.1、解决僵尸进程问题
我们先来认识系统调用wait。

- 参数
wstatus:wstatus是一种输出型参数。用户在wait外定义一个status然后传status的指针进wait,如果wait执行成功,就会返回子进程的退出信息。在这里,我们暂时不需要获取退出信息,可以传入NULL。 - 返回值:
- 执行成功,
wait就会返回终止子进程的pid; - 执行失败,返回-1。
- 执行成功,
我们来看一段代码,演示父进程通过wait回收僵尸子进程的全过程:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1); // 此时子进程还没有,相当于父子共有1秒
pid_t id = fork();
if (id == 0)
{
// 子进程
int cnt = 5;
while (cnt)
{
printf("我是子进程。pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
cnt--;
}
sleep(5);
printf("子进程退出。\n");
exit(1); // 子进程循环大约5秒,休眠大约5秒再退出,总共大约10秒
}
sleep(15); //
// 父进程(没有循环)休眠大约15秒
// 中间就有5秒空窗期,此时父进程休眠(s),子进程变僵尸(z)
pid_t rid = wait(NULL); // 僵尸子进程消失(被回收)
if (rid > 0)
{
printf("父进程等待成功。\n");
sleep(5);// 父进程大约5秒后也退出
}
return 0;
}
运行代码,然后同步监控进程:

这样一来,我们就完成了僵尸进程的回收。
我们也可以得出结论:
- 原则上,我们设计代码的时候,必须保证父进程是最后退出的;
- 父进程可以通
wait,完成对僵尸子进程的回收; - 如果子进程还未退出(还未变成僵尸进程,也就是z状态),而父进程已经执行到了
wait系统调用,那么父进程就会阻塞在wait处,直到子进程退出。
对于结论(3),我们可以把上面代码中标记的sleep(15)去掉,再执行代码并同步监控,发现父进程确实阻塞在wait处了。

2.2、获取子进程的退出信息
实际上,
wait:只能等待任意子进程。也就是说当有多个子进程存在时,哪个子进程先退出,wait就只会等待哪个进程。waitpid:可以等待指定的进程。
我们先来认识系统调用waitpid:

waitpid的参数列表:pid:指定子进程的pid。未来输入指定子进程的pid,父进程就可以等待指定的子进程;而输入"-1",父进程就等待任意的进程,与wait一样。wstatus:输出型参数。未来输入一个int变量(status)指针,waitpid就会提取子进程的退出信息,写入到status中。options:等待进程的方式。等待进程的方式分为默认阻塞等待(0) 、非阻塞等待(WNOHANG)。
waitpid的返回值:- 目前我们只需要知道,
waitpid在默认阻塞等待方式下,如果等待成功,就会返回被等待的子进程的pid(返回值 > 0)。
- 目前我们只需要知道,
我们就可以得出:
waitpid(-1, NULL, 0) == wait(NULL)
以后我们就可以使用waitpid等待进程,因为waitpid的选项更多。
2.2.1、status接收退出信号的原理
我们修改上面的代码,然后看看能不能通过新定义的status,拿到子进程的退出码exit(1):
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
pid_t id = fork();
if (id == 0)
{
// 子进程
int cnt = 5;
while (cnt)
{
printf("我是子进程。pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
cnt--;
}
sleep(5);
printf("子进程退出。\n");
exit(1);
}
//sleep(15); //
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
printf("父进程等待成功。status: %d\n", status); // status为多少?能不能拿到子进程的退出码1?
sleep(5);
}
return 0;
}

status结果是256?这是怎么回事?
实际上,int类型的status,其二进制表示的32位中,
- 左16位不使用;
- 对于右16位:
- 右7位:存退出信号------exit signal
- 第8位:core dump------(我们暂时留个印象)
- 右8位:存退出码------exit code

我们可以认为,此时status被当作了位图来使用。
那么我们就可以通过对status进行位运算,提取出指定子进程的退出信息:
- 退出码:
(status >> 8) & 0xFF - 退出信息:
status & 0x7F
但是,对于存储子进程退出信息的status,我们还要自己进行位操作提取退出码和退出信号?未免太麻烦了点?
Linux已经为我们准备好了三个宏,直接用就可以:
| 宏 | 含义 |
|---|---|
WIFEXITED(status) |
判断进程是否正常退出,正常为真,异常为假 |
WEXITSTATUS(status) |
若进程正常退出,提取退出码 |
WTERMSIG(status) |
若进程异常退出(因信号终止),提取退出信号 |
小贴士1
有人会讲:为什么我们不可以定义一个全局变量status(main函数外),当子进程将要结束的时候,就修改status的内容变成子进程的退出码;然后父进程就可以通过全局status拿到子进程的退出码?
答案是不行的,原因是写时拷贝。当子进程企图修改status时,触发写时拷贝,操作系统就会将"父子进程共用一个status变量"的现实,变成"父子进程各有一个物理地址不同的status变量"的现实。那么父进程就不能通过全局status拿到子进程的退出码。
我们也可以小小总结一下:
- 退出码的取值范围是0, 256。因为status只分配了8的比特位存储退出码,所以退出码就只有 28 = 256 种,即0, 256。
2.2.2、不同情况下,退出信息的获取
2.2.2.1、等待成功
正常退出(但是退出码是自定义的):
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
pid_t id = fork();
if (id == 0)
{
// 子进程
int cnt = 5;
while (cnt)
{
printf("我是子进程。pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
cnt--;
}
sleep(5);
printf("子进程退出。\n");
exit(13); // 退出码自定义为13
}
//sleep(15); //
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
printf("父进程等待成功。status: %d, exit code: %d, exit signal: %d\n", status, (status >> 8)&0xFF, status & 0x7F);
sleep(5);
}
return 0;
}

我们如何演示出异常退出?
我们可以使子进程:
处于正常死循环是,发送9号终止信号
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
pid_t id = fork();
if (id == 0)
{
// 子进程
while (1) // 死循环(然后发送9号信号)
{
printf("我是子进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
}
}
//sleep(15); //
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
printf("父进程等待成功。status: %d, exit code: %d, exit signal: %d\n", status, (status >> 8)&0xFF, status & 0x7F);
sleep(5);
}
return 0;
}

除0操作
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
pid_t id = fork();
if (id == 0)
{
// 子进程
while (1)
{
printf("我是子进程。pid: %d, ppid: %d\n", getpid(), getppid());
int i = 10 / 0; // 除0操作
sleep(1);
}
}
//sleep(15); //
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
printf("父进程等待成功。status: %d, exit code: %d, exit signal: %d\n", status, (status >> 8)&0xFF, status & 0x7F);
sleep(5);
}
return 0;
}

访问野指针
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
pid_t id = fork();
if (id == 0)
{
// 子进程
while (1)
{
printf("我是子进程。pid: %d, ppid: %d\n", getpid(), getppid());
int *p = NULL;
*p = 10; // 访问NULL
sleep(1);
}
}
//sleep(15); //
int status = 0;
pid_t rid = waitpid(id, &status, 0);
if (rid > 0)
{
printf("父进程等待成功。status: %d, exit code: %d, exit signal: %d\n", status, (status >> 8)&0xFF, status & 0x7F);
sleep(5);
}
return 0;
}

2.2.2.2、等待失败
可以对waitpid输入一个不存在的进程pid:
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
pid_t id = fork();
if (id == 0)
{
// 子进程
int cnt = 5;
while (cnt)
{
printf("我是子进程。pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
cnt--;
}
sleep(5);
printf("子进程退出。\n");
exit(13);
}
//sleep(15); //
int status = 0;
pid_t rid = waitpid(id + 1, &status, 0); // 输入一个不存在的pid
if (rid > 0)
{
printf("父进程等待成功。status: %d, exit code: %d, exit signal: %d, rid: %d\n", status, (status >> 8)&0xFF, status & 0x7F, rid);// 顺便看看waitpid的返回值
sleep(5);
}
else
{
printf("父进程等待失败。status: %d, exit code: %d, exit signal: %d, rid: %d\n", status, (status >> 8)&0xFF, status & 0x7F, rid);
sleep(5);
}
return 0;
}

当然,我们看到了此时waitpid返回值为"-1"。实际上,waitpid如果等到了子进程,会返回子进程的pid;如果等待失败,则返回"-1"。
2.2.2.3、总结
所以,父进程需要获取退出子进程的退出信息,而退出信息的构成:
退出信息 = 退出码 + 退出信号
2.2.3、获取退出信息的原理的简单表述,以及总结
1、子进程退出时,退出信息保存在PCB中

2、父进程"wait"子进程,本质上是获取已退出子进程的退出信息
父进程会遍历自己的所有孩子进程,查找有没有z状态的子进程:

如果找到了z状态的子进程,就读取其退出信息,再拷贝下来,经过一系列转化写入status中。
所以,"父进程等待子进程的大概方式"是:
- 父进程遍历检查子进程z状态,获取子进程PCB内部记录的退出信息。
3、非阻塞等待
还记得吗?waitpid的第三个参数:

系统调用waitpid的第三个参数options,决定的是进程的等待方式:
- 输入
0:默认的阻塞等待 - 输入
WNOHANG:非阻塞等待
我们再看看Linux文档关于waitpid返回值的描述:

翻译成中文,大致意思是:
- 成功的情况下,就会返回状态已经改变(成z状态)的子进程,的pid;
- 如果
options参数传入的是WNOHANG,而子进程的状态还未改变,立即返回0; - 失败的情况下,返回-1。
对于目前的解释,我们是似懂非懂的。比如"子进程的状态还未改变,立即返回0",我们是不太懂的。
options参数传入的是WNOHANG,就会启用"非阻塞调用",所以我们就必须了解非阻塞调用,以及阻塞调用。
3.1、非阻塞等待的感性认识
对于非阻塞等待,我们有一个小故事:
你是李四。你的班级里有一个叫张三的努力型学霸,张三上课的时候,笔记做得非常好。
快期末了,明天要考《C语言》,你觉得自己啥也没复习,考试可能要完蛋。你想起了张三,还有张三精美的笔记。于是你来到张三所在寝室的楼下:

你来到楼下,向张三打电话,问张三能不能带上自己的C语言笔记,然后和你一起去图书馆帮你复习。张三答应了,不过张三说再等等他,因为张三他要再看会儿书。
这时,李四你并没有一直接电话。你立马挂断了电话,过了一段时间你又给张三打起了电话。而在第一次挂断和第二次打电话的期间,李四你也没闲着,干了这几件事:
- 装作看看书;
- 玩玩手机;
- 来回走两圈看看风景。

就这样,李四你接连打了好几个电话,每次电话挂断到下一次打电话的期间,你都没闲着,干着这几件事:

直到张三看完书,带着笔记下楼和你一起去图书馆。
在这个故事中,
- 李四------父进程;
- 张三------子进程;
- 打电话------调用
waitpid - 电话里头,张三说等一下,李四不维持着通话状态,立即挂断电话------函数调用立即返回;
- 李四打一通电话,
- 有两种结果:
- 张三答应并马上下楼,与李四一起去自习室;
- 张三说再等等,李四立马挂电话;
- 而这两种结果,都有一个共同点:挂电话。所以从打电话到挂电话------一次非阻塞等待(检测 / 调用)。
- 有两种结果:
- 在一通电话与另一通电话之间的时间窗口内,李四可以做其它的事;同理,在一个非阻塞等待到另一个非阻塞等待之间的时间窗口内,父进程也可以做其它的事。
- 李四打了很多次电话------非阻塞轮询过程,即非阻塞轮询过程包含了多次非阻塞等待。
3.2、阻塞等待的感性认识
对于阻塞等待,我们可以继续刚刚的小故事:
学校《C语言》考试结束了。李四你很庆幸自己还好有张三帮忙复习,C语言考试你有十足的把握。
可是,李四你听到同学的闲聊,听到明天又有《操作系统》考试。于是你赶紧又跑到张三楼下,请求张三带上他的笔记,和你到图书馆自习:

李四你向张三打电话,张三在电话答应了你的请求,然后果不其然,张三说再等等他,他要看10min书。
但是,这一次张三你没有立即挂断电话,而是要求张三一直保持通话,直到张三真正下楼才挂断。而在此期间,你什么也没干,就是守着电话。

那么,这一整个打电话到挂电话的过程------阻塞等待。
所以,非阻塞等待与阻塞等待的一大区别是:
| 等待方式 | 区别描述 |
|---|---|
| 非阻塞等待 | 父进程等待子进程期间,可以做其他事 |
| 阻塞等待 | 父进程等待子进程期间,不能做其他事 |
3.3、非阻塞等待的代码
我们可以改造上面的代码,设计出一个进行"非阻塞等待"的过程。不过有两个细节:
- 我们需要设计父进程非阻塞等待过程中做的其他事。比如我们定义一些行为函数,然后借助函数指针:
- 定义函数指针;
- 定义函数指针数组;
- 非阻塞等待时,就可以通过访问函数指针数组里的函数指针,从而回调函数。
- 由于非阻塞等待下
waitpid认为子进程还未改变状态,就直接返回"0"结束了,所以我们就必须循环重复非阻塞等待,直到父进程等待成功。
c
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <stdlib.h>
#include <sys/wait.h>
void Printlog()
{
printf("我要打印日志。\n");
}
void SyncMySQL()
{
printf("我要访问数据库。\n");
}
void Download()
{
printf("我要下载核心数据。\n");
}
typedef void(*task_t)();
task_t task[3] = {
Printlog,
SyncMySQL,
Download
};
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
pid_t id = fork();
if (id == 0)
{
// 子进程
int cnt = 5;
while (cnt)
{
printf("我是子进程。pid: %d, ppid: %d, cnt: %d\n", getpid(), getppid(), cnt);
sleep(1);
cnt--;
}
sleep(15);
printf("子进程退出。\n");
exit(13);
}
int status = 0;
while (1)
{
pid_t rid = waitpid(id, &status, WNOHANG);
if (rid > 0)
{
printf("父进程等待成功。rid: %d\n", rid);
sleep(1);
if (WIFEXITED(status))
printf("子进程正常退出。exit_signal: %d, exit_code: %d\n", WTERMSIG(status), WEXITSTATUS(status));
else
printf("子进程异常退出。exit_signal: %d\n", WTERMSIG(status));
exit(1);
}
else if (rid == 0)
{
printf("父进程非阻塞等待中。rid: %d\n", rid);
for (int i = 0; i < 3; ++i)
task[i]();
sleep(1);
}
else
{
printf("父进程等待失败。rid: %d\n", rid);
exit(1);
}
}
sleep(1);
return 0;
}
3.4、总结
目前我们的最佳实践是,有阻塞等待就够了。
4、编写代码,以演示多进程等待
编写代码,以创建一批子进程,然后全部管理起来,再依次等待。为了更好地管理,我们可以使用C++的标准库。
cpp
#include <iostream>
#include <cstdio>
#include <vector>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int g_num = 5;
void Work()
{
for (int cnt = 0; cnt < g_num; ++cnt)
printf("我要开始工作了。cnt: %d\n", cnt);
}
int main()
{
printf("我是父进程。pid: %d, ppid: %d\n", getpid(), getppid());
sleep(1);
std::vector<pid_t> children; // 存的东西是pid,类型要匹配
for (int i = 0; i < g_num; ++i)
{
sleep(1);
pid_t id = fork();
if (id < 0)
{
perror("fork failed.\n");
exit(1);
}
else if (id == 0)
{
//children.push_back(getpid()); // 不要这样入,因为有写时拷贝,父进程的children才需要存pid
printf("我是子进程。pid: %d, ppid: %d, i: %d\n", getpid(), getppid(), i);
Work();
printf("我要退出了。pid: %d\n", getpid()); // 子进程可以做一点任务
exit(1);
}
else
{
children.push_back(id);
}
}
sleep(3);
int status = 0;
for (size_t i = 0; i < children.size(); ++i)
{
pid_t rid = waitpid(children[i], &status, 0);
if (rid > 0)
{
printf("父进程等待成功。rid: %d\n", rid);
sleep(1);
if (WIFEXITED(status))
printf("子进程正常退出。子进程的pid: %d, exit_code: %d\n", children[i], WEXITSTATUS(status));
else
printf("子进程异常退出。子进程的pid: %d, exit_signal: %d\n", children[i], WTERMSIG(status));
}
else if (rid < 0)
printf("父进程等待失败。rid: %d\n", rid);
sleep(1);
}
return 0;
}

这里有几个细节:
- 差错处理;
- 父进程
for循环创建下一个子进程之前,可以sleep停顿一下,以保证前一个子进程的工作做完; - 父进程创建完子进程时,可以再
sleep停顿一下,以保证所有子进程都完成了任务; - 其实子进程退出之前,也可以
- 用
vector记录退出子进程的pid时,必须是父进程记录,不能是子进程,因为有写时拷贝。
小贴士2
我用的vim配置好像有点奇怪(好多红叉,尽管代码是对的)。也许我该花时间了解一下vim的不同配置?但也没必要?因为已经有了AI和vs code?