失眠网,内容丰富有趣,生活中的好帮手!
失眠网 > 【Linux操作系统】进程的控制

【Linux操作系统】进程的控制

时间:2021-03-30 00:34:20

相关推荐

【Linux操作系统】进程的控制

文章目录

一、✔进程创建1.再谈fork函数2.写时拷贝二、✔进程终止1.进程终止的场景查看进程退出码所代表的含义2.进程终止的常见方法3.进程终止,内核做了什么工作三、✔进程等待1.进程等待的必要性2.进程等待的方法(1)wait方法(2)waitpid方法①status②options四、✔进程程序替换1.什么是进程程序替换2.怎么进行进程程序替换(1) execl(2) execv(3) execlp(4) execvp(5) execle(6) 剩余的函数

一、✔进程创建

1.再谈fork函数

fork是Linux中非常重要的函数,它能够从已经存在的进程中创建一个新的进程,新创建的进程叫做子进程,原有的进程叫做父进程。

头文件:<unistd.h>

返回值:子进程返回0,父进程返回子进程的pid,当子进程创建失败时返回-1

fork的具体介绍在上一篇博客里写了,可以点击链接查看

当我们的进程调用fork函数以后,内核会为我们做这几件事:

分配新的内存块和内核数据结构给子进程(task_struct、mm_struct、页表)将父进程部分数据结构的内容拷贝至子进程添加子进程到系统进程列表当中fork准备返回时,开始调度器调度

所以,fork函数之前,父进程是独立执行的,fork函数之后,父进程和子进程两个执行流分别执行。

注意:fork之后,谁先执行完全由调度器决定

我们可以写一个简单的代码来简单地验证一下fork函数前后的变化:

我们运行程序查看结果:

我们可以看到在fork函数之前,只有父进程一个进程在执行;在fork函数之后,父进程和子进程分别执行。

fork函数创建子进程以后,子进程将共享父进程的代码,这里的共享父进程的代码虽然看起来像是子进程只共享父进程在fork函数之后的所有代码,但实际上子进程是共享父进程的所有代码。只不过子进程只能从fork函数之后开始执行。

为什么刚创建出的子进程就能够知道父进程的代码执行到哪里了呢?

答:原因是在CPU中有一种寄存器叫作eip(程序计数器),也有的地方叫作PC指针,这个寄存器能够保存进程当前正在执行指令的下一条指令。而当父进程创建出子进程以后,父进程的eip程序计数器会被拷贝给子进程,子进程便知道父进程接下来要执行的指令是什么了。

2.写时拷贝

在fork函数成功创建子进程后,通常情况下父子进程代码是共享的,如果父子进程都不写入或者修改数据的情况下,数据也是共享的。当任意一方试图写入或修改数据时,操作系统便以写时拷贝的方式拷贝一份副本。(具体见下图)

为什么要有写时拷贝呢?难道不可以在创建子进程的时候就直接拷贝父进程的数据嘛?

答:在创建子进程的时候就把父子进程分开,这个方法是可以实现的。但是我们之所以不选择这个方法,是因为该方法并不是最优的。为什么要有写时拷贝,我们先来看看其他方案的缺点:

首先,如果在创建子进程的时候就将父子进程的数据分离开,父进程的数据子进程不一定会全部使用,即便全部使用了,也不一定全部写入,所以就会有浪费空间的可能性。除此之外,最理想的方案是只将会被父子修改的数据进行分离拷贝,不需要修改的数据父子进程共享即可。但这种方案从技术角度实现复杂。

所以最终采用写时拷贝!也就是只有真正需要修改的时候才拷贝,这就是延迟拷贝策略。

二、✔进程终止

1.进程终止的场景

关于进程的终止,我们必须要有正确的认识,首先我们要回答下面的几个问题:在我们写代码的时候,main函数都会有一个返回值,我们一般返回值写的是0,那么这个返回值是给谁返回的?这个返回值为什么是0?可以是其他值嘛?

要回答上面的问题,我们首先来认识一下进程退出的场景:

代码运行完毕,结果正确代码运行完毕,结果不正确代码异常终止

因此,我们每一个程序的main函数会有返回值,是因为在进程代码跑完的时候,父进程需要这个返回值来判断进程的运行结果,如果返回值是0,则表示代码跑完结果正确,非零则表示失败了。

这个返回值就是“进程退出码”!进程退出码表征了进程退出的信息。

我们可以来总结一下上面问题的答案:

main函数的返回值叫做进程退出码,表征进程退出的信息这个返回值准确来说是给父进程返回的,父进程需要获取子进程的退出信息返回值是0代表进程代码运行完毕且结果正确返回值也可以是其他值,其他的非零返回值代表的是各种不同的错误信息(即代码跑完结果不正确)

我们可以在Linux下简单地验证一下进程退出码:

我们什么代码都不写,直接一运行起来就返回,返回值设置为特殊一点的1124,运行程序查看效果。

我们运行程序以后进程一下子跑完就退出了,由于当前进程的父进程是bash,所以我们可以用echo指令查看bash内部的函数,输入指令:

echo $?

这条指令可以查看:在bash中,最近一次执行完毕时,对应进程的退出码

所以我们发现第一次查询的退出码,也就是我们自己写程序的退出码是123,第二次查询到的是0,原因是第二次查询到的是echo这一条指令的退出码(指令也是进程)。

查看进程退出码所代表的含义

我们可以查看一下进程退出码各自所代表的含义:

在C语言中有一个函数叫strerror,可以打印进程退出码的退出信息。我们通过man手册先来查看一下这个函数。

我们利用strerror函数循环打印0到100的所有退出码各自对应的退出信息:

程序运行以后,0到100的进程退出码各自对应的退出信息就打印出来了:

2.进程终止的常见方法

在main函数中return,就代表进程结束退出了(必须是main函数return才代表进程退出,其他函数return代表函数调用结束)在自己的代码中,任意位置调用exit函数

我们用man手册查看一下exit函数:

exit函数的status参数就是进程退出码

和exit函数具有非常相似功能的函数是_exit函数,_exit是一个系统调用函数,二者都能让进程退出,但有小小的差别:

exit函数终止进程,顺便会刷新缓冲区。_exit函数直接终止进程,不会有任何其它的刷新操作。

3.进程终止,内核做了什么工作

我们的进程=内核结构+进程代码和数据,当一个进程不再运行了,首先要做的是进入Z状态,也就是僵尸状态,父进程此时会去等待该进程,回收该进程的退出信息。然后将进程设置为X状态,此时才叫真正的进程退出。退出的时候会释放进程的内核结构,以及进程加载到内存中的代码和数据,这就是进程终止时内核所做的工作。

其实在Linux系统中,进程终止时操作系统可能只会释放该进程的代码和数据,并不会释放该进程的内核数据结构。操作系统会维护一个叫内核的数据结构缓冲池,将这些废弃的进程内核结构放到里面,当下次有新的进程需要开辟空间的时候,直接从缓冲池中分派出去,这就叫做slab分派器,这种做法一般取决于操作系统的内存管理,可以在高频开辟空间时提高效率。

三、✔进程等待

1.进程等待的必要性

当子进程退出的时候,如果父进程不对子进程做任何处理,就可能导致子进程进入僵尸状态,从而变成僵尸进程,会造成内存泄漏。进程一旦进入僵尸状态,我们没有办法杀死这个进程,因为谁也没有办法杀死一个已经死去的进程。父进程需要知道子进程的任务完成得如何,子进程运行是否完成,结果是否正确。

因此,父进程通过进程等待的方式,回收子进程资源,获取子进程的退出信息。

2.进程等待的方法

(1)wait方法

wait是一个Linux系统调用接口,我们先用man手册查看一下wait函数:

头文件:<sys/types.h>、<sys/wait.h>返回值:成功则返回被等待进程的pid,失败则返回-1参数:是一个输出型参数,用来获取子进程的退出状态,如果不关心子进程的退出状态可以设置为NULL

下面我们用代码来尝试着使用以下wait函数:

我们首先用fork创建一个子进程,然后让子进程打印五次以后终止,父进程休眠上20s以后开始等待子进程。

我们运行程序查看效果,我们可以在命令行上写一个循环监控进程信息的脚本,每隔一秒监控一次进程信息,输入指令:

while : ; do ps ajx | head -1 && ps ajx | grep process | grep -v grep | grep -v process.c; echo "---------------------------------------------------------------"; sleep 1 ; done

一开始子进程在正常运行着,输出打印五次内容以后准备终止进程。

子进程被终止以后,由于父进程仍然处于S状态,还没有等待子进程,所以子进程此时处于Z状态,即成为了僵尸进程,正在等待被父进程回收。

父进程sleep了20s以后苏醒过来了,随后执行了wait函数,等待子进程,对子进程进行回收,回收以后子进程退出了,父进程执行完毕也退出了。

(2)waitpid方法

waitpid也是一个系统调用接口,但waitpid能把wait完全包含,wait是可以等待任意进程,而waitpid则是等待指定的进程,我们用man手册查看一下waitpid函数:

头文件:<sys/types.h>、<sys/wait.h>返回值:如果返回值大于零,那么返回的就是被等待进程的pid;如果返回值小于零,表示等待失败参数:

(1)pid:如果传入的参数大于零,则代表要等待进程的pid;如果传入的参数为-1,则代表等待任意进程

(2)*status:这是一个输出型参数,通过调用该函数,从函数内部拿出进程的退出信息

(3)options:表示进程等待的类型,如果传入的是0,则代表阻塞等待;如果传入的是WNOHANG,则代表非阻塞等待

①status

我们的进程退出信息其实是包含了进程退出状态、进程退出码、进程退出信号等内容的,这些内容都叫作进程退出信息,也就是都存放在status这个输出型参数当中,下面我们来认识一下这个输出型参数的构成:

实际上,虽然status是一个整数,但它并不是整体当作一个整数使用的,而是被当作一个位图结构使用的。关于status这个整数,我们只需要关心该整数的低16个比特位,这低16个比特位是会被分为下图的三个部分:

其中我们能够通过次低八位来得到子进程的退出码,最低七位来得到异常信息,我们赶紧来写代码验证一下:

我们让子进程正常运行打印,打印完以后退出,进程退出码设置为10,再让父进程去调用waitpid函数等待子进程,用status这个输出型参数通过位运算来获取进程退出码。

这里可以解释一下位运算是怎么算的:因为status的次低八位代表的是进程退出码,我们想要拿到这个进程退出码,首先要将status的值右移8位,这样原本的次低八位就变成了最低八位,再按位与上0xFF,将剩下的位全部设置为0,这样就拿到了进程退出码了

我们运行程序看一下结果:我们发现通过上面的方法我们确实拿到了进程退出码。

我们验证了次低八位的进程退出码,再来看看进程最低七位的异常字段。如果进程是异常退出的,其退出的原因是该进程收到了特定的信号。我们再写个代码来查看一下进程退出信号:

我们这次将子进程设置为死循环,不会再退出,而父进程则一直在阻塞等待子进程。

程序运行起来以后我们可以看到子进程一直在刷屏打印,父进程一直在等待子进程,子进程如果不退出父进程就会一直等待下去。

我们复制另一个命令行窗口,之前我们使用过指令kill -9 pid来杀死过进程,其实这就是进程的退出信号,我们可以在Linux下查看一下进程的所有退出信号,输入指令:

kill -l

这就是进程退出信号以及每一个退出信号所代表的含义。

下面我们可以用指令kill -9 pid来终止子进程,看一下父进程是否能获取子进程的退出信号。

我们看到子进程被终止以后,父进程的确获取到了子进程的退出信号。

当进程收到退出信号时,就代表该进程已经异常退出了。

我们来总结一下:目前父进程已经可以通过waitpid函数拿到子进程的进程退出码和进程退出信号了,如果子进程异常退出,那么父进程就受到进程退出信号;如果子进程正常退出,那么父进程就通过进程退出码判断子进程是代码跑完结果正确还是代码跑完结果不正确。

那么父进程是先看子进程的进程退出码还是先看进程退出信号呢?

答案是先看进程退出信号,因为父进程首先要确定子进程是不是正常跑完的,如果出现异常了,那么也没必要看进程退出码了。

上面我们说到了status是用位图结构来表示不同的进程退出信息的,我们演示的时候也采用了位运算操作来提取相应的进程退出信息,其实Linux为我们提供了宏可以自动提取进程的退出信息:

WIFEXITED(status):用于查看进程是否异常退出,如果进程正常退出则返回真,异常退出则返回假。WEXITSTATUS(status):用于查看进程退出码,如果进程正常退出(即WIFEXITED(status)为真),则提取进程的进程退出码。1

②options

首先我们要介绍一下阻塞等待和非阻塞等待:

阻塞等待:父进程在等待子进程的时候,如果子进程此时并没有退出,还在执行着任务,那么父进程只能够等待子进程退出,父进程的状态会由R状态变为S状态,也就是阻塞态,一直等待着子进程退出然后回收子进程。这就是阻塞等待。非阻塞等待:父进程在等待子进程的时候,如果子进程此时并没有退出,还在执行着任务,父进程在waitpid函数内部不会阻塞式的等待子进程,而是在得知子进程暂时还没有退出的时候直接返回,从而继续执行其它任务,等到子进程退出的时候再去等待回收子进程。这种等待方案也叫作轮询等待方案。

options的参数设置:

0:设置为0时表示阻塞等待WNOHANG:表示非阻塞等待

下面我们写代码来分别感受一下阻塞等待和非阻塞等待

首先是阻塞等待:

我们让子进程休眠20s,父进程等待子进程20s,这个过程是阻塞等待,等子进程被唤醒以后退出,父进程等待子进程成功,回收子进程。

我们运行程序看看结果:

子进程在休眠20s的时候,父进程处于阻塞状态等待父进程,等到子进程再次被唤醒的时候,父进程等待成功回收子进程。

再来看看非阻塞等待:

这里需要补充的是,如果进程等待设置的是非阻塞等待,它的返回值有以下三种情况:

等待成功且子进程退出了,返回的是被等待进程的pid等待成功但子进程并没有退出,返回的值是0等待失败了,返回的值是-1

我们写代码来感受一下非阻塞等待:

首先子进程依旧是休眠上20s,被唤醒之后直接退出,父进程等待子进程,只不过这次是非阻塞等待,我们加上条件判断,如果等待成功且子进程退出了,就正常打印子进程的退出信息;如果等待成功但子进程没有退出,就让父进程继续执行其它任务;如果等待失败,我们暂时不做处理。

运行程序我们看一下结果:

当子进程还没有退出的时候,父进程等待成功了但子进程没有退出,所以父进程先做其他事情,再去查看子进程是否退出,直到子进程退出了,父进程就回收子进程了。

四、✔进程程序替换

1.什么是进程程序替换

当我们用fork()创建子进程之后,子进程会和父进程共享代码和数据(数据不发生修改的情况下),那如果我们需要创建子进程用来运行其他的程序呢?这该怎么实现呢?

这就要用到我们所说的进程程序替换。如下图所示,当我们想让子进程创建出来去运行其它程序的时候,同样也是先将要运行的程序从磁盘加载到内存,然后让子进程重新建立页表映射关系(准确地说应该是谁执行程序替换,就让谁重新建立页表映射关系,这里以子进程为例子),这样就能够让我们的父进程和子进程彻底分离,并且让子进程去运行一个全新的程序。

2.怎么进行进程程序替换

我们如果想要执行一个全新的程序,我们需要怎么做?

首先必须是找到程序所在的位置,也就是知道程序在哪里然后必须要知道程序是怎么执行的

围绕着上面两点,我们有以下几种函数,可以让我们完成进程程序替换:

下面我们分别介绍一下这几种函数(这几种函数所实现的功能是一样的,只在函数的使用上有区别):

(1) execl

const char *path:这个参数是用来接收程序所在地址的const char *arg:这是一个可变参数,用来接收程序运行方法的,我们在命令行怎么运行程序(比如 ls -l -a),这个参数就怎么填,最后必须是NULL,表示该参数传递完毕(“ls”,“-l”,“-a”,“NULL”)

下面我们写个代码来演示一下这个函数的使用方法:

我们用一个简单的程序,在打印出第一句话以后,就替换成Linux下的ls指令。

我们运行程序看一看效果:

确实完成了程序替换,ls指令被执行了。

但很快我们就发现了一个问题:为什么ls指令执行完以后,另一条打印的代码(上图中的第8行代码)没有被执行呢?

其实一旦程序替换成功了,就会将当前进程的代码和数据全部替换了,替换函数后面的printf也是代码,当然也被替换了,也就是说程序成功替换以后,进程就不会执行原来的代码了,而是去执行我们替换的代码。

那么进程程序替换函数的返回值还有意义嘛?

当然是有意义的,上述说的是程序替换成功的情况,如果程序替换成功了,就直接去运行新的程序新的代码了,也就不会有返回值了;如果程序替换失败了,则会继续向后执行,这时候就会有返回值返回了。

因此,程序替换函数(exec系列函数)不需要判断返回值!

(2) execv

const char *path:这个参数是用来接收程序所在地址的char *const argv[]:这个参数是一个指针数组,也是用来接收程序运行方法的,只不过是将指令以数组的形式传递进去

下面我们写个代码来演示一下这个函数的使用方法:

我们先用printf打印出一句话,然后将我们的运行方法保存在_argv指针数组中,再调用execv函数,替换成Linux下的ls指令。

我们运行程序看一看效果:

同样完成了程序替换,执行了ls指令。

这里我们可以稍微总结一下,execl函数和execv函数都是属于exec系列的函数,他们可以区分成:

execl的尾缀是字母 “l” ,代表的是 “list” 列表的意思,所以execl函数的传参方法是按列表的形式传递的。execv的尾缀是字母 “v”,代表的是 “vector” 数组的意思,所以execv函数的传参方法是按数组的形式传递的。

(3) execlp

const char *file:这个参数是用来接收环境变量的(前两个函数是通过路径找到程序,这个函数则是通过环境变量找到程序)const char *arg:这是一个可变参数,用来接收程序运行方法的

下面我们再写个代码演示一下这个函数的使用方法:

这里execlp的第一个“ls”是环境变量,第二个“ls”是程序运行的方法。

我们运行一下程序看看效果:

同样完成了程序替换,执行了ls指令。

(4) execvp

这个函数就不需要再演示了,它和上面的函数类似,只不过是将命令行参数字符串放入数组中传递。这个函数也是传入环境变量来找到程序。

(5) execle

char *const envp[]:这个参数也是用来传递环境变量的,但是第一个参数 “path” 不同的是,这个参数是手动地将环境变量传递给被调用的程序。

这个需要我们举一个例子来看一看才能够说的清楚:

我们新创建一个CPP文件,让我们的程序去调用CPP程序,在CPP文件里只打印环境变量的值

我们再回到我们原来的程序,现在我们不再调用系统的指令了,而是调用我们自己写的新程序:

这里再补充个小知识,如果我们想一次性编译两个或两个以上的文件,我们可以在makefile里加一个伪执行文件all,将所有文件包含进去

下面我们运行一下程序看看结果:

环境变量确实被我们打印出来了

这里被打印出来是非常理所应当的,因为cpp文件所在的系统里确实存在这么一个环境变量,那如果我们想要在cpp文件中打印一个系统不存在的环境变量呢?

这时候我们就要用到execle函数将环境变量传递到被调用的程序中了,下面我们再用代码来演示一下:

我们自己定义一个环境变量MYPATH,这个环境变量是系统里没有的,如果我们不传入进去,cpp文件里肯定是打印不出来的,现在我们通过execle函数将这个环境变量传递进去。

我们运行程序看一看效果:

可以看到,我们自定义的环境变量被打印出来了。

execle传递环境变量是覆盖式的而不是增加式的,意思就是说传递进去的环境变量,会覆盖原来的所有环境变量

(6) 剩余的函数

有了上面五个函数的演示,这两个函数也就非常简单了,这里就不做演示了。

最后,针对上述七种程序替换函数做一个小总结,其实它们是有以下的规律的:

l 和 v 的区别:l 代表的是程序运行方式以列表的形式传入(list),v 代表的是程序运行方式以数组的形式传入。有 p 和无 p 的区别:有 p 代表查询程序所在位置的方式是通过环境变量,无 p 代表查询程序所在位置的方式是通过路径。有 e 和无 e 的区别:有 e 代表可以传入环境变量给被运行的程序,无 e 则不能传入环境变量。

如果觉得《【Linux操作系统】进程的控制》对你有帮助,请点赞、收藏,并留下你的观点哦!

本内容不代表本网观点和政治立场,如有侵犯你的权益请联系我们处理。
网友评论
网友评论仅供其表达个人看法,并不表明网站立场。