文章目录
- 一、进程创建
- 二、进程终止
- 2.1进程退出场景
- 2.2进程退出方法
- 三、进程等待
- 3.1进程等待必要性
- 3.2进程等待的方法
- 3.3获取子进程status
- 四、进程程序替换
- 4.1替换原理
- 4.2替换函数
- 4.3命名理解
- 五、总结
一、进程创建
谈到创建进程,不得不提到一个函数----fork。
在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1
进程调用fork,当控制转移到内核中的fork代码后,内核做以下工作:
① 分配新的内存块和内核数据结构给子进程
② 将父进程部分数据结构内容拷贝至子进程
③ 添加子进程到系统进程列表当中
④ fork返回,开始调度器调度
另外,在fork之前,父子进程做的事是相同的,fork之后,它们有了各自的分工,所做工作也不同。 具体的过程在之前的文章中已经写过了,这里不再赘述。
另外还需要铺垫一下写时拷贝的内容,这个在上一篇博客中讲到了,这里也不再说了。
fork常规用法:
一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
fork调用失败的原因:
系统中有太多的进程
实际用户的进程数超过了限制
二、进程终止
2.1进程退出场景
我们接触到的所有进程,退出时的情况无非就只有三种:
代码运行完毕,结果正确
代码运行完毕,结果不正确
代码异常终止
思安考一个问题:为什么我们写的C/C++程序,在main函数最后都要return 0?
答:0表示进程的退出码,它代表的含义是进程运行并退出成功。
而非0的退出码则表示不成功,具体的数值对应具体的不成功的原因分类,这其中就包含了结果不正确和代码异常终止的情况。
举个例子:
这里我们让main函数的退出码为100。
执行结果如下:
补充:echo $? 可以获取最近一个进程的退出码,很明显第一个echo获取的是./myproc的退出码,而第二个echo获取的是上一次echo命令的退出码。
退出码存在的意义:
答:当进程退出失败时,可以根据退出码得知具体失败的原因。
下面来看一下不同的退出码代表的进程退出失败的不同原因:
这里暂时只贴出一部分信息,感兴趣的朋友可以自己探索一下更多的退出码信息。
2.2进程退出方法
①main函数return
这个方法毋庸置疑,大家都很熟悉。那么接下来提一个问题:
其他函数return可以使进程退出吗?
答:这个问题很简单,大家都知道,当然是不能。
做个实验:
可见,其他函数return并不会使进程退出。
实际上,main函数return是终止程序,而其他函数return是函数返回。
②exit终止进程
再举个例子:
可见,在return之前进程就已经退出了。
exit无论在main函数或是在其他函数中使用,都能使进程直接终止!
与exit相似的还有一个_exit:
看起来好像和exit没有什么不同,但它们既然是两个东西,就一定会有区别。
在解释它们的区别之前,先补充一个输出缓冲区的概念。
在程序输出数据时,会先把一部分数据放进缓冲区中,待缓冲区满了之后,再将它们刷新到显示器上。而如果要输出的数据不足以填满缓冲区,\n和return都可以让系统自动刷新缓冲区,从而让数据输出到显示器上。
而exit和_exit的区别就是,前者和return一样,可以刷新缓冲区,而后者则不作任何进程结束之后的收尾工作,直接将缓冲区的数据释放,并不会刷新到显示器上。
那么进程终止在OS层面都做了哪些改变呢?
答:与进程创建时进行的操作相反。进程终止时,OS会将进程的PCB,进程地址空间,进程的页表、映射关系和进程有关的代码和数据统统释放掉。
三、进程等待
3.1进程等待必要性
进程等待是什么?
答:父进程创建子进程的目的一般是为了让子进程帮助父进程完成一些任务,而父进程需要知道子进程完成任务之后的数据,而父进程等待子进程完成数据并退出的过程就是进程等待。
为什么要有进程等待(必要性)?
答:①父进程通过获取子进程退出的信息,可以得知子进程的执行结果。
②可以保证时序问题:子进程先退出,父进程后退出(不让子进程变成孤儿进程)。
③之前讲过,子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。另外,进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。所以父进程需要通过进程等待来释放子进程的资源。
3.2进程等待的方法
①wait
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
返回值:
成功返回被等待进程pid,失败返回-1。
参数:
输出型参数,获取子进程退出状态,不关心则可以设置成为NULL。
写一段程序体验一下:
按照理论来说,上面程序执行时,两个进程的变化过程应该是:子进程执行->子进程变成僵尸进程->父进程等待,此时子进程彻底消失->父进程执行完毕退出。
接下来看一下结果跟理论是否相符:
可见,和理论答案完全一样!
②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。
具体的代码只和上面的有一点点不同:
它们执行的结果是一样的,这里就不演示了。
另外再补充一点:waitpid中的第一个参数换为-1时,表示等待任意一个子进程,而在这个程序中,父进程只有一个子进程,所以将其换为-1的结果是一样的。
3.3获取子进程status
很明显,status是一个输出型参数,接下来我们直接把上面代码中的waitpid的第二个参数变为status,最后将其打印试一下:
这样的结果好像并不能看出什么,接下来将子进程的退出码改一下,再看一下结果:
所以不难发现:父进程拿到的status的值和子进程的退出码强相关。
再回到之前的问题,父进程等待的原因就是为了让父进程通过status的值拿到子进程的执行结果。
这里再讲一下status的构成
status是一个整数,具有32个比特位,而我们只是用低16位,高16位暂时不用管。
前面讲过,进程终止有三种情况。其实也可以分为两种----进程正常终止和异常终止。正常终止指的是通过return或exit结束进程,而异常终止指的是进程因为异常问题导致自己接收到了某种信号而被迫终止。所以status中就包含进程的退出状态和终止信号。
至于第八位涉及信号问题,这里不多解释。
经过解释,想要拿到一个进程的退出状态(退出是否成功,是否被异常终止)就很容易了(实质上就是分别拿到status的高八位和低七位)。
高八位:(status>>8)&0…0(24个0)1111 1111(也就是十六进制的0xFF)
低七位:status&0…0(25个0)1111111(也就是十六进制的0x7F)
代码如下(对上面的代码稍作改动):
执行结果如下:
结果正确。
但其实,拿到进程的退出状态,不用进行麻烦的位运算,直接调用系统规定的宏即可:
下面再解释一下waitpid的第三个参数option
上面的代码中,我们采用了waitpid的默认参数0,它代表的是父进程等待子进程时的状态为阻塞状态,所谓阻塞状态就是在此期间,父进程不被操作系统调度执行。
我们之前讲过进程PCB和运行队列的概念。
而阻塞等待实际就是在子进程运行,父进程等待期间,将父进程的PCB从运行队列移动到等待队列(进程状态从R变为S)。待子进程退出之后,父进程再重新回到运行队列。
与阻塞等待对应的就是非阻塞等待,在非阻塞等待期间,父进程会不断地监视子进程是否运行完毕,与此同时也可以做一些其他的事情,这就叫做父进程的轮询方案,对应的参数为WNOHANG:
四、进程程序替换
4.1替换原理
先提一个问题,之前我们创建的子进程,都是通过if else语句让其执行与父进程不同的事情,那么如果我们想让子进程执行一个新的程序呢?
答:通过进程替换,将原来进程中的代码和数据替换为需要被执行的程序的代码和数据。这种不改变进程,只改变代码和数据的技术就叫做进程替换。
在此过程中,系统没有创建任何新的进程!!!
4.2替换函数
要理解进程是怎么完成替换的,就必须要了解以下函数:
直接来一小段代码:
可以看到,execl之前的命令照样执行,但execl之后的命令便不再执行。原因就在于,在execl时进行了程序替换。
程序替换的本质就是将进程的代码和数据加载到指令的进程的上下文中,而加载这个操作就是靠加载器(exec系列的函数)来完成的。
另外,因为进程可以写时拷贝使得父子进程之间具有独立性。所以,如果父子进程中的任意一个进行了程序替换,都不影响另一个进程的执行结果。这里就不做演示了。
再补充一点,因为只要exec系列的函数执行成功,就不会执行原进程后面的指令,也就不会返回值。换句换说,只要exec系列的函数有返回值,就证明程序替换一定失败了!如下:
下面来详细解释execl的三个参数:
int execl(const char *path, const char *arg, …);
第一个参数path:替换原进程额进程的路径(所在目录/文件名缺一不可)
第二个参数 char * arg:你期望该进程在命令行上所执行的命令对应的字符串格式
第三个参数…:可变参数列表,需以NULL结尾,当命令行对应字符串写入完毕后用NUL收尾
具体样例可参考上面的代码。
4.3命名理解
其实exce系列的函数有六个:
#include <unistd.h>`
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[]);
execl我们已经解释过了,下面讲解一下这些函数的命名规则
l(list) : 表示参数采用列表(execl)
像上面的例子中,将命令行对应的字符串一个个的列举出来的方法就是l(list)的方法。
v(vector) : 参数用数组(execv)
其实这个函数和上面的没什么区别,只不过是将execl中逐一列举出来的字符串统一存入一个数组中,再把这个数组当做函数的参数而已,代码如下:
p(path) : 有p自动搜索环境变量PATH(execlp)
其实这个跟execl差别也不大,但是它比execl更方便,方便的地方在于,execlp的第一个参数不用指定绝对路径,只需给出文件名,它会自动帮我们在环境变量中寻找该文件,如下:
执行结果相同,这里就不粘贴了。
e(env) : 表示自己维护环境变量
它的意思是可以不使用默认的操作系统提供的环境变量,而是可以自己提供或维护一个指定的环境变量。在这之前,先验证一个结论:程序替换不仅可以替换操作系统中的程序,也可以替换自己指定的(自己创建的)程序。为此,新建一个文件:
与此同时,把Makefile内容稍作调整:
而我现在想要让myproc进程执行myload,只需执行下面这段代码:
执行结果如下:
现在有了足够的铺垫,再来看如何进行execle操作:
首先在myproc.c程序中增加一个环境变量,并把它导入myload.c:
接下来在myload程序中将导入的环境变量打印出来:
执行结果如下:
至于其他的’p’,‘l’,'v’相组合的函数,实质上也就是将不同的功能融合在一起了,这里不一一演示了。
那么同样是程序替换,为什么要实现这么多接口呢?
答:实际上,真正的操作系统调用的程序替换接口只有一个----execve。
而其他的接口都是为了满足用户不同的应用场景,封装出来的,可以分别看一下手册中关于这几个接口的描述:
可以看到,execve不同于我们上面的其他接口,这个是手册2,而另外几个是手册3.区别在于手册3是由云服务器提供的,而手册2是由操作系统提供的。
五、总结
至此,关于Linux中的进程中非信号部分的内容就更新完了。在前面的几篇文章中,对于一些过程,我有意识地把一些进行详解,但还有一些只是一笔带过。因为我们都清楚,在学习操作系统的过程中,有时候不能太刨根问底。因为这本来就是计算机专业最难的一门学科,没有之一,就连操作系统的代码都是全世界的大佬一起维护的。可见,我们学习操作系统的路一眼望不到尽头啊。
另外,如果大家想针对之前的内容进行一次复习。推荐大家自己动手尝试写一个迷你版的shell并运行一下。因为此前已经有很多博主都写过这个了,我就不写了,感兴趣的朋友可以去搜一下其他博主写的代码,学习一下。
最后,关于Linux的内容还远远没有更完,不过我会持续更新的,希望大家可以一起进步!!!