我虽然这里写了fork函数,但是在我的上一篇博客中也已经讲了fork函数的基本用法。所以这篇不过多叙述fork,只是要用到fork才在目录中写的fork函数。如果点进来的你不是很了解fork函数可以看看我上篇博客:进程概念
下面两个就是本篇重点要说的,通过控制子进程来使子进程实现不同的功能。
一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
看不懂没关系,往后接着看就能看懂了。
一个进程,就三种退出的场景。
怎么理解。
首先,main函数的返回值,也就是return x,x就是这个进程的退出码。
我们可以用echo $? 来查看最近一次的进程退出时的退出码。这个在我上一篇博客中也是讲了的。这里就再演示一下:
代码如下:
#include
int main()
{
printf("hello world\n");
return 20;
}
就这么简单的hello world,返回值就是进程的退出码。
然后再echo $? 退出码就变成了0,因为命令行上的命令也是进程,所以echo $?也有对应的退出码,就是0。
我们在给个不是0的例子:
上面ls进程中没有所谓的e选项,所以ls这个进程就执行错误了。我们此时再用echo $? 来查看ls -a -b -c -d -e的退出码时就出现了2。
那么退出码有什么意义呢?
我们可以通过退出码来判断进程执行结果是否正确。
!0 的数表示进程运行结果错误,0表示进程运行结果正确。
这也是为什么我们刚学C时,每次都要写return 0;而不是return 1;等等。
不同的退出码代表着不同的错误信息。
举个例子:
当你数学考试成绩出来的时候,可能你刚好考了59分,这时候你爹问你为啥没考到60,你可以说出你没考六十的原因,比如说你考试的时候发烧了/拉肚子了/粗心了…等等原因,我们将上面的原因都标上一个具体的数字,比如说1就代表发烧,2就代表拉肚子,3就代表粗心,等等。当你爹问没及格的原因时,就能直接通过数字来找到你对应没考及格的原因。但是如果你及格了的话,你爹正常情况下是不会问你为啥及格了的,这时候及格就相当于是你考试的时候发挥正常。再把这个正常的情况设置为0。
这里就可以类比到进程当中,一个进程的退出码是会被其父进程接收的。
进程结果运行正确,返回0。
进程结果运行错误,返回!0。
进程异常终止。
我们来看看每个!0的退出码都代表什么含义:
可以用strerror来查看每个退出码所代表的错误信息。
代码如下:
跑出来一大堆,这里总共有134个有效的退出码。
中间的太多了,我就直接省略了。
我们再看一下退出码为2的错误信息:No such file or directory
就是没有这个文件或目录的意思。
根据上面这点也就大概能明白为什么main要return 0了。像有的学校的老师竟然还有教学生用return 非零值的,我只能说不好评价。
下面演示下进程崩溃的情况:
给出如下代码:
运行:
我们在上面的退出码中是找不到136这个序号对应的退出码的。
而且,代码如果正常运行的话,代码中后面的打印应该也是有的。但是这里就直接停止了,这就是崩溃的情况。
此时这个退出码136没有什么意义。
就好比你考试作弊被抓住了,那么你的考试成绩也就没有什么意义了。
上面讲的是进程退出的三种情况。
下面说进程退出的常见方式。
其实上面也都涉及了。
有三种退出方式
第一个就不说了。说下2,3。
例子:
第二种,其他函数中
可以看到这里运行了func之后进程就终止了,并没有打印hello world。而且echo $?的结果是12,也就是func中的exit(12);
上面的两个exit的例子也就说明了:exit在任意位置调用,都代表终止进程,参数就是进程的退出码,是由自己来定的。
再讲一下缓冲区的例子:
这个在我前面的博客中也讲了,我也就是在这里提一嘴,方便讲下一个_exit()这个函数。
当我们打印数据时,在后面加上\n会帮我们刷新缓冲区,所以这个代码跑起来就会先打印hello world,后sleep1秒。
但是如果不加\n 看起来就会变成先sleep1秒,再打印hello world。
但是最后还是打印了,这是因为return在进程结束的时候还会帮我们刷新缓冲区。exit也可以。
总的来说就是exit和return本身会要求系统进行缓冲区刷新。
exit最后也会调用_exit, 但在调用_exit之前,还做了其他工作:
前两点没看懂没关系,重点在第三点。
这里没有打印hello world。
_exit 表示强制终止进程,不要进行后续收尾工作,比如刷新缓冲区(用户级缓冲区)。
关于进程终止就讲到这。
最后说一点,进程退出,操作系统层面做了什么?
系统层面,少了一个进程,要free掉改进程的pcb、mm_struct、页表和各种映射关系、代码+数据申请的空间也要释放掉。
进程等待是什么?
通过fork()函数,可以创建子进程,有时并不确定子进程还是父进程谁先退出谁后退出,而子进程为了帮助父进程完成某种任务,那么父进程就要知道子进程任务完成的怎么样,父进程fork()之后,需要通过wait()/waitpid()这两个函数来等待子进程退出。
两个函数:wait 和 wait_pid
简单理解,这两个函数就是用来收尸的。用来让父进程给子进程收尸。
pid_t wait(int*status);
对于wait:
返回值:成功则返回被等待进程pid,失败则返回-1。
参数status:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
这个status参数,等会讲waitpid的时候再说,这里就先用NULL。
直接来演示一下:
代码如下:
上面让父等待8s,是为了方便观察。
然后运行:
补上没截到的:
可以看到,右边前五秒,父子均为s状态。
五秒后,子变为z,父仍为s。
三秒后,父sleep完毕,等待子,此时子被回收。子进程消失,父仍为s
再过三秒,父消失。
再来说waitpid。
pid_ t waitpid(pid_t pid, int *status, int options);
下面的这些函数的解释理解起来比较难,先眼睛过一遍然后看例子就行,看完例子再回头看这些解释。
对于waitpid:
返回值:
- 当正常返回的时候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。
进程退出的情况有三种,那么等待进程退出情况也就有三种,下面就用waitpid来演示这三种情况。
代码上面与wait基本上一样,只需要将wait改为waitpid就行。
等待任意子进程
此处只需要将函数中第一个参数pid改为-1即可。
改为-1意思是等待任意一个子进程
这里的结果和前面的也一样,因为只有一个子进程,改为-1虽然是等待任意一个子进程,但是现在创建了一个子进程,所以就等的是这一个子进程。
等待失败
这里还是改参数,改为一个不存在的进程的pid。
这里会直接等待失败。
通过第二个参数,可以得到子进程的退出信息。
代码稍微修改一下:
然后再运行
上面最后打印的这两个东西,是关于子进程的退出信息的。
一个是退出码,一个是退出信号。
退出码前面都讲过了。退出信号其实也说了,只是没挑明,就是代码异常终止。本质是这个进程因为异常问题,导致自己收到了某种信号。这个某种信号就是退出信号。
这两个不是同时出现的。
一个是进程正常运行结束出现的就是退出码。
一个是进程异常终止就出现退出信号。
status这个参数4个字节,这两个退出信息都是在两个低字节处的。
所以正常终止就是低8~15比特位的数据就是有用的退出码,7~0比特位处的就是0;
异常终止就是8~15比特位的数据为0,6~0比特位处的就是有用的终止信号,中间的第7位暂时不讨论。
此时想要拿到status中的数据的话,两种情况。
上面的例子中,子进程通过exit(0)正常退出,退出码就是0。
异常终止,得到退出信号的情况:
跑到 a /= 0 的时候子进程就崩掉了,父进程就得到了子进程得退出信号,而不是退出码。
如果我们直接打印status得话,如果退出码为0,则打印的是0,如果不是零,那就是一个很怪的数。
正常退出,但exit(10)的情况:
可以看到 status 的值为2560,其实对应到二进制中就是1010 0000 0000。
或者我直接kill -9杀进程(代码中子进程改为运行10s)。收到的退出信号就是9
现在我们再回头看命令行上的进程,为什么我们能够通过echo $?得到进程的退出码。
最重要的一点就是,bash是命令行启动的所有进程的父进程。而且bash就是通过等待的方式来得到子进程的推出结果的,所以我们能通过echo $?看到子进程的退出码。
如果嫌用位操作符来搞退出信息麻烦的话,库里还提供了两个宏。
WIFEXITED()这个宏可以帮我们判断子进程是否正常退出,也就是是否收到了退出信号。
WEXITSTATUS()这个宏是在子进程未收到退出信号时帮我们把status中的退出码整出来。
演示一下:
父进程在等子进程的时候,
可以什么都不做的等,这时候父进程pcb是被放在等待队列中的。
也可以边等边做其他的事,这时候父进程就不会闲着了。
第一种不做事的方式叫做进程的阻塞等待。
第二种边等边做事的方式叫做进程的非阻塞等待。
参数options就是决定是父进程是阻塞等待还是非阻塞等待的。
为0的时候是阻塞等待。
为WNOHANG就是非阻塞等待。
hang有挂起的意思,也有暂停的意思。WNOHANG就是不要让进程停下来。
当我们看到某些应用或者操作系统本身卡住了,长时间不动,就称应用或者程序hang住了,其实就是某个应用所需要的资源未准备就绪,就要不断的等那个资源。
举个例子:
如果你在楼下等朋友出去玩,朋友让你等半个小时。
这半个小时你可以用来干什么?
光在那里等,什么也不干,一直望着你朋友家的窗户,这就是阻塞等待。
但是这半个小时,你可以打游戏,可以听会歌,可以看会电影等等,休闲的途中过一会抬头望一眼朋友家的窗户问下朋友好了没(不断重复),直到朋友下楼。这种等待的方式就是非阻塞等待。
如果在等待的途中朋友说去不了了,他滴妈妈让他补作业不让他下楼玩。这时候就是等待失败了。
是否在等待的时候干事情是由你自己来决定的。
我们前面所有的例子都是子进程先退出的。
改一改代码,让父进程直接开始等,最后再打印father do things,意思就是父进程开始做自己的事情:
这个就是阻塞等待的例子,父进程等到子进程退出才开始做自己的事情。
阻塞的本质就是进程的PCB被放入了等待队列当中,状态改变为S。
返回的本质就是进程的PCB从等待队列里被拿了出来,放到了运行队列当中,从而获取到CPU资源。
那么非阻塞等待呢?
非阻塞是指不要让父进程进入等待队列中,而是让父进程边等变做事(执行代码),还在运行队列中,而且过一段时间父进程就判断一下子进程是否退出。
这种方式就叫做基于非阻塞的轮询方案。
上面的所有例子都是让子进程执行父进程代码中的一部分。
如果想让子进程执行另一个程序,这就是程序替换。
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
就是下面的图。如果看不懂下面图,可以看我上一篇中的进程概念。
进程不变,仅仅是将进程的代码和数据给替换掉。用exec*系列的函数来实现。
这些函数的参数细节先不讲,先演示一下给大家看看:
但是有这条语句,结果就变成了:
执行到execl就停了,并且执行了ls -a -l 命令。也就是函数中的第二个参数。
这就是进程替换。
但是为什么下面的hello world没打印呢?
就是因为进程原先的代码和数据被替换了。在执行execl之前,进程的代码还是原来的,所以打印了第一句话,但是执行了execl之后,进程原先的代码就被替换成了相当于命令行上 ls -a -l 这个进程的代码。
现在,升个级,加上子进程:
运行结果就变成了这样:
又要讲点细节了,
为什么子进程的代码中并没有exit()或者是return退出,而且子进程是继承了父进程的代码和数据的,但是为什么没有执行后面的打印代码(wait success 和 do father things)呢?
因为进程之间是相互独立的,独立的基础是子进程在执行了execl之后就不再是和父进程共享代码了。进程替换之后在代码区是会改变原进程的代码段的,此时就会发生写时拷贝,导致二者不再共享同一份代码。这时二者之间就是相互独立的。子进程去跑ls的代码,父进程接着原来的代码跑。
程序替换的本质就是把程序的进程代码+数据(加载进特定进程的上下文中! !
C/C++程序要运行,必须的先加载到内存中!
如何加载?
加载器,就是exec*系列的程序替换函数!
只要进程的程序替换成功,就不会执行后续代码,意味着exec*系列的函数, 成功的时候,不需要返回值检测!
只要exec*系列的函数返回了,就一定是因为调用失败了!
给个失败的例子:
这里失败的原因就是目录下没有lsss这个文件。
所以最好在子进程中加上退出码。
上面已经讲了,程序替换是什么(将代码和数据替换)和为什么(子做不同事),下面讲讲怎么做------>(exec*系列函数)。
extern char **environ;
int execl(const char *path, const char *arg, ...);
int execlp(const char *file, const char *arg, ...);
int execle(const char *path, const char *arg,
..., char * const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execvpe(const char *file, char *const argv[],
char *const envp[]);
不全讲,用法上很相似。
先说我们例子中的execl()。
int execl(const char *path, const char *arg, ...);
两个参数,第一个参数是 某个文件的全路径(路径(绝对和相对都可)+文件名),这个参数的作用是决定你要执行谁;第二个参数是个可变参数列表,意思就是有多长取决于你自己,像C中的printf的参数就是一个可变参数列表,这个参数的作用是决定你想要怎么执行这个程序。
比如说ls这个指令。
路径 + 文件名就是 /usr/bin/ls(绝对路径),这就是全路径。
第二个参数在我们前面的例子中就是"ls",“-a”, “-l”,当然可以再加上其他选项(前提是有这个选项)。
然后说execv。
int execv(const char *path, char *const argv[]);
path和上面的execl一样。
我在上一篇将讲程概念里面的环境变量的时候讲过main函数参数的问题。其中main函数的第二个参数就是argv,是用来保存命令行上的命令信息的,这里的argv很相似,是用来保存第一个参数里面指定命令的执行方式的。
听起来比较灰色,看例子:
也就是这个:
char* argv[] = {"ls", "-a", "-l", NULL};
execv("/usr/bin/ls", argv);
其实和上面的execl的功能是一样的。只不过是传参的形式发生了改变。
由可变参数列表,改成了将那些可变参数转放到了argv这个指针数组中,然后将argv作为参数传过去。
int execlp(const char *file, const char *arg, ...);
看见…就是可变参数列表。
execl+p就是这个函数比execl多了一个功能。
就是第一个参数可以不用加上路径了,原来传的是路径+文件名,现在直接传文件名就可以了。看:
再看execvp();
int execvp(const char *file, char *const argv[]);
也是execv + p,就不多说了,就是execv多了一项功能。类比一下,直接给例子:
再说个+e的。execle();
int execle(const char *path, const char *arg,
..., char * const envp[]);
这个是用来向别的程序中导环境变量的。
将envp中的导到path中,但不能直接讲。
得先讲别的:
看例子:
我先新建一个code.c文件,然后打印一下其环境变量:
code.c代码:
可以看到是系统本身的。
前面演示的例子都用的是test.c,编译出来的是Test.c;
新建的文件是code.c,编译出来是Code。
然后我们先用execl来演示一下在执行Test时让Test子进程执行Code。
test.c中的代码:
code.c中的代码不变。
运行Test:
成功。
现在我将在test.c中新搞几个环境变量然后用execle来展示。
其中在执行Test时,子进程被替换成Code时会将test.c中的环境变量导过去。
code.c中的代码不变,然后运行Test(test.c编译出来的),得到如下结果:
还有别的exec[l/v]+[p]+[e]没讲,但其实讲到这里就可以说讲完了,因为都是类似的用法。我就不再说了。
要中重点说的是lvpe这几个命名上的区别。
l(list) : 表示参数采用列表,也就是可变参数列表。
v(vector) : 参数用数组,也就是那个argv的指针数组。
p(path) : 有p自动搜索环境变量PATH,也就是第一个参数传参时,不需要加路径。
e(env) : 表示自己维护环境变量,也就是将环境变量导入另一个进程中。
自己想想,其实都讲完了。
我这里就展示一下最后一个例子:execvpe();
函数讲解就到这。
这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
如果调用出错则返回-1
所以exec函数只有出错的返回值而没有成功的返回值。
上面的这些函数,其实都是基于一个函数来搞的。这个函数就是execve。
我们用man手册来查看exec系列的函数时,唯独少了execve,因为这个时系统提供的,而剩下的都是用C语言封装出来的库函数。
下面这张图就可以很好地看出这一点:
所有的这些接口看起来是没有什么太大的差别,也确实如此,最大的不同就是参数上的区别。
为什么有这么多接口呢?
就是为了满足不同的应用场景。
最后说一点就是自己打开一个进程和进程替换没有什么差别。
自己打开就是新建一个进程,有新的PCB产生。
替换是替换原来的进程,没有新的PCB。
结合前面所讲的知识点,我们可以自己做一个简陋版本的shell。
首先我们先看一下我们平常用的shell的界面:
长这个样子,也就是 [用户名@主机名 当前工作路径]提示符 。
我们首先要搞出来这个东西,你可以选择调用库中的函数来获取这些用户名啥的,但是我们今天的重点不是这,而是关于进程的。
所以我这里就直接打印了,不搞那么麻烦。
细节什么的就说第四点。剩下的就不说了。
如果没有判断cd的而无脑fork的话,结果是这样的:
到此结束。。。