3.进程等待
3.1进程等待的原因
之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。
另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。
最后,父进程派给子进程的任务完成的如何,我们需要知道。如,子进程运行完成,结果对还是不对,或者是否正常退出。
父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息
总结:进程为什么要等待?回收子进程资源,获取子进程退出信息,即通过进程等待的方式解决僵尸进程的问题。
3.2进程等待的方法
1. 回收子进程资源wait
我们需要了解wait这个函数,通过man 2 wait
打开手册:
#include<sys/types.h> #include<sys/wait.h> pid_t wait(int*status); 返回值: 成功返回被等待进程pid,失败返回-1。 参数: 输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
了解了关于wait的信息之后,就试着使用一下wait()
这段代码的目的是想演示僵尸状态下的子进程被回收的结果:
即子进程先在循环中sleep10秒,而父进程sleep15秒,这样当子进程运行完毕exit时,父进程在子进程结束的5s内不会回收子进程,这就造成子进程变成Z(僵尸)状态,当5s之后,父进程就会通过wait回收子进程,ret的接收的值就是子进程的进程退出码。最后得sleep(5)是为了让父进程再破案一段时间从而更好的观察状态。
那么这段代码我们编辑完成之后赋值ssh渠道进行观察进程的状态:
一开始右侧执行脚本,观察状态,同时左侧运行mytest,我们发现当子进程正在执行时,子进程和父进程都处于S+状态,当子进程执行完毕,没有被父进程回收时的那5秒,子进程就变成了Z+状态,当父进程执行时,通过调用wait将子进程回收,子进程就结束了,最后的5秒只剩下父进程处于S+状态。这就是父进程通过进程等待回收了僵尸进程(子进程)。
2. 获取子进程的退出信息waitpid
通过man 2 waitpid
查询waitpid的信息
pid_ t waitpid(pid_t pid, int *status, int options); 返回值: 当正常返回的时候waitpid返回收集到的子进程的进程ID; 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0; 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在; 参数: pid: Pid=-1,等待任一个子进程。与wait等效。 Pid>0.等待其进程ID与pid相等的子进程。 status: WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出) WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码) options: WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
1. 获取子进程status
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。
否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。
status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位):
而上面所说的实际上就是:对于这个拿到子进程的退出结果,实际上并不能直接反应出我们想要的结果,其结果是一个复合类型,我们需要将其进行拆分:
对于32个bit位在这里只有尾部16个bit位是有意义的,因此我们将这些拿出来,即0~7
位返回0代表正常的终止信号(返回0证明没有出问题),8~15
次低8位代表子进程对应的退出码。
3.若代码没跑完结果异常了:(在子进程中添加一个错误)
不同的错误通过信号的值可找到对应的错误。下面是struct task_struct的源码,我们发现对于进程退出码和终止信号都在这个PCB中。
上述的过程我们也再总结一下:
- 让OS释放子进程的僵尸状态
- 获取子进程的退出结果(如果子进程不结束,父进程就会一直处于阻塞等待,等待子进程退出)
2. WIFEXITED(status)和WEXITSTATUS(status):
运行一下结果:
成功接收到了子进程正常退出的退出码。那如果子进程不是正常退出呢?我们将cnt改成50,这样会有充足的时间杀掉子进程让其异常:
3.3再谈进程退出
- 进程退出会变成僵尸,会把自己的退出结果写入到自己的task_struct中
- wait/waitpid 是一个系统调用,即以OS的身份进行,因此OS也有能力去读取子进程的status。
即前两条都意味着子进程的退出信号和退出结果都保留在子进程的PCB中。
3.4进程的阻塞和非阻塞等待
在此之前,我们先以一个例子解释阻塞和非阻塞:
在一所学校中有张三和李四这么两个人,张三经常逃课,因此什么也不会,李四认真听讲,学的非常好。考试周到了,张三约好李四让其辅导张三,并想着帮了这么大的忙,得请李四吃顿饭。于是张三给李四打电话:“李四,现在有时间吗?下楼请你吃个饭。”李四说:“等我20多分钟,我看完这本书就下去。”于是张三答应了下来,但这期间张三并没有挂电话,想着能够等待他看完的消息。(现实中并不会出现这样的情况,即便是舔狗也不会)就这样两头电话打着,双方却都很安静,过了20多分钟,李四看完了,就这样二人通过电话彼此收到了消息。
过了几天之后,张三考的还不错,为了感谢李四的帮助想再请李四吃个饭,这次李四仍然说:请等我一会,我处理完事情就下楼。而张三对与上次一直打电话但两头都沉默这种情况感觉很是尴尬,于是这次就先挂了电话。张三一会看看书,一会打打游戏,又时不时的给李四打电话了解处理事情的进度,就这样打了10几次电话后,李四说,我下楼了并且已经看到你了,张三很是高兴,便和李四出去吃饭了。
对于上面的这个例子,张三第一次打电话并没有挂断电话,就这样一直检测李四的状态,这种状态实际上就是阻塞状态。
而对于第二次打电话,并没有一直接通,打的每一次电话都是一种状态检测,如果李四没有就绪,那么就挂断,过一段时间再次检测,而这种每一次打电话实际上都是一个非阻塞状态——而这多次非阻塞就是一个轮询的过程。因此打电话就相当于系统调用wait/waitpid,张三就相当于父进程,李四就相当于子进程。
对于阻塞等待,我们上面已经演示过,那么下面就直接上非阻塞状态的过程:
对于这段代码,设计理念是这样的:子进程在执行期间,父进程则会一直等待并通过while的方式去轮询非阻塞状态,直到子进程退出。
如果子进程出异常了,那么父进程也能够抓到,为了演示这种情况我们在子进程中增加一个野指针的错误:
此时的退出码为0,代表的是子进程的退出码,而终止信号是11号错误,对于异常的进程退出,他的退出码是没有意义的,所以我们返回为0的退出码也不看。
那什么时候会等待失败呢?id错误的时候会等待失败。
4. 进程的程序替换
创建子进程的目的:
- 想让子进程执行父进程代码的一部分(执行父进程对应磁盘代码中的一部分)
- 想让子进程执行一个全新的程序(让子进程想办法加载磁盘是指定的程序,执行新程序的代码和数据,这就是进程的程序替换)
4.1见见猪跑
在这一小节中,包含6种函数,为了提前演示,就在这里拿出一个函数看看进程程序替换究竟是什么样子。
对于一个程序加载到内存去执行,首先是找到这个程序,然后通过不同的选项去以不同的方式去执行,这与环境变量是一样的。因此对于此execl函数来讲,第一个参数path就代表找到程序对应的路径,第二个就代表选项,选哪种方式运行程序的选项;而后面的...
我们为他引入一个新的名词:可变参数列表。顾名思义我们在C语言中的scanf以及printf类的函数,无论传入多少个参数都没有限制,实际上就是可变参数列表的作用,因此,excel里的可变参数列表的作用就是让我们能在传入选项参数时能够传入任意数量的选项。(如 cmd 选项1,选项2……)
知道了这个函数功能之后,开始操作:
一、构建环境
- 首先新建一个目录exec,并将上一级的Makefile拷贝到当前目录下:
cp ../Makefile .
- 然后打开Makefile,将里面的文件名替换成我们想要创建的文件名:
%s/mychild/myexec/g
编写代码,函数execl的头文件是unistd.h
二、编译执行
我们发现其就有了ls指令的功能(ls也是一个程序)。
三、修改完善
当然,我们也可以将其增加选项命令执行对应的功能:
执行之后对比正常的ls -a -l命令:
发现二者无异。那么这就叫做进程的程序替换。
但是我们发现第一个printf打印出来了,但是execl后面的printf却没有打印出来,这是为什么呢?通过下面理解:
4.2 理解原理(是什么、为什么、怎么办)
当我们执行代码时,就会创建进程地址空间与物理内存磁盘之间形成映射关系,当执行上面的代码时就是这样,执行第一个printf会照常打印,到了execl函数时,就会发生进程的程序替换,也就是说,我们所编写的代码会被我们调用的execl对应磁盘内部的代码覆盖,即将指定程序的代码和数据覆盖自己的代码和数据,执行这个新的代码和数据,所以我们明白了为什么execl后面的printf没有执行。
那在进程程序替换的时候,有没有创建新的进程呢?实际上是没有,我们一开始所创建的虚拟空间并不会变化。
execl函数的返回值问题
我们知道,只要是一个函数调用就有可能失败,就是没有替换成功,就是没有替换,而对于这exec系列的函数,失败了返回-1,程序不被替换,因此execl下面的代码也会继续执行。下面就演示一下:(随便打一个不存在的路径或者程序)
//
execl下面的代码也就正常执行了。而exec系列的函数调用成功是没有返回值的,也不需要返回值,因为进程被替换之候原本的代码就没有意义了,即便返回了一个值,也不会有什么作用,还会有额外的开销。
- 多进程的问题
这次我们通过fork创建子进程,并在子进程执行对应的execl函数:
如果我们仍随便打一个不存在的位置或者程序,那么code的值就会变成-1。那这个时候,子进程调用的execl会影响父进程吗?答案当然是否定的,进位进程具有独立性,下面就来理解一下具体是什么原因:
当只存在一个父进程时,就会创建出上面这样的映射关系,当fork函数开始执行,子进程生成,就会创建出子进程的PCB,以及对应的虚拟内存、页表,与父进程共享对应的物理内存:
而当子进程调用execl时,由于子进程发生改变,本着进程直之间具有独立性的原则,子进程就会发生写时拷贝,将共享的数据段和代码段在物理内存的另一个位置进行写时拷贝,并与新的位置形成映射,这样便不会影响到父进程。此外我们也可以看出,数据和代码都可以发生写时拷贝。
总结:虚拟地址空间+页表保证进程独立性,一旦有执行流想替换代码或者数据,就会发生写时拷贝
4.3 一个一个调用对应的方式
除了execl,还有其他类似的接口,六种以exec开头的函数,统称exec函数,我们通过man execl
查看:
主要:
#include <unistd.h>` int execl(const char *path, const char *arg, ...);//l(list) : 表示参数采用列表 int execlp(const char *file, const char *arg, ...);//p(path) : 有p自动搜索环境变量PATH int execle(const char *path, const char *arg, ...,char *const envp[]);//e(env) : 表示自己维护环境变量 int execv(const char *path, char *const argv[]);//v(vector) : 参数用数组 int execvp(const char *file, char *const argv[]);//vp就是v和p的结合
一、函数解释
这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
如果调用出错则返回-1
所以exec函数只有出错的返回值而没有成功的返回值。
二、函数的具体原理及演示
下面就来演示其他几个例子:
execlp(const char *file, const char *arg, …)
p:path,不用告诉我程序的路径,只有告诉这个函数传入的名字,就会自动的在环境变量PATH中进行可执行程序的查找。
4.4 应用场景:模拟shell命令行解释器
我们将子进程的代码中的替换注释掉,在添加成这样:
不传入argv[0]的原因是argv[0]代表我的程序:myexec,这样的话就会出现死循环的情况,因为会一直调用,所以为了跳过,我们从第二个元素argv[1]的地址开始。
那如果我们将第一个./myexec去掉,发现不就是相当于自己写了一个shell吗?因此下面我们来编写shell命令行解释器:
新建目录myshell,touch一个myshell.c ,并编辑Makefile
下面就来编写myshell.c:
编译运行
这样就可以很好的模拟出shell命令行解释器了,但还有一个问题:就是返回上一级路径时,对于我们这个代码是这样的情况:
但是按照正常的命令行来说应该是变化的,因此下面就来尝试解决这个问题:
- 首先我们要知道什么是当前路径
因此在这里touch一个新的myproc.c
来解释:
复制ssh渠道并观察执行:
当前进程的工作目录,就是当前路径。 因此,若是想实现路径的改变,就需要实现进程工作目录的改变,说到这里,大家也应该明白,这个当前进程的工作目录也是可以修改的。
- 改变当前路径:chdir函数
下面不废话,直接演示其是如何改变当前路径的:
编译运行:
我们发现,这样就将这个进程的路径改变了,也就是说如果我们再通过这个进程创建文件,就会创建到此时这个/home/cfy的这个路径中。
那回到一开始,为什么我们自己写的shell,cd 的时候路径没有变化呢?
在上面实现的shell模拟代码中,我们fork出了子进程,子进程有自己的工作目录,因此cd更改的是子进程的工作目录,子进程执行完毕,继续用的是父进程,就是我们的shell,因此在这个过程中父进程也就是shell的工作目录并没有发生变化。
- 将编写的模拟shell进行修改——修改当前路径
这样就补充了之前的不足。像cd这种不需要让我们的子进程来执行,而是让shell自己执行的命令,被称为内建/内置命令。 接下来还没完,实现最后一个问题:echo内建命令。对于echo我们知道,通过echo $?
能够活获得最近一次进程的退出码和终止信号。最终代码:
完结!