发布时间:2024-09-14 17:01
在Linux中,我们通常使用fork函数来为一个已经存在的进程创建一个新进程。而这个新创建出来的进程被称为原进程的子进程,原进程被称为该进程的父进程。
该函数其实是一个系统调用接口,原型如下:
#include
pid_t fork(void);
特性:子进程会复制父进程的PCB,二者之间代码共享,数据独有,拥有各自的进程虚拟地址空间。
此时可能会有一个疑问,既然代码共享,并且子进程是拷贝了父进程的PCB,虽然他们各自拥有自己的进程虚拟地址空间,但其中的数据必然是相同的(拷贝而来),并且通过页表映射到同一块物理内存中,那么又如何做到数据独有呢?答案是:通过写时拷贝技术。
写时拷贝技术:子进程创建出来后,与父进程映射访问同一块物理内存,但当父子进程当中有任意一个进程更改了内存中的数据时,会给子进程重新在物理内存中开辟一块空间,并将数据拷贝过去。 这样避免了直接给子进程重新开辟内存空间,造成内存数据冗余。换句话说,如果父子进程都不更改内存中的值,那他们二者各自的进程虚拟地址空间通过页表映射,始终是指向同一块物理内存。
正是通过这样的写时拷贝技术,才保证了父子进程代码共享但数据独有的这一特性。 对于一些小萌新来说,可能上述文字描述并不是那么直观,此处有必要上图来进一步说明一下:
如父进程中有全局变量g_val初值为10,子进程创建之后通过复制父进程的PCB,并且二者的进程虚拟地址空间通过页表映射到同一块物理内存,但如果子进程更改了g_val的值,就会在物理内存中开辟新的空间并保存属于子进程的g_val:
在知道了以上特性后,下面我们来认识一下fork函数的返回值,相当重要!
通过以上函数原型我们可以看到起返回值是pid_t类型,其实就是int,在内核中是通过typedef重命名过的,我们把其当做int类型即可。
如果创建子进程失败,会返回-1,是小于0的,而如果创建子进程成功,该函数则会返回俩个值,这一点和普通的函数有很大区别。它会给子进程返回0值,而给父进程返回子进程的pid(一个大于0的数),也正是通过给父子进程返回值的不同,从而我们可以使用选择语句对齐进行分流,从而让父子进程执行不同的代码,而达到我们创建子进程的某种目的。
在了解到这一点之后,我们便可以通过代码来创建子进程并且进一步验证前面说到的一些特性。
#include
#include
//父子进程代码共享,但数据独有
int g_val = 100;
int main()
{
pid_t pid = fork();//创建子进程
if(pid < 0) {
printf(\"fork error!\\n\");
return -1;
}
else if(pid == 0) {
//子进程
g_val = 200;
printf(\"This is Child! g_val = %d p = %p\\n\",g_val,&g_val);
}
else {
//父进程
sleep(1);
printf(\"This is Parent! g_val = %d p = %p\\n\",g_val,&g_val);
}
return 0;
}
运行程序,得到如下结果:
对于这一结果感到惊讶吗?其实只要你看懂了我上面所说的内容,相信这个结果并不难理解:子进程拷贝父进程的PCB,拥有和父进程一模一样的进程虚拟地空间以及数据,但子进程将自己的g_val更改后,会在物理内存中为其重新开辟空间来存储子进程更改后的数据,而结果中看到的地址完全相同,则是因为它们仅仅是虚拟的地址空间,真正的值是存储在物理内存中的。而这时通过页表的映射,这俩个看似相同的地址已经指向了不同的物理内存。
不止可以通过fork来创建子进程,vfork也同样是用来创建子进程的系统调用函数,那么它和fork有什么区别呢?
#include
#include
pid_t vfork(void);
通过函数原型我们似乎并不能看出什么端倪,的确,vfork在使用时和fork几乎没有什么区别,返回值及其含义也和fork完全相同。其和fork的区别在于,用v_fork创建出来的子进程,也是拷贝父进程的PCB,但它和父进程共享同一个进程虚拟地址空间。也就是如下图所示的这样:
但是我们要思考一个问题,父子进程共享同一个进程虚拟地址空间不会有问题吗?会的!会造成调用栈混乱的问题! 举个例子,如果父进程中调用Test函数首先压栈,之后子进程则调用Fun函数,由于二者共享同一个栈空间,则Fun函数也会继续压栈,但如果此时父进程的Test函数调用完毕想要返回,却发现其并不在栈顶位置,无法出栈,这不就有问题了吗?
那怎么解决呢?vfork采用的方案是,在其创建出子进程之后,让子进程先执行,而父进程则会阻塞,直到子进程执行完毕,父进程才会开始执行,这样就避免了调用栈混乱的问题。
但是!这个问题是解决了,可是新的问题也随之而来了呀,我们创建子进程难道不是为了让其而父进程并发的跑或者说更高效的完成一些任务吗,而现在再子进程退出前父进程什么都不能做,这难道不会影响效率吗?或者说的再直白一些,不是浪费时间吗???
不得不说,确实。可能也正是因为这些种种的缺点,vfork这个函数已然逐渐的被时代淘汰了,fork它不香吗?为什么要用vfork呢? 博主也理解不了它存在的意义…不过也罢,我们只需稍作了解,然后还是把爱全都给fork吧!
含义:进程终止的含义就是一个进程的退出。
进程退出的场景:
进程常见退出方法:
1. 正常退出:
2. 异常退出: Ctrl+C,信号终止等
exit函数:
#include
void exit(int status);
其中,stauts定义了进程的终止状态,由用户自己传递,父进程可以通过wait来获取该值(下边进程等待部分实操)。
_exit函数:
#include
void _exit(int status);
exit和_exit俩个函数都可以退出当前进程,而二者的区别在于:exit是库函数,_exit是系统调用函数,而库函数内部封装了系统调用。 也就是说,调用exit函数最终也会调用_exit来使进程退出,只不过在其调用_exit之前,还会做一些其他的事情,如下图:
从上图我们可以看出,exit()与_exit()还有一个很重要的区别就是在退出前会不会刷新缓冲区。显然,前者是会刷新缓冲区的,这也是它在封装后者的基础上所增加了一些后者并不具备的功能。
代码验证如下:
#include
#include
#include
int main()
{
printf(\"我要退出了!\\n\");
exit(1);
printf(\"应该不会打印我了!\\n\");
return 0;
}
运行以上代码,结果如下:
以上结果符合完全符合我们的预期,那如果使用_exit()呢?我们再试试:
#include
#include
#include
int main()
{
printf(\"我要退出了!\");
_exit(1);
printf(\"应该不会打印我了!\\n\");
return 0;
}
不是说_exit()退出时不会刷新缓冲区吗?怎么还是会打印出来呢?注意:不是bug,原因是\\n(换行符)也有刷新缓冲区的作用。我们去掉\\n再次执行代码就会看到我们预期的结果:
那如何说明一开始调用exit不是因为其内部会刷新缓冲区而不是\\n的作用呢?很简单,去掉\\n再试试就清楚了,肯定也是会刷新缓冲区而打印对应内容的,只不过不会换行了。这里就不在演示。
再补充一点,除了\\n(换行)以及exit()函数会刷新缓冲区之外,也可以调用fflush()来强制刷新缓冲区:
#include
#include
#include
int main()
{
printf(\"我要退出了!\");//没有\\n
fflush(NULL);//刷新缓冲区
_exit(1);
printf(\"应该不会打印我了!\\n\");
return 0;
}
以上结果与使用exit函数退出进程并且前一条打印语句不带\\n一致,大家可以自行验证。
之前在了解进程概念的的时候有说到过僵尸进程,如果子进程先于父进程退出,而父进程并没有关心子进程的退出状况,从而无法回收子进程的资源,就会导致子进程变成僵尸进程。
如果对信号有一定的了解,就会知道,僵尸进程一旦产生就算是kill-9这样的强杀信号都杀不掉它,因为谁也没办法杀掉一个已经死去的进程! 那怎么办呢?当时在进程概念的位置并没有提解决(避免)僵尸进程的办法,而在这个位置再次说到它,就是想来引出进程等待这个概念。进程等待的作用就是防止僵尸进程的产生!
进程等待:父进程通过进程等待的方式,回收子进程的资源,获取子进程的退出状态。
那具体如何完成进程等待呢?答:在父进程中,使用wait或waitpid接口来完成进程等待。
#include
#include
pid_t wait(int *status);
返回值:成功会返回被等待进程的pid,失败则会返回-1
参数:一级指针status,它其实是个输出型参数,用于获取子进程的退出状态,如果不关心则可以设置为NULL
代码实例:
#include
#include
#include
int main()
{
pid_t pid = fork(); //创建子进程
if(pid < 0) {
perror(\"fork\");
return -1;
}
else if(pid == 0) {
//子进程
printf(\"I am child, my pid is %p\\n\", getpid());
sleep(3);
}
else {
//父进程
printf(\"I am father, my pid is %p\\n\", getpid());
wait(NULL); //进程等待
printf(\"进程等待成功!\\n\");
}
return 0;
}
执行以上程序,等够成功等待使我们预期之内的,但我们还应知道的一点是,wait是一个阻塞接口,意味着它在等待子进程退出期间是阻塞在函数内部的,直到子进程退出,它获取了子进程的退出状态并回收子进程的资源,才会返回。 如果要验证以上结论,可以适当增加子进程中休眠的时间,然后使用pstack[父进程进程号] 查看调用堆栈就可以看出,这里不再进行验证。
//头文件同wait的头文件
pid_t waitpid(pid_t pid, int *status, int options);
waitpid同样也可以被用来进行进程等待,但它较wait接口稍稍复杂一些:
返回值:
参数:
也就是说,如果使用waitpid接口并设置options参数为WNOHANG,则未等待到子进程退出时也会立即返回,而不是阻塞,因此这种场景我们一般搭配循环来使用,以确保可以成功等待到子进程退出。
代码实例:
#include
#include
#include
int main()
{
pid_t pid = fork();
if(pid < 0) {
perror(\"fork\");
return -1;
}
else if(pid == 0) {
//子进程
printf(\"I am child, pid is %p\\n\", getpid());
sleep(10);
}
else {
printf(\"I am father, pid is %p\\n\", getpid());
while(waitpid(pid, NULL, WNOHANG) == 0); //循环调用waitpid,直到其返回值不为0
printf(\"进程等待成功!\\n\");
}
return 0;
}
运行结果:10秒之后waitpid成功等待到子进程退出而返回非0值,跳出while循环并执行后续打印语句
其他传参方式大家可以自行验证。
我们发现,不论是wait还是waitpid都有一个出参status,而我们之前并未关心这一点,那么这里就来探讨一下如何获取子进程的退出状态吧!
之前,我们已经知道status是一个出参,由操作系统为其赋值,用户可以传递NULL值表示不关心,而如果传入参数,操作系统就会根据该参数,将子进程的退出信息反馈给父进程,由status最终被赋予的值来体现。
那么,到底如何通过status来获取子进程的退出信息呢,要知道这一点,我们必须先知道status的使用细节:
status是一个int类型的值,意味着它应该有32个比特位,但它又不能被当初普通的整形来看待,因为其高16位的值并不被使用,而只使用其低16个比特位:
那么,在只关心其低16位的基础上,具体的比特位又分别代表什么含义呢,也就是如何通过这低16个比特位来获取子进程的退出信息呢,我们同样通过俩张图来解释:
子进程正常退出时:
子进程异常退出时:
图片表达应该更加直观一些,不过还是要稍作解释:可以看出,不论是正常退出还是异常退出,status的高8个比特位(只讨论低16个比特位)都表示子进程的退出码,而这个退出码一般是return的返回值或者exit的参数;正常退出时,status的低8个比特位为全0;而异常退出时,其第8个比特位则为core dump标志位,用来标志是否会有core dump文件产生,而低7个比特位则是退出信号。
我们可以分别通过以位运算的方式来分别得到以上信息:
退出码:(status >> 8) & 0xFF
低7位(检测子进程是否异常退出):status & 0x7F
core dump标志位:(status >> 7) & 0x1
通过代码来进一步验证以上结论:
#include
#include
#include
#include
int main()
{
pid_t pid = fork();
if(pid < 0) {
perror(\"fork\");
return -1;
}
else if(pid == 0) {
//子进程
printf(\"I am child, pid is %p\\n\", getpid());
sleep(3);
exit(20); //退出子进程并将其退出码设置为20
}
else {
printf(\"I am father, pid is %p\\n\", getpid());
int status; //定义status,让操作系统为其赋值
waitpid(-1, &status, 0); //这种传参方式的waitpid和wait几乎没有区别
printf(\"进程等待成功!\\n\");
//低7位为全0则表示正常退出
if((status & 0x7F) == 0) {
printf(\"正常退出!\\n\");
printf(\"exitcode = %d\\n\", (status >> 8) & 0xFF);
}
else {
printf(\"异常提出!\\n\");
printf(\"core dump flag = %d\\n\", (status >> 7) & 0x1);
}
}
return 0;
}
原理:进程程序替换其实是替换当前正在运行程序的代码段和数据段,并更新堆栈信息。
有关进程虚拟地址空间以及根据页表映射至物理内存这一模式大家都已经非常熟悉了,这里就不再画图解释。我们需要知道的是,进程程序替换与fork不同,它并不会创建新的进程,而是该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。替换前后的进程号并未改变。
我们一般通过替换函数来完成进程程序替换,也就是exec函数簇,需要注意的是,它并不是一个函数,而是多个函数。他们都以exec开头,统称exec函数,其函数原型如下:
#include
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[]);
首先说返回值:
参数解释:
path/file:要替换的可执行程序的名称,path需要带路径
arg/argv[]:可执行程序的参数,规定其第一个参数必须是可执行程序的名称,并以NULL结尾表示参数传递完毕,二者的区别在于参数是以可变参数列表还是字符数组的方式给出
envp[]:程序员自己组织的环境变量,以数组的方式给出,内部同样需要以NULL结尾,如果传入NULL则认为当前程序没有环境变量
如果以上描述还不是很好理解,那么我们可以再仔细观察下这些函数的区别,可以发现,除了开头都是exec这一共同点之外,其余字母无非就是l或v的区别、有没有p的区别以及有没有e的区别:
l或v的区别:
有没有p的区别:是否会去搜索环境变量
有没有e的区别:是否需要程序员自己组织环境变量
代码实战:
#include
#include
int main()
{
printf(\"下面进行进程替换!\\n\");
//将当前程序替换为ls程序
execl(\"/usr/bin/ls\",\"ls\",\"-l\",NULL); //l表示命令行以可变参数列表的形式给出,没有p则说明需要带路径,没有e说明不需要自己组织环境变量
//如果进程替换成功,下面的代码将不再执行
printf(\"继承替换失败!\\n\");
return 0;
}
上述代码以execl为例简单的演示了进程程序替换的实际效果,也完全符合我们的预期,其他函数大家可以自己尝试,都非常的简单。
还需要补充的一点就是:如果使用man去查看这些函数,会发现他们都在3号手册,也就是库函数所在的手册,意味着上述的exec函数簇其实本身都是库函数,而非系统调用。其实,不论是哪个函数,它们最终都会去调用一个叫做execve的系统调用函数,从而真正完成进程程序替换。
#include
int execve(const char *filename, char *const argv[], char *const envp[]);
不仅如此,而这些函数内部,其实也是相互调用的逻辑,不过最终都还是会去调用execve来完成进程程序替换:
文章到这里就结束了,如果感觉博主写的还行的话,就点个赞吧~