【Linux】进程控制

在这里插入图片描述

本篇博客由 CSDN@先搞面包再谈爱 原创,转载请标注清楚,请勿抄袭。

fork函数

我虽然这里写了fork函数,但是在我的上一篇博客中也已经讲了fork函数的基本用法。所以这篇不过多叙述fork,只是要用到fork才在目录中写的fork函数。如果点进来的你不是很了解fork函数可以看看我上篇博客:进程概念

fork常规用法

下面两个就是本篇重点要说的,通过控制子进程来使子进程实现不同的功能。

一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。

一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

看不懂没关系,往后接着看就能看懂了。

进程终止

进程退出场景

一个进程,就三种退出的场景。

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

怎么理解。
首先,main函数的返回值,也就是return x,x就是这个进程的退出码。
我们可以用echo $? 来查看最近一次的进程退出时的退出码。这个在我上一篇博客中也是讲了的。这里就再演示一下:
在这里插入图片描述
代码如下:

#include<stdio.h>
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没有什么意义。
就好比你考试作弊被抓住了,那么你的考试成绩也就没有什么意义了。

上面讲的是进程退出的三种情况。
下面说进程退出的常见方式。

进程常见退出方法

其实上面也都涉及了。
有三种退出方式

  1. main 函数return
  2. exit()终止进程
  3. _exit()强制终止进程

第一个就不说了。说下2,3。

  1. exit()终止进程进程。
    在这里插入图片描述
    函数的参数就是进程的退出码
    就相当于是 return status;不过是放在哪里都能结束进程。

例子:

第一种,main函数中
在这里插入图片描述
在这里插入图片描述

第二种,其他函数中
在这里插入图片描述
在这里插入图片描述
可以看到这里运行了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之前,还做了其他工作:

  1. 执行用户通过 atexit或on_exit定义的清理函数。
  2. 关闭所有打开的流,所有的缓存数据均被写入
  3. 调用_exit

前两点没看懂没关系,重点在第三点。

_exit不会刷新缓冲区。
在这里插入图片描述

在这里插入图片描述
这里没有打印hello world。
_exit 表示强制终止进程,不要进行后续收尾工作,比如刷新缓冲区(用户级缓冲区)。
在这里插入图片描述
关于进程终止就讲到这。

最后说一点,进程退出,操作系统层面做了什么?
系统层面,少了一个进程,要free掉改进程的pcb、mm_struct、页表和各种映射关系、代码+数据申请的空间也要释放掉。

进程等待

进程等待是什么?
通过fork()函数,可以创建子进程,有时并不确定子进程还是父进程谁先退出谁后退出,而子进程为了帮助父进程完成某种任务,那么父进程就要知道子进程任务完成的怎么样,父进程fork()之后,需要通过wait()/waitpid()这两个函数来等待子进程退出。

进程等待的必要性

  1. 通过获取子进程退出的信息(不仅是退出码),能够得知子进程执行结果。
  2. 可以保证时序问题:一定是子进程先退出,父进程后退出。
  3. 进程退出的时候,会先进入僵尸状态(处于僵尸状态的进程连kill -9都杀不掉),僵尸进程会造成内存泄漏的问题,需要通过父进程等待子进程,释放掉子进程所占用的资源。

进程等待的方法

两个函数: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:

返回值:

  1. 当正常返回的时候waitpid返回收集到的子进程的进程ID;
  2. 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
  3. 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;

参数:

pid:
pid=-1,等待任一个子进程。与wait等效。
pid>0.等待其进程ID与pid相等的子进程。

status:
两个宏

  1. WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
  2. WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)

options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。

进程退出的情况有三种,那么等待进程退出情况也就有三种,下面就用waitpid来演示这三种情况。
代码上面与wait基本上一样,只需要将wait改为waitpid就行。

waitpid第一个参数pid

等待指定子进程
在这里插入图片描述
这里的作用前面的wait是一样的,都是父进程等待子进程。
在这里插入图片描述

等待任意子进程

此处只需要将函数中第一个参数pid改为-1即可。
改为-1意思是等待任意一个子进程(这里图给错了,id那个位置应该是-1,懂我意思就行)
在这里插入图片描述
在这里插入图片描述
这里的结果和前面的也一样,因为只有一个子进程,改为-1虽然是等待任意一个子进程,但是现在创建了一个子进程,所以就等的是这一个子进程。

等待失败

这里还是改参数,改为一个不存在的进程的pid。
这里会直接等待失败。

在这里插入图片描述
在这里插入图片描述

waitpid第二个参数status

通过第二个参数,可以得到子进程的退出信息。
代码稍微修改一下:
blog.csdnimg.cn/5688f91f0dda44368fdd087d3e327f0f.png)
然后再运行
在这里插入图片描述
上面最后打印的这两个东西,是关于子进程的退出信息的。
一个是退出码,一个是退出信号。
退出码前面都讲过了。退出信号其实也说了,只是没挑明,就是代码异常终止。本质是这个进程因为异常问题,导致自己收到了某种信号。这个某种信号就是退出信号。

这两个不是同时出现的。
一个是进程正常运行结束出现的就是退出码。
一个是进程异常终止就出现退出信号。

status这个参数4个字节,这两个退出信息都是在两个低字节处的。
在这里插入图片描述
所以正常终止就是低8~15比特位的数据就是有用的退出码,7~0比特位处的就是0;
异常终止就是8~15比特位的数据为0,6~0比特位处的就是有用的终止信号,中间的第7位暂时不讨论。

此时想要拿到status中的数据的话,两种情况。

  1. 正常退出,就让status的8~15位&二进制为1111 1111的数,十六进制就是ff,这样就能得到退出码。
  2. 异常终止的话,就让status的0~6位&二进制为0111 1111的数,十六进制就是7f,这样就能得到退出信号。

上面的例子中,子进程通过exit(0)正常退出,退出码就是0。

演示一下:
正常终止得到退出码的情况:
在这里插入图片描述
在这里插入图片描述

异常终止,得到退出信号的情况:
在这里插入图片描述
跑到 a /= 0 的时候子进程就崩掉了,父进程就得到了子进程得退出信号,而不是退出码。
在这里插入图片描述

如果我们直接打印status的话,如果退出码为0,则打印的是0,如果不是零,那就是一个很怪的数。

正常退出,但exit(10)的情况:
在这里插入图片描述
可以看到 status 的值为2560,其实对应到二进制中就是1010 0000 0000。

异常终止(还是a /= 0的代码):
在这里插入图片描述
可以看到status的结果就是8,也就跟前面讲的相符。

或者我直接kill -9杀进程(代码中子进程改为运行10s)。收到的退出信号就是9
在这里插入图片描述

或者kill -2,退出信号就是2。
在这里插入图片描述

现在我们再回头看命令行上的进程,为什么我们能够通过echo $?得到进程的退出码。

最重要的一点就是,bash是命令行启动的所有进程的父进程。而且bash就是通过等待的方式来得到子进程的退出结果的,所以我们能通过echo $?看到子进程的退出码。

如果嫌用位操作符来搞退出信息麻烦的话,库里还提供了两个宏。

在这里插入图片描述
WIFEXITED()这个宏可以帮我们判断子进程是否正常退出,也就是是否收到了退出信号。
WEXITSTATUS()这个宏是在子进程未收到退出信号时帮我们把status中的退出码整出来。

演示一下:

子进程以exit的形式退出。
在这里插入图片描述
父进程用两个宏函数:
在这里插入图片描述

在这里插入图片描述

子进程崩溃:
在这里插入图片描述
父进程代码同上。

在这里插入图片描述

waitpid第三个参数options

父进程在等子进程的时候,
可以什么都不做的等,这时候父进程pcb是被放在等待队列中的。
也可以边等边做其他的事,这时候父进程就不会闲着了。

第一种不做事的方式叫做进程的阻塞等待。
第二种边等边做事的方式叫做进程的非阻塞等待。

参数options就是决定是父进程是阻塞等待还是非阻塞等待的。
为0的时候是阻塞等待。
为WNOHANG就是非阻塞等待。
hang有挂起的意思,也有暂停的意思。WNOHANG就是不要让进程停下来。
当我们看到某些应用或者操作系统本身卡住了,长时间不动,就称应用或者程序hang住了,其实就是某个应用所需要的资源未准备就绪,就要不断的等那个资源。

举个例子:
如果你在楼下等朋友出去玩,朋友让你等半个小时。
这半个小时你可以用来干什么?
光在那里等,什么也不干,一直望着你朋友家的窗户,这就是阻塞等待。
但是这半个小时,你可以打游戏,可以听会歌,可以看会电影等等,休闲的途中过一会抬头望一眼朋友家的窗户问下朋友好了没(不断重复),直到朋友下楼。这种等待的方式就是非阻塞等待。

如果在等待的途中朋友说去不了了,他滴妈妈让他补作业不让他下楼玩。这时候就是等待失败了。

是否在等待的时候干事情是由你自己来决定的。

我们前面所有的例子都是子进程先退出的。

改一改代码,让父进程直接开始等,最后再打印father do things,意思就是父进程开始做自己的事情:

在这里插入图片描述在这里插入图片描述
这个就是阻塞等待的例子,父进程等到子进程退出才开始做自己的事情。
阻塞的本质就是进程的PCB被放入了等待队列当中,状态改变为S。
返回的本质就是进程的PCB从等待队列里被拿了出来,放到了运行队列当中,从而获取到CPU资源。

那么非阻塞等待呢?
非阻塞是指不要让父进程进入等待队列中,而是让父进程边等边做事(执行代码),还在运行队列中,而且过一段时间父进程就判断一下子进程是否退出。

这种方式就叫做基于非阻塞的轮询方案。

演示一下:
在这里插入图片描述
在这里插入图片描述

进程程序替换

替换原理

上面的所有例子都是让子进程执行父进程代码中的一部分。

如果想让子进程执行另一个程序,这就是程序替换。

用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。

就是下面的图。如果看不懂下面图,可以看我上一篇中的进程概念

在这里插入图片描述

有下面这些替换函数:

在这里插入图片描述

这些函数的参数细节先不讲,先演示一下给大家看看:

直接演示一下:
先不搞父子进程的,就一个进程:
在这里插入图片描述

如果没有那条execl语句的话,结果应该是这样的:
在这里插入图片描述

但是有这条语句,结果就变成了:

在这里插入图片描述

执行到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()。

execl
int execl(const char *path, const char *arg, ...);

两个参数,第一个参数是 某个文件的全路径(绝对和相对都可),这个参数的作用是决定你要执行谁;第二个参数是个可变参数列表,意思就是有多长取决于你自己,像C中的printf的参数就是一个可变参数列表,这个参数的作用是决定你想要怎么执行这个程序。

比如说ls这个指令。
路径就是 /usr/bin/ls(绝对路径),这就是全路径。
第二个参数在我们前面的例子中就是"ls",“-a”, “-l”,当然可以再加上其他选项(前提是有这个选项)。

再给个例子:
在这里插入图片描述
在这里插入图片描述

然后说execv。

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作为参数传过去。

结果如下:
在这里插入图片描述
再说execlp();

execlp
int execlp(const char *file, const char *arg, ...);

看见…就是可变参数列表。

execl+p就是这个函数比execl多了一个功能。

就是第一个参数可以不用加上路径了,原来传的是路径+文件名,现在直接传文件名就可以了。看:
在这里插入图片描述
在这里插入图片描述

再看execvp();

execvp
int execvp(const char *file, char *const argv[]);

也是execv + p,就不多说了,就是execv多了一项功能。类比一下,直接给例子:

在这里插入图片描述

在这里插入图片描述

再说个+e的。execle();

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时其进程中会被导入下面五个环境变量。
在这里插入图片描述

code.c中的代码不变,然后运行Test(test.c编译出来的),得到如下结果:
在这里插入图片描述
还有别的exec[l/v]+[p]+[e]没讲,但其实讲到这里就可以说讲完了,因为都是类似的用法。我就不再说了。

要中重点说的是lvpe这几个命名上的区别。

命名理解

l(list) : 表示参数采用列表,也就是可变参数列表。
v(vector) : 参数用数组,也就是那个argv的指针数组。
p(path) : 有p自动搜索环境变量PATH,也就是第一个参数传参时,不需要加路径。
e(env) : 表示自己维护环境变量,也就是将环境变量导入另一个进程中。

仔细想想,其实都讲完了。
我这里就展示一下最后一个例子:execvpe();

在这里插入图片描述

执行之前需要将路径导入到PATH中,不然会出错。
在这里插入图片描述

函数讲解就到这。

函数解释

这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
如果调用出错则返回-1
所以exec函数只有出错的返回值而没有成功的返回值。

exec函数族

上面的这些函数,其实都是基于一个函数来搞的。这个函数就是execve。

我们用man手册来查看exec系列的函数时,唯独少了execve,因为这个时系统提供的,而剩下的都是用C语言封装出来的库函数。

在这里插入图片描述
在这里插入图片描述
下面这张图就可以很好地看出这一点:
在这里插入图片描述
所有的这些接口看起来是没有什么太大的差别,也确实如此,最大的不同就是参数上的区别。

为什么有这么多接口呢?
就是为了满足不同的应用场景。

最后说一点就是自己打开一个进程和进程替换没有什么差别。
自己打开就是新建一个进程,有新的PCB产生。
替换是替换原来的进程,没有新的PCB。

自己做一个简易的shell

结合前面所讲的知识点,我们可以自己做一个简陋版本的shell。

首先我们先看一下我们平常用的shell的界面:
在这里插入图片描述
长这个样子,也就是 [用户名@主机名 当前工作路径]提示符
我们首先要搞出来这个东西,你可以选择调用库中的函数来获取这些用户名啥的,但是我们今天的重点不是这,而是关于进程的。

所以我这里就直接打印了,不搞那么麻烦。

  1. 打印提示符
  2. 获取命令字符串
  3. 解析命令字符串
  4. 检测命令是否需要shell本身执行(内建命令)(父进程来做)
  5. 执行第三方命令(子进程来做)

简陋版的shell代码如下:
在这里插入图片描述

细节什么的就说第四点。剩下的就不说了。
在这里插入图片描述
如果没有判断cd的而无脑fork的话,结果是这样的:
在这里插入图片描述

加上后就是正常的情况:
在这里插入图片描述

到此结束。。。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

先搞面包再谈爱

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值