Linux操作系统-父进程的等待:一个关于回收与终结的故事

🔥海棠蚀omo:个人主页

❄️个人专栏:《初识数据结构》《C++:从入门到实践》《Linux:从零基础到实践》

✨追光的人,终会光芒万丈

博主简介:

这篇我们来讲解进程等待的相关知识,经过这篇的讲解相信大家对于进程就有了更深刻的认识,废话不多说,我们往下看。

一.进程等待的必要性

这个问题也可以换成:为什么要进行进程等待?

我们之前讲过,子进程退出,父进程如果不管不顾,就可能造成" 僵尸进程 "的问题,进而造成内存泄露。

另外,进程一旦变成僵尸状态,那就是刀枪不入,"杀人不眨眼" 的kill -9也无能为力,因为谁也没有办法杀死一个已经死去的进程。

最后,父进程派给子进程的任务完成得如何,我们需要知道,如:子进程运行完成,结果还是不对,或者是否正常退出。

上面的这些问题我们都要解决,故我们需要进程等待,换句话说进程等待的意义就是:父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。

而子进程的资源就是我们之前讲过的,如:PCB,mm_struct等等,这个工作是必要 的,不然就可能会造成内存泄漏,而后面的获取子进程退出信息这个工作室可选的,我想获取子进程的退出信息我就获取,我不想知道我就不获取。

二.进程等待的方式

上面既然说了我们要做进程等待的工作,那么该如何进程等待呢?我们接着往下看:

2.1wait()函数

wait这个函数就可以用来执行进程等待,那他是怎么用呢?

用文字来说就是:

父进程调用wait表示,父进程等待任意一个子进程:

1.如果子进程没有退出,父进程wait的时候,就会阻塞等待。没错,就是进程状态中我们说的阻塞,和我们使用scanf函数时的情况是一样的,就是等。

2.如果子进程退出,父进程wait的时候,wait就会返回了,之后系统就会自动解决子进程的僵尸问题。

对于wait函数我只会给大家举个简单的例子来帮助大家更好的去理解wait函数是怎么使用的,用它来解决僵尸进程问题,因为进程等待的最佳实践并不是使用wait函数,而是下面要讲的waitpid,将到后面大家就知道为什么最佳实践是waitpid而不是wait了。

我们先看wait函数的返回值,这样后面看例子时就能更好地去理解:

这里就表明了wait函数的返回值,如果等待成功就会返回子进程的pid,反之如果等待失败,则返回-1。

那什么叫等待失败呢?什么情况下会造成等待失败呢?

答案就是如果父进程等待的子进程压根就不是自己的子进程,而是别人的子进程,那等待不就没意义了嘛,这种情况下就会造成等待失败,这种情况也不常见,这里就不过多解释了。

说了这么多,我们下来就来正式看一看父进程是如何使用wait来完成进程等待的:

这次我们先解释一下上面的代码,fork之后出现父子进程,而通过对id值的判断,使子进程就会执行if判断语句中的代码,而在子进程执行完代码后,我们要通过exit来使子进程退出,这里是关键。

只有子进程退出了,才不会执行下面父进程要执行的代码,这点相信大家都能看懂,而我让父进程在执行进程等待之前让其sleep了10秒钟,为的就是让大家看到子进程结束后而在在父进程执行进程等待之前会处于Z状态,我们能在10s之间能看到,不然子进程的Z状态只会持续一瞬间,看不到。

下面我们就来看看代码执行的结果:

结果也正如我们所料,第二张图的三种情况分别是在:子进程执行过程中,子进程执行完退出后,父进程执行完进程等待后。

第一种情况想必不用解释,而第二张图我们也确实看到了子进程退出后处于Z状态,而在最后父进程执行完进程等待后,子进程也就被回收了,只剩下了父进程,这也从侧面证明了我们通过wait函数确实完成了父进程对子进程退出后的工作。

上面我们只展示了单个子进程的情况,而我们上面也说了使用wait函数,父进程会等待任意一个子进程,那下面我们再来看看多进程的情况,看能不能完成和上面同样的操作:

和上面一样,先解释这部分代码,这里我们将执行fork函数的部分放在了一个循环中,我们通过定义一个N来表示循环次数,也就是第一个循环就会执行5次fork函数,也就是父进程会执行5次,也就会创建5个子进程。

因为我们这里使用C语言实现的,所以这里同样也是用循环来等待每一个子进程,并打印出每个子进程的pid。

而后面我们会用C++的方式来更好的管理子进程的pid,代码看起来也更美观。

代码解释完,我们下面来看看程序执行的结果是否都符合我们的需求:

从上图的运行结果我们也可以看到5个子进程在运行,说明我们确实通过循环执行fork函数来创建了5个子进程,并通过第二张图我们也看到了5个子进程退出后全部处于Z状态,并在最后父进程通过循环等待每一个子进程,将它们回收。

这里解释一下为什么上面的输出结果中父进程的printf没有出现在第一行:我们上面也说了,父进程要通过5次循环执行fork函数来创建子进程,而在循环结束之前,已经创建好的子进程已经开始执行它们的代码了,所以父进程的printf没有出现在第一行。

2.2waitpid()函数

在上面展示的图中,我们不仅能看到wait,还有waitpid,waitpid的参数也要比wait多了两个,那么多的这两个参数有什么用呢?不废话,我们直接看:

这里面详细介绍了waitpid函数的各个参数的作用以及返回值,直接看这张图我们并不能很好地理解这些参数值和含义以及怎么用的,在下面我会一一介绍。

而在介绍之前我们光看这张图就能知道waitpid相比较wait更复杂,功能更多,所以上面我说wait并不是进程等待的最佳实践,最佳实践其实是waitpid。

废话不多说,我们直接先来看waitpid的返回值:

这里我们先不看中间关于WNOHANG的内容,这个是waitpid的第三个参数,我们在后面会讲,我们只看其他的。

我们通过观察发现waitpid的返回值和wait是一样的,等待成功了就返回子进程的pid,等待失败了就返回-1。

下面我就来详细介绍waitpid的三个参数:

2.2.1int* status

这个参数在wait中同样拥有,在上面我们直接传了NULL,其实也就是传了0。

我们从上面的图中对waitpid的参数介绍中可以看到这个参数是一个输出型参数,那什么叫输出型参数呢?

与我们常见的输入型参数相反,输入型参数是将参数传给函数内部,供函数使用。而输出型参数却是将函数内部处理后的数据带出来,说人话就是我们传进去了一个值,当函数结束后,我们再次访问这个参数,就会和原来的值不同,它的值就变为了被函数处理过后的值。

并且我们观察到这个参数的类型是int*,也就是它指向了一个int类型的数据。

那这个输出型参数status带出来的是什么值呢?

这里先说结论:退出码和信号编号!!!

退出码我们之前讲过,而要理解为什么带出来的是退出码和信号编号,并且信号编号是什么?这里用一个例子来帮助大家去理解:

这里我们将子进程的退出码改为1,并使用waitpid来完成进程等待的工作。

这里我们先介绍退出码:

这里先介绍正常终止的情况,正常终止我们在进程终止的章节已经讲过,这里就不过多赘述了。

我们上面说了int* status指向了一个int类型的数据,int类型的大小是4个字节,也就是32个比特位,我们不关心前16个比特位,只看后16个比特位,而在后16个比特位中,后8位表示退出状态,也就是退出码。

也就是理论上我们如果输出status应该就是子进程的退出码1,事实真是如此吗?我们来试验一下看看:

看来答案跟我们想的并不一样,输出的退出码不是1而是256,这是怎么回事呢?

上面这张图就说明了一切,虽然后8位的第1位确实是1,但是别忘了后面还有8位呢,也就是答案应该是2的8次方,那么该如何得到退出码呢?我们接着往下看:

我们在学习C语言的时候相比都接触过位运算,我们这里将status右移8位,就可以将后8位移到前8位,这样对应退出码的1就落在了最低位,并将右移的结果和0xFF进行按位与,0xFF我们应该也都认识,就是每个比特位都是1的数,经过上面的计算我们就能得到我们想要的推出码了。

注意这里是status >> 8,而不是status >>= 8,前者并不会改变status的值,而后者却会真的改变status的值,我们要的并不是改变后的status,也不能去改变status,我们要的是status右移8为后的那个临时变量,用这个临时变量和0xFF进行按位与运算。

下面我们来看看经过上面的改变是否能得到退出码:

看来答案和我们想的一样,通过上面的改变我们确实得到了子进程的退出码1。

上面讲了正常终止的情况,下面我们来看看异常的情况:

异常的情况就是被信号杀死,从上图我们能得到三个信息:

1.位于8位的属于退出码的部分显示的是未用

2.位于第8位变成了core dump标志

3.前7位变成了终止信号

下面我来介绍这些信息:

对于第一个信息,大家会疑惑为什么退出码显示的是未用呢?

这个问题我换个问法:进程因为某种原因被信号杀死了,也就是异常终止,退出码还有意义吗?

换了个问法想必大家心里就清楚了,没有意义了,进程都被迫中止了,也就是都没执行到return或者exit来返回退出码进程就结束了。

那既然退出码都没意义了,那还用他干啥呢,所以才显示的未用,我们也不必关心后8位的数字是多少,同样没有意义。

而对于第二个信息,这个core dump这里先不做介绍,涉及到后面的知识,这里我们就把它先抛在一边。

而最后的第三个信息 终止信号,这才是我们下面要讲的重点,也就是我们上面所提到的信号编号

详细的信号知识我们并没有讲过,但是我们用过啊:

kill -9我们就见过并用过,这就是信号的一种,而上图也显示了各种不同的信号编号以及对应的名字,就比如:kill中信号编号为9的名为SIGKILL,作用就是杀掉一个进程。

并且我们也注意到这里的信号编号是从1开始的,并不是从0开始的,这是为什么呢?

我们将第二张被信号所杀的图对应到第一张正常终止就可以发现正常终止中的前7位对应的信号编号的部分为0,因为现在的进程是正常终止,并没有因为出现异常而被os发送信号,那既然没有信号,那可不就为0嘛。

而从1后面的信号都是有对应的异常原因,那我们该如何取到所谓的信号编号呢?

答案和上面我们取退出码是一样的,通过位运算来取,废话不多说,直接看结论:

因为属于信号编号的部分就是前7位,所以我们并不需要进行左移或者右移,而我们并不清楚后面9位什么情况,但不管它们是什么情况,我们都要把它们变为0,这样在计算status的值时候我们就只会计算前面7位,这样就能得到信号编号。

而0x7F这个数就是前面7位是1,后面9位都是0。

说了这么多,下面我们直接来看一个例子看是都能得到我们想要的信号编号:

这里我每个子进程的循环中加上了上面的两行代码,让x/=0,我们都知道,0是不能做除数的,所以这个程序一定为因为异常而退出,结果也正如我们所料。

我们看到最后的结果中输出的退出码位0,但我们更关心的是其后面的exit_signal,也就是信号编号,结果显示是程序退出的信号编号为8,也就是os给我们的子进程发出了编号为8的信号来使它们退出。

我们从上图中找到了编号为8的信号,这个信号什么意思呢?

这个信号全称是Floating-Point Exception ,意思就是浮点数异常,**表示进程执行了一个错误的算术运算,**这个错误也正符合我们上面出现的x/=0错误。

当然一个例子还不具有说服力,我们再来看一个:

这里我们设置了一个指针p,但是并没有对其进行初始化,也就是我们常说的野指针,而我们对这个野指针进行了解引用并赋值,这个行为很明显是错误的,而结果也如我们所料,我们的子进程因为这个错误而被异常终止了。

我们从输出结果可以看到这次的信号编号为11,我们来看看这个信号又是什么:

SIGSEGV 的全称是 Segmentation ViolationSegmentation Fault ,意思就是段错误表示进程试图访问一段未被分配给它、或者它没有权限访问的内存地址。

这个信号也正好对应我们上面的错误,我们对野指针进行解引用赋值,那可不就是试图访问一段未被分配给它的内存地址嘛。

这样的例子还有很多,信号编号也还有其他的,这里就不一一演示了,感兴趣的可以去查一下对应编号代表什么含义,并自己试着写个例子。

讲完了上面的内容,大家想必现在就大概清楚为什么最开始介绍status的值是多少,我说的是退出码和信号编号了。

和上一篇进程终止的内容结合起来,进程退出的情况严格来说有两种:正常终止异常终止 ,而我们将status的后16位也分为了两部分,分别代表退出码信号编号,对应的就是上面的两种情况。

而我们也说了正常终止也分为:结果正确和结果不正确,所以我们才用后8位来表示退出码,而退出码不正说明了进程执行的结果正确还是不正确吗?

而异常终止就没什么可说了,我们需要知道是进程是因为什么信号而被终止的即可。

结合上面两种情况,退出码为0,信号编号为0,就表示进程正常终止且结果正确;退出码>0,信号编号为0,就表示进程正常终止;退出码为0,信号编号>0,就表示进程异常终止

我们用上面一个数status就表示出了子进程退出的相关信息:子进程如果执行完,父进程想知道子进程的结果正确不正确,看退出码即可;如果进程出现异常,父进程只需要看信号编号就能知道子进程异常的原因

我们如果去看linux的源码,就能在task_struct也就是PCB中找到这两个数字,名字就和我们上面起的是一样的:exit_codeexit_signal

没错,当子进程退出后,这两个数就会被写入子进程的PCB中,而父进程通过进程等待除了要解决僵尸问题,要提取的信息就是这两个数字啊,通过这两个数字父进程就能知道子进程的情况。

而在上面我们看到了这个:

那么这两个是什么东西呢?

答案是宏,这两个宏的出现就是为了便利我们去判断waitpid的返回值。

WIFEXITED(status)我们看它的作用说是若为正常终止子进程返回的状态,则为真,换句话说也就是是判断信号编号是否为0,是0就是真,反之则为假。

而WEXITSTATUS(status)就是配合着WIFEXITED(status)来使用的,当WIFEXITED(status)的返回为真时,我们需要判断子进程的结果正确不正确,所以就要看子进程的退出码,WEXITSTATUS(status)就能提取子进程的退出码。

而子进程异常的情况我们就不需要判断了,出异常了子进程就会终止了。下面我们通过代码来看一看这两个宏的简单运用:

从上图的输出结果我们就可以看到子进程正常结束后,就会进入到下面的if判断语句中,并且输出了子进程的退出码。

2.2.2 pid_t pid

从上面的内容我们知道了这个参数可以传-1,也可以传对应子进程的pid,传-1的效果就和wait函数是一样的,父进程会等待任意一个子进程。而传对应子进程的pid,父进程就会等待相应的子进程。

我们上面的代码都是用C语言实现的,而C语言不太好管理各个子进程的pid,所以下面的例子我用C++来实现:

这里我们利用C++中的vector来统一管理子进程的pid,父进程每执行一次fork函数,就会生成一个子进程,而我们就可以通过vector中的push_back来讲每个子进程的pid给入进去。

而有了每个子进程的pid,我们只需要利用范围for来遍历vector,拿到每个子进程的pid将其传给waitpid的第一个参数pid_t pid,就可以实现让父进程按照pid来等待每一个子进程。

2.2.3 int options

到这里我们就讲到了waitpid的最后一个参数int options,这个参数从上面我们知道如果这个参数是0,叫做阻塞等待,而参数如果是WNOHANG,后面跟了一句话,简而言之就是非阻塞等待。

那这两个分别是什么意思呢?什么叫做阻塞等待,什么又叫作非阻塞等待呢?下面我讲个故事大家就能理解了:

明天该考数据结构了,张三还没开始复习,但是他知道李四这学期学习很努力,于是他就联系李四说:" 李四啊,你能不能辅导我一下明天的数据结构?我请你吃饭 ",李四一听张三要请自己吃饭,于是就爽快的答应了。

而在李四同意后,张三就马不停蹄地跑到了李四的宿舍楼下面,但是李四所在的楼层太高了,张三不想爬楼梯,就不准备上去了,于是他就打电话问李四:李四,我到你宿舍楼下面了吗,你什么时候下来啊?

李四说:" 马上马上,等我10分钟 "。于是张三在等了2分钟之后,又给李四打了个电话:" 李四,下来没?"。

李四说:" 在穿鞋了,马上马上 "。张三又等了2分钟,又给李四打了个电话:" 李四,到哪儿了? "。

李四说:" 到四楼了,快下来了 "。张三又等了2分钟,再次给李四打了个电话:" 李四,到那儿了? "。

李四说:" 到二楼了,快到了 "。......

就这样,张三又连着给李四打了几个电话,终于李四下来了,他俩一块去图书馆复习去了。

而上面的过程我们用图来演示:

又过了几天,该要考计算机网络了,张三依旧没有复习,于是他又来找李四了,说:" 李四,能不能再帮我复习复习啊,请你吃饭 "。李四想着上次张三确实请他吃了一顿大餐,于是又爽快的答应了。

在李四同意后,张三再次来到了李四的宿舍楼下面,同样没有上楼,张三给李四打了个电话,说:" 李四,你什么时候下来啊 ",李四说:" 等我20分钟,我把这点看完 "。但是这次张三并没有选择将电话挂断,而是说:" 那行,电话不用挂,我就等着你下来 "。

于是张三等了20分钟,李四下来了,他们两个有一块去图书馆复习去了。

我们同样用图来表示:

听完了上面的故事,大家对于阻塞等待和非阻塞等待是不是有了一点理解,下面我就针对这两张图来说明。

我们先说第二张图,这张图其实代表的就是阻塞等待,父进程会向操作系统询问相应的子进程结束了没有,如果没有,那父进程就会一直等,等子进程结束了,父进程才会执行进程等待。就和上面的故事一样,李四还要20分钟才下来,张三在这期间没有挂断电话,就一直等着,等李四下来了,他们才一起去图书馆。

而第一张图表示的就是非阻塞等待 ,父进程同样向操作系统询问相应的子进程结束了没有,如果没有,那么当父进程不会因为条件没有就绪而阻塞,而是立即返回,然后过段时间重新询问操作系统子进程结束了没有,如果还没有,依旧立即返回,直到子进程结束了,父进程才执行进程等待。对应上面的例子就是张三多次打电话询问李四下来了没有,直到李四下来了,他们才一起去图书馆。

一次询问,没有就立即返回叫做非阻塞等待,而上面多次的进行非阻塞等待的这种方式叫做:非阻塞轮询方案!!

相应的,因为有了非阻塞等待的缘故,相应的返回值也发生了变化:

我们之前并没有介绍中间关于WNOHANG的部分,现在就来说明一下,我们来思考一个问题:非阻塞等待,如果询问操作系统后的子进程并没有结束而立即返回,那这种方式是属于等待成功呢还是属于等待失败呢?

答案都不属于,所以针对这种情况,如果你将第三个参数int options设置为WNOHANG,那么返回值就变为三个了:

子进程退出 && waitpid成功 -> 大于0

子进程没有退出 && waitpid成功 -> 等于0

waitpid失败(等待失败) -> 小于0

说了这么多,下面举一个非阻塞等待的例子我们来看看:

因为非阻塞轮询方案我们也不知道具体要循环几次,所以这里就直接用一个死循环来实现,从上面输出的结果我们也可以看到,在子进程退出之前,waitpid的返回值都为0,进而一直在执行相应的输出语句。

上面我们也见了见非阻塞轮询方案的简单运用,下面我们我们来思考一个问题:为什么要设计非阻塞等待,或者说为什么要设计非阻塞轮询方案呢?

这个问题的答案我们要从上面例子中来得出,这里在问大家一个问题:子进程没有退出之前,父进程在干什么?

看了上面的例子我们都知道,父进程在执行自己的代码,也就是反复地执行printf函数,这点我们都看到了,那么换句话说是不是就是在子进程执行期间,父进程也在干自己的事

得出这个结论我们离答案也就不远了,再总结一下:通过非阻塞等待,不会再卡住父进程,父进程就可以在等待的事件间隙自己就可以做其他的事情了。这就是上面问题的答案

这就和上面我们举的张三和李四的例子是一个道理,张三虽然是2分钟给李四打一个电话,而在这2分钟之间张三会干什么呢?

可能会拿出手机刷抖音,也可能会拿出课本看着等等,也就是张三会去做自己的事情。反之如果是阻塞等待,那么张三就需要拿着电话一直等着李四。

从这里我们也可以得出一个结论:非阻塞等待可以在一定程度上提高效率,让父进程做更多的事情

这里我们只是简单的让父进程去执行printf函数,当然还可以有更复杂的操作,比如:让父进程将某些函数都执行一遍,而这些函数又会去完成别的工作等等,这就跟我们讲进程的调度队列中提到的并发概念是类似的。

经过上面的介绍我们已经知道了waitpid中三个参数的含义和作用,以上就是父进程的等待:一个关于回收与终结的故事的全部内容。

相关推荐
橘子编程13 小时前
仓颉语言变量与表达式解析
java·linux·服务器·开发语言·数据库·python·mysql
虚神界熊孩儿13 小时前
linux下创建用户和用户组
linux·运维·服务器
hhwyqwqhhwy14 小时前
linux 驱动 rtc
linux·运维·实时音视频
python百炼成钢14 小时前
53.Linux regmap驱动框架
linux·运维·服务器·驱动开发
python百炼成钢14 小时前
54.Linux IIO驱动框架
linux·运维·服务器·驱动开发
纷飞梦雪14 小时前
ubuntu22开启root
linux·运维·ubuntu
Konwledging14 小时前
linux debug工具集合
linux
星哥说事14 小时前
恶意团伙利用 PHP-FPM 未授权访问漏洞发起大规模攻击
linux·服务器
Evan芙14 小时前
shell编程求10个随机数的最大值与最小值
java·linux·前端·javascript·网络
再睡一夏就好14 小时前
进程调度毫秒之争:详解Linux O(1)调度与进程切换
linux·运维·服务器·c++·算法·哈希算法