上篇文章介绍了进程的相关概念,形如进程的内核数据结构task_struct 、进程是如何被操作系统管理的、进程的查看、进程标识符、进程状态、进程优先级、已经环境变量和进程地址空间等知识点;
本篇文章接着上篇文章继续对进程的控制进行展开,主要包括进程的创建fork,进程的退出和终止、写时拷贝、进程等待(防止僵尸进程的产生使得内存泄漏),进程替换的相关知识!
进程创建
进程的创建在上篇文章中也有介绍过,进程创建的方式有两种,一种是我们将一个程序跑起来它就会变成一个进程,还有一种就是通过fork()函数创建子进程,主要的创建方式就是通过fork函数,所以我们再来回顾一下fork()函数吧.
fork()函数是一个系统调用接口
//fork()
pid_t fork(void);
返回值: pid_t 类型(实际上是无符号整数) 如果创建子进程成功,返回新创建的子进程的pid(大于0的)给它的父进程,返回0给它自己,如果创建失败就返回-1
注意:fork()成功创建了子进程后就会有父子两个进程,那么也就是说会有两个执行流,fork()会返回两次,分别对父进程和创建出来的子进程进行返回,给父进程返回子进程的pid,因为父进程和子进程的关系是一对多的,所以父进程需要去唯一标识子进程,而进程的pid是天然的标识一个进程的标志,所以fork()返回给父进程的是新创建出来的子进程的pid!而子进程他自己是被创建的时候就知道了自己的pid和其父进程的pid的,所以它不需要fork()函数对他返回任何值,所以fork()就默认给它返回0意思意思一下。上面是创建成功的情况,如果创建失败就会返回-1给当前进程!!!
fork()的使用场景
- 一个进程希望将自己分身,可以一个人做多份工作,那么一个进程就可以通过创建子进程的方式来实现这个目的,可以将自己要干的事分担给子进程,让子进程去帮自己完成。
- 一个进程需要执行别的可执行程序,就可以通过创建子进程的方式,让子进程通过程序替换去帮自己完成程序的执行,我们使用的shell就是这样的,我们执行命令(命令也是可执行程序)的时候,bash就会创建子进程,然后通过程序替换去执行可执行程序。
fork()调用失败的原因
原因非常简单,类比你的手机下来太多东西,容量不够了就不能在下载东西了!
所以fork()创建子进程失败的原因无非就是操作系统中存在太多的进程,内存不够了。
子进程的数据和代码默认是与父进程共享的
我们学习语言的时候了解过继承,子类会继承父类的成员变量,这个理念在进程中同样被使用!
一个进程如果通过fork()函数创建子进程成功,那么它的子进程就会以它的父进程为模板,拷贝它的代码和数据。
创建子进程的操作系统需要干什么
fork()是系统调用,那么就必须要由操作系统来完成子进程的创建工作,那么os会做什么事情呢?
-
根据上篇文章的知识,进程是被操作系统管理起来的,一个进程就是一个task_struct结构体,该结构体包括所有的进程的属性,比如进程的pid、进程的代码对应的地址,数据对应的地址,进程的退出状态,退出信号,进程地址空间等等;
-
操作系统用task_struct 结构体 将进程描述好,再对这些结构体管理,实现对进程的管理工作;
-
知道了上面这些,那么我们就很容易猜到 创建一个进程操作系统就肯定会先创建一个新的task_struct 结构体,该结构体就是那个新新创建的子进程,创建好结构体还没完,还需要对其进行初始化,根据继承的理念,子进程会默认继承父进程的代码和数据,那么操作系统就会把父进程的task_struct中的代码和数据的地址拷贝给子进程;
父子进程是具有独立性的
进程之间是具有独立性的,即使是父子进程也是如此,各个进程之间都是独立运行的!
父子进程既然共享代码和数据如何实现独立性
既然子进程创建出来后是和父进程共享的代码和数据,那么子进程对继承自父进程的数据或代码进行修改,岂不是会对父进程造成影响吗?那怎么还说进程是具有独立性的呢?
- 子进程是和父进程共享的代码和数据没错,进程间具有独立也没有错,错的是子进程修改和父进程共享的数据和代码是不可能会影响到父进程的!因为操作系统考虑到了这个问题,并且让父子进程之间具有写时拷贝的机制,使得在父子一方对共享的资源进行修改是就会将修改的资源分离使得父子各有一份,从而实现进程的独立性!!!
下面具体介绍写时拷贝~
写时拷贝详解
未发生写时拷贝:
未发生写时拷贝的时候,父子是共享这数据和代码的。 它们通过各自的页表将相同的虚拟地址映射到相同的物理地址。
发生写时拷贝后:
当子进程对父子共享的数据或者代码进行修改时,因为进程之间要有独立性,操作不可能直接让子进程将共享的数据给改掉,那样会直接影响到父进程!
所以操作系统会在子进程对父子共享的代码或者数据进行修改的时候,会开辟出一块新的物理空间,将要被修改的代码或者数据拷贝一份之后,让父子中先对共享资源修改的一方去修改新拷贝出来的数据,再让先修改共享资源的一方的页表去重新映射新开辟出来的物理空间实现父子进程的资源分离,互不影响!这就是写时拷贝的基本原理~ (当有一方要对共享资源修改时,为其开要修改的资源开辟新空间,再对该空间的值进行修改,使得二者都有对应的资源,但是被修改的资源不是同一块物理空间了!)
注:写时拷贝的相关操作是由操作系统的内存管理模块完成的!
为什么要有写时拷贝?直接在创建子进程的时候就为其数据和代码开辟新的空间,将其和父进程的数据和代码分离开不好么?
- 1.父子进程的数据,子进程不一定全用,即使使用,也不一定全都会写入(修改)----------存在空间浪费
- 2.最理想的情况是,只有会被父子修改的数据,才会进行分离拷贝,不会修改修改的共享即可 ------理论可以技术角度上无法实现,父子对数据的修改是不可提前预测的
- 3.如果fork的时候就无脑的将父进程的数据拷贝给子进程,就会增加fork的成本(内存和时间层面上)
所以既然写时拷贝存在就有它存在的原因,存在即合理!
写时拷贝是解决上述问题的较为合理的方法,所以才会被采用。写时拷贝是一种演示拷贝的策略,只有当你会对数据进行修改的时候才会给你开辟新的空间,将数据分离,当你不修改的时候,就不会给你新开辟空间,这样省下来的空间就可以被其他进程使用了!体现了os良好的内存管理方案
进程终止
进程退出的场景有哪几种?
- 第一种:代码运行完毕,结果正确
- 第二种:代码运行完毕,结果不正确
- 第三种:代码都没执行完,发生了异常,操作系统直接终止进程
进程退出的方式
- 第一种:通过main函数返回
- 第二种:通过调用exit()函数或系统调用_exit()退出进程
- 第三种:给进程发信号,将进程终止(kill)
上面提到了exit()和_exit(),它们的功能都是让进程退出,两者之间有什么区别呢?
exit()和_exit()的区别
exit()是封装_exit()的函数, _exit()是系统调用。exit() 最终也还是会去调用 _exit() , exit()在 _exit()的基础上增加了新的功能,就是:
- 执行用户通过atexit或on_exit定义的清理函数
- 关闭所有打开的流,所有的缓存数据都会被写入(刷新)
如何获取一个进程的退出码或者退出信号
-
exit(int status)和 _exit(int status)中的参数status就是进程的退出状态码
-
status只有低16位才有价值,它的0-7位是存储着进程的退出信号,8-15位是存的进程的退出码
-
进程正常终止时的退出信号是为0的,退出码为进程设置的退出码
-
进程被信号所终止时,退出码为0,退出信号为进程所收到的信号
方法一:通过位运算来获取
//获取退出码
status>>8&0xFF
//获取退出信号
status&0x7F
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<unistd.h>
int main()
{
pid_t id=fork();
if(id==0)
{
//child
int n=10;
while(n--)
{
printf("i am child! i am running pid:%d ppid:%d\n",getpid(),getppid());
sleep(1);
}
exit(110);
}
else if(id>0)
{
sleep(10);
int status=0;
waitpid(id,&status,0);
if(id>0)
{
sleep(3);
printf("等待子进程成功\n");
printf("子进程退出码:%d 退出信号:%d 子进程pid:%d \n",status>>8&0xFF,status&0x7f,id);//位运算获取退出码和退出信号的方式
}
printf("父进程退出!\n");
}
else
{
printf("fork error!\n");
}
return 0;
}
运行结果:
方法二:通过宏来获取
操作系统提供对应的宏来协助我们获取对应的退出信息
宏 | 说明 |
---|---|
WIFEXITED(int status) | 子进程正常终止则返回真,可以通过WEXITSTATUS(int status)获取子进程退出码 |
WIFSIGNALED(int status) | 子进程异常终止返回真,若为真可通过WTERMSIG(int status)获取子进程的终止信号 |
WIFSTOPPED(int status) | 子进程若为暂停状态返回真 |
WIFCONTINUED(int status) | 子进程被暂停后将其继续的状态,返回真 |
WEXITSTATUS(int status) | 获取子进程退出码 status的次低8位 |
WTERMSIG(int status) | 获取子进程终止信号 stauts的低7位 |
#include<stdio.h>
#include<stdlib.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<fcntl.h>
#include<unistd.h>
int main()
{
pid_t id=fork();
if(id==0)
{
while(1)
{
printf("i am child pid:%d \n",getpid());
sleep(1);
//exit(110);
}
}
else
{
int status=0;
int ret=waitpid(id,&status,0);//阻塞式等待子进程
if(ret>0)
{
// if(WIFEXITED(status)||WIFSIGNALED(status))
if(WIFEXITED(status))
{
printf("子进程退出! 退出码:%d\n",WEXITSTATUS(status));
}
if(WIFSIGNALED(status))
{
printf("子进程退出!退出信号:%d\n",WTERMSIG(status));
}
}
}
return 0;
}
进程等待
进程等待的用处是什么?
我们知道子进程退出时如果父进程未读取其相关的退出信息那么该子进程就会变成僵尸进程,僵尸进程会有内存泄漏浪费系统资源,且操作系统无法回收,就算是kill 也不能将它怎么样,因为它已经僵尸了,kill可以杀死在运行的进程,但是它做不到杀死一个已经死去的进程!
- 所以进程等待就是防止产生僵尸进程的处理方式。通过让父进程等待子进程退出,然后再读取它的退出信息,那么子进程就不再僵尸,会变成终止状态,随时等待被系统回收资源!
- 创建子进程一般都是让子进程去完成父进程给它分配的任务,那么父进程就需要知道子进程最终完成的如何,所以父进程有必要等待子进程退出,读取它的退出状态!
进程等待的方式
wait()函数
pid_t wait(int *stauts)
wait会等待任意一个子进程
参数:
status是输出型参数,可以通过status获取退出子进程的退出状态(退出码或终止信号)
返回值:
成功返回对应子进程pid,失败返回-1
waitpid()函数
pid_t waitpid(pid_t pid,int* status,int options)
waitpid相对wait的可选性更多
参数:
pid : 为-1时,代表着等待任意一个子进程;大于0时,代表等待指定pid的子进程
status:输出型参数,可以通过它获取子进程的退出状态(退出码或终止信号)
options:等待方式,当options为0 时代表父进程阻塞式的等待子进程退出;当options为WNHANG时代表着非阻塞等待,当waitpid返回值为0时,说明子进程还未退出,父进程不会一直在那等待,而是会去干别的事,父进程会以轮询的方式来获取子进程是否退出。
返回值:等待成功返回等待的子进程的pid 失败返回-1
阻塞等待
笼统的理解
所谓的阻塞式等待就是将waitpid中的参数options设置为0,那么父进程就会一直停留在waitpid()这条语句这里,什么也不干就是干等着子进程退出,之后再往下执行后续代码。
系统的理解
父进程阻塞式等待子进程就是当子进程未退出时,操作系统会将父进程的PCB(task_sttuct)放到子进程的等待队列当中,根据前面的进程状态知识可以知道,一个进程等待着某种资源就绪的状态叫做阻塞状态,子进程在等待着父进程退出就是父进程等待着资源就绪,只要子进程不退出,那么父进程就会一直再其等待队列中,直到子进程退出,操作系统才会将父进程继续放回运行队列往下运行后续代码!!!
非阻塞等待
设置非阻塞等待的方式就是将waitpid()中的options设置为WNOHANG即可,那么父进程就会去询问子进程是否退出,如果退出了就返回子进程的pid,未退出返回0,出错返回-1;
通过这种返回就可以让父进程去以轮询的方式去询问子进程是否退出,如果退出就将其回收,否则父进程就可以去干其他事情,而不是一直在原地阻塞着硬等着子进程退出,非阻塞等待的方式可以解放父进程的时间!!!
进程替换
为什么要有程序替换呢?
子进程被创建出来是共享着父进程的代码的,并且会从fork()之后的代码处开始往后执行,那么执行的是和父进程一样的代码,这是没有什么意义的! 我们通常是创建子进程去让子进程去完成其他的工作,比如让子进程去执行其他的可执行程序,那么这里就需要用到进程的替换。
原理是什么?
进程替换的原理就是当进程调用exec系列的进程替换函数后,当前进程的用户空间的代码和数据就会全部被新的程序所替换,接下来就会执行的是新的程序的代码!
注意:进程替换只是将一个进程的用户空间代码和数据用新的程序的代码及数据来替换,整个过程中是没有创建新的进程的,原进程的pid是不变的,变的只有代码和数据!
六种程序替换函数
execl()
int execl(const char *path, const char *arg, ...);
函数名中的l代表list,指代传执行程序的方式是按列表的方式传参的
参数列表
path:代表着要执行的程序的绝对路径
arg:执行该程序的方式,这里的arg是一个可变参数列表。(执行指令方式的多个字符串都可以被arg接收),但是传给arg的最后一个字符串必须是NULL
例如:执行ls 命令时 我们敲的是 ls -a -l
那么让ls去替换子进程时,首先path 就是传的ls的绝对路径(/usr/bin/ls),剩下的就是我们的执行方式 ls -a -l 那么arg就得是“ls" “-a” “-l”,当然最后得串一个空代表执行命令结束了,所以传给arg的是"ls",“-a”,“-l”,NULL
执行结果:子进程执行了ls(ls是一个命令 ,也是一个程序!)
execlp
int execlp(const char *file, const char *arg, ...);
解释
函数名中的p代表着PATH的意思,带p的替换函数就会自动去搜索环境变量PATH;
所以在带p的替换函数中执行某个程序的时候就可以不用传某个程序的绝对路径,只需传其程序名即可,程序替换函数会自己去环境变量中去找这个程序。
参数列表
第一个就是替换的程序路径(不用写全),第二个就是可变参数列表,接收的是执行的方式,同execl.
execle
int execle(const char *path, const char *arg,..., char * const envp[]);
解释
函数名带l,说明传参方式是列表;
函数名中的e代表着environment的意思,也就是环境变量了。execle函数可以自己组装环境变量。
execv()
int execv(const char *path, char *const argv[]);
解释
path代表着执行的程序的绝对路径;
argv代表着执行替换程序的方式,用数组的方式传参。
execvp
解释
execvp和execlp只相差了一个字母,一个是l,一个是v,l代表list(列表),v代表vecor(数组)
也就是带l的执行程序的方式是以列表的方式传递的,带v的则以数组的方式传递!
execvpe
解释
类比execle和execlp,带v可知execvp的程序的执行方式以数组形式传递,带p可知其会自己搜索环境变量PATH,带e可知该函数可以自己组装环境变量。
execve
上面的六个函数都是语言封装的execve函数 execve是系统调用
注意:
- exec系列的函数的参数args,是接收的程序执行的方式,其必须以NULL结尾!
exec系列函数用法总结:
函数名 | args(执行方式)的传参形式 | 是否带路径(PATH) | 是否使用当前环境变量 |
---|---|---|---|
execl | (list)列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 自己组装环境变量 |
execv | (vector)数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execvpe | 数组 | 是 | 自己组装环境变量 |
execve | 数组 | 否 | 自己组装环境变量 |