进程退出的三种场景:
1.代码运行完毕,结果正确
2.结果运行完毕,结果不正确
3.代码异常终止,程序跑了一部分终止了,在vs
中对应程序崩溃
首先,我们需要知道,main
函数的return
值是其实是进程的退出码,会返回给父进程或者系统,父进程可以根据退出码进行原因分析。
一般来说,退出码为0
表示进程运行完毕,结果正确。退出码为非0
表示进程运行完毕,结果错误。
对此,我们可以进行验证:
格式:echo $?
功能:输出最近一次进程退出时的退出码
当我们使用正确命令的时候,退出码为0
,使用错误命令的时候,退出码为非0
。
一个进程正常运行结束,结果正确,对于这种情况不用太多的分析。
但是一个进程正常运行结束,结果错误,这时就需要分析进程是因为什么原因导致结果错误。
所以,每个进程的退出码其实都表示了一个错误原因。我们可以通过strerror
函数来获取退出码对应的错误原因。
头文件:#include
格式:strerror(退出码)
功能:返回退出码对应的退出原因字符串
注:
程序正常运行结束,说明程序的主体代码已经执行完成,exit
或者return
等代码已经执行,这时退出码就具有意义。如果程序异常终止结束,这时退出码也就没有参考意义。
(
1
)
(1)
(1)return
关于return
有两种场景:
1.非主函数进行return
这种非主函数内的return
叫做函数返回,其并不是终止进程。
2.主函数进行return
主函数的return
是终止进程。
( 2 ) (2) (2) exit
头文件:#include
格式:exit(退出码)
功能:终止进程
注:exit
函数不管在哪使用其含义都是终止进程。
在明白了exit
和return
以后,我们需要重新看向以前的一个知识点:
以前我们说了printf
打印内容的时候,如果不带\n
,那么只有等进程退出以后系统才会刷新缓冲区,将内容打印到显示器上,其实这背后是exit
和return
在要求系统刷新缓冲区。
(
3
)
(3)
(3)_exit
头文件:#include
格式:_exit(退出码)
功能:终止进程
_exit
和exit
的功能基本一致,我们着重学习两者的区别:
一个正常运行的程序,在运行exit
函数以后会执行用户定义的清理函数,冲刷缓冲区,关闭流等等,接着再将控制权交给内核操作系统。
而_exit
在程序运行完以后,不会执行清理,冲刷缓冲区这些操作,而是将控制器直接交给内核操作系统。
注:这里的缓冲区是用户级缓冲区
对此我们通过代码进行验证:
#include
#include
int main()
{
printf("_exit验证");
_exit(0);
}
操作系统层面做了什么?
进程的退出,说明在操作系统层面,少了一个进程,那么PCB,进程地址空间,页表和各种映射关系,代码,数据和申请的空间都要被释放掉。
在了解进程等待之前,我们重新看向fork
函数的作用:
fork
创建子进程,子进程的创建是为了帮助父进程完成某种任务,那么既然是帮助父进程完成某种任务,父进程就需要知道子进程执行任务是否成功?完成了多少。
在讲进程状态的时候,我们说过进程处于僵尸状态时,会将退出信息写入PCB
中,供父进程进行读取,但是父子进程的调度顺序不是固定的,可能存在这样一种情况,子进程还在运行,父进程已经退出了,这时父进程就无法读取到子进程的退出信息,所以进程等待出现了,通过一些系统调用函数可以使得父进程等待子进程运行完再退出。
总结下进程等待的作用:
1.父进程通过等待获取子进程退出的信息,能够得知子进程的执行结果。
2.可以保证时序问题:子进程先退出,父进程后退出。
3.进程退出的时候会先进入僵尸状态,会造成内存泄露的问题,通过进程等待,父进程可以释放子进程占用的资源。(这里父进程释放子进程的资源实际上是不准确的,父进程只会读取子进程的退出信息,释放资源的任何最终还是由操作系统来完成)。
如何做到进程等待?
在Linux中,通过wait,waitpid这样的系统调用函数可以让父进程做到等待子进程。
(
1
)
(1)
(1)wait
头文件 #include
#include
函数原型:pid_t wait(int* status)
返回值:类型pid_t 等待成功返回子进程pid 失败返回-1
参数类型 int * status:输出型参数,可用于获取子进程的退出信息
功能:等待子进程
对于这里的参数我们需要进行详细的介绍:
关于输出型参数status
,`其类型是一个整形指针,也就是说用户只需要在外部定义一个变量status
,在参数部分传递进变量的地址,在wait
函数内部,系统会自动为status
进行赋值。
status
参数由32
个比特位组成,但是目前在这里我们只介绍低16
位。
进程的终止就三种可能性:前两种可能我们已经介绍过了,但是对于进程的异常终止,我们还没有过多的介绍,这里大家需要知道进程异常终止的本质是因为这个进程因为异常问题,导致自己收到了某种信号。所以异常终止的进程其退出码没有借鉴意义。
注:若进程正常运行,那么进程信号就为0
所以可以通过if
语句进行判断,当进程信号为0
时,获取进程的退出码,反之获取进程信号。
如何通过status获取到子进程的退出码和退出信号呢?
退出码 = (status >> 8) & 0xFF;
退出信号 = status & 0x7F;
注:如果用户不关心子进程的退出信息和退出码,参数status
可以传递空指针。
注:除了可以使用按位与的操作得到退出码,Linux
中还有两个宏可以获得退出信号和退出码。
WIFEXITED(status)
:若子进程正常退出,则返回一个大于0
的数字。(判断进程是否正常退出)
WEXITSTATUS(status)
: 若返回值非零,则可以提取子进程退出码。(查看进程的退出码)
通过代码我们来验证status
参数的组成。
#include
#include
#include
#include
#include
using namespace std;
int main()
{
pid_t id=fork();
if(id==0)
{
int cnt=5;
while(cnt--)
{
cout<<"I am a son proc"<<endl;
sleep(1);
}
exit(13);
}
else
{
int status=0;
sleep(10);
cout<<"father begin"<<endl;
pid_t ret= wait(&status);
sleep(1);
if(ret>0)
{
cout<<"father wait sucess"<<endl;
if((status&0x7f)==0)
{
cout<<"son pron exit normal"<<endl;
cout<<"son exitcode="<<((status>>8)&0xFF)<<endl;
}
else
cout<<(status&0x7f)<<endl;
}
else
cout<<"father wait failed"<<endl;
}
}
结果发现,子进程的退出码和我们设置的一样,确实为13
。
也可以通过给子进程发送退出信号,来让其异常终止。
在得知了父进程如何获取子进程的退出信息以后,我们也就能理解一个现象:
为何echo$?
能够获取到最近一个进程的退出码,通过一串代码我们进行解释:
#include
#include
using namespace std;
int main()
{
cout<<"son proc id="<<geipid()<<endl;
cout<<"father proc id="<<getppid()<<endl;
}
在前面我们提到过,在命令行上启动的进程的父进程都为bash
,echo $?
命令的作用是输出最近一个进程的退出码,而最近一个进程的退出信息父进程bash
通过进程等待接收了,这就是echo $?
的原理。
通过进程等待,我们也可以验证父进程会清理子进程的资源(只是读取子进程的退出信息)
#include
#include
#include
#include
#include
using namespace std;
int main()
{
pid_t id =fork();
if(id==0)
{
int cnt=5;
while(cnt--)
{
cout<<"I am a son proc"<<endl;
sleep(1);
}
exit(13);
}
else
{
sleep(10);
cout<<"father begin"<<endl;
pid_t ret=wait(NULL);
if(ret>0)
cout<<"father wait sucess"<<endl;
else
cout<<"father wait failed"<<endl;
}
}
子进程运行5
秒后退出,父进程休眠10
秒,开始等待子进程,在父进程等待结束可以看到僵尸状态消失了。
(
2
)
(2)
(2)waitpid
头文件 #include
#include
函数原型:pid_t watipid(pid_t pid,int* status,int options);
返回值:类型pid_t 等待成功返回子进程pid 失败返回-1
功能:等待子进程
waitpid
的功能和wait
大致一样,其第二个参数的作用和wait
一样,这里就不多解释了。
我们着重解释参数一和参数三。
参数一:
1.传入具体要等待的子进程的pid
。
2.传入-1
,父进程会选择任意一个子进程进行等待。
参数二:父进程的等待方式
1.输入0
表示以阻塞等待的方式等待子进程
2.输入WNOHANG
表示以非阻塞的方式等待子进程。
注:
WNOHANG
表示非阻塞等待,看到某些应用或者操作系统本身,卡住了长时间不动,一般称其HANG
住了。
W :wait
NO:没有
阻塞等待:如果父进程以阻塞等待的方式等待子进程,那么父进程在等待的过程中什么也不会做,会一直等待子进程直到等待成功or失败。
阻塞了是不是意味着父进程不被调度执行了?
是的,阻塞的本质:其实是进程的PCB从运行队列被放入了等待队列并将进程的R状态改为S状态
返回的本质:进程的PCB从等待队列被放入了运行队列被CPU调度,并获取子进程的退出信息。
非阻塞等待:父进程以非阻塞等待的方式等待子进程,那么父进程在等待的过程中不是完全卡住的,其会间隔一段时间查看子进程的情况,如果子进程退出,那么父进程返回,如果子进程还在运行,那么父进程在一段时间过后会继续查看,直到等待成功or失败,这种方式也叫
基于非阻塞的轮询等待方式
。
非阻塞等待由多次的询问/查看组成,父进程不是一直卡着不动等待子进程,而是隔一段时间等待一次,在这些等待的时间间隔内,父进程依旧可以被CPU调度,执行自己的代码。
非阻塞等待和阻塞等待的异同:
同:两种等待的本质都是PCB和状态的切换。
异:阻塞等待时,父进程啥也不干。非阻塞等待时,父进程在等待间隔的时间内可以被CPU调度,父进程的PCB不断在运行队列和等待队列中进行切换。
代码实现:
注:通过代码实现非阻塞轮询等待,waitpid
返回0
表示子进程还在运行,父进程需要重复等待。返回值大于0
表示等待成功,等待结束。返回值小于0
表示等待失败。
代码主体大致不变,因为是轮询等待,所以需要将等待包在一个while
循环内。
#include
#include
#include
#include
#include
using namespace std;
int main()
{
pid_t id=fork();
if(id==0)
{
int cnt=5;
while(cnt--)
{
cout<<"I am a son proc"<<endl;
sleep(1);
}
exit(12);
}
else
{
int status=0;
cout<<"father wait begin"<<endl;
while(true)
{
pid_t ret=waitpid(id,&status,WNOHANG);
if(ret==0)//子进程还在运行,父进程继续等待
{
cout<<"father proc do my things"<<endl;//父进程在等待时间间隔内可以做自己的事情
}
else if(ret>0)//父进程等待成功
{
cout<<"father wait sucess"<<endl;
cout<<"exitsignal:"<< (status & 0x7F)<<endl;
cout<<"exitcode:"<<((status >> 8)&0xFF)<<endl;
break;
}
else if(ret<0)//父进程等待失败
{
cout<<"father wait failed"<<endl;
break;
}
sleep(1);
}
}
}
父进程通过fork
函数创建子进程,子进程的出现可以是帮助父进程完成某种任务,能否让子进程运行一个新的程序呢?
事实上,现有技术是可以做到的,通过调用exec*
系列替换函数可以达到进程替换的目的。
进程替换:进程不变,仅仅替换当前进程的代码和数据的技术,叫做进程的程序替换。
进程替换只有替换物理内存中的代码和数据,进程原本的页表,进程地址空间,PCB等数据结构都没有发生变化。
进程替换会创建新进程吗?
不会,进程替换技术主要是替换物理内存中的代码和数据。
进程替换听起来很玄乎,所以我们需要先看这个技术会造成怎样的现象,再从现象进行步步的剖析。
这里我们开始介绍进程替换函数
(
1
)
(1)
(1)execl
头文件:#include
函数原型:int execl(const char*path,const char *arg,.....)
功能:实现进程的替换,运行一个全新的程序
参数一:要替换程序的路径+程序名
参数二:如何运行这个程序,如
ls-l
(那么参数二就需要传入"ls"
,"-a"
,NULL
)
注:参数二使用了可变参数列表,所以参数二必须以NULL
结尾这样系统才知道参数结束。
注:这里的参数二其实也就是命令行参数
#include
#include
using namespace std;
int main()
{
cout<<"I am a proc"<<endl;
execl("/usr/bin/ls","ls","-l",NULL);
cout<<"Hello execl!"<<endl;
cout<<"Hello execl!"<<endl;
cout<<"Hello execl!"<<endl;
}
一个程序变成进程,在这个过程中,加载器会将程序的代码和数据加载到内存中,加载器的底层原理使用的就是exec*
系列的程序替换函数
注:程序替换的本质就是把程序的代码和数据加载进特定进程的上下文中,这两句话很不好理解,在后面的学习中我们逐渐加深对其的理解。
父进程通过fork创建子进程,父进程和子进程的代码是共享的,但是如果父进程or子进程发送进程替换,不是会影响代码和数据吗?
替换数据很好理解,会发生写时拷贝,事实上在替换代码的时候也会发生写时拷贝。
替换函数的命名规则
l(list) : 采用可变参数列表的形式传递
v(vector) : 参数用数组的方式进传递
p(path) : 系统会根据程序名在path内进行匹配
e(env) : 进程单独维护自己自己的环境变量
(
2
)
(2)
(2)execv
头文件:#include
函数原型:int execv(const char *path,char *const argv[])
参数一:要替换程序的路径+程序名
参数二:将程序的运行方式写入数组中,传递数组的地址。(数组最后需要以NULL结尾)
char *argv[]={"ls","-a","-l",NULL};
execv("/usr/bin/ls","ls","-a","-l");
(
3
)
(3)
(3)execlp
头文件:#include
函数原型:int execlp(const char *file,const char* arg,...)
参数一:只需要传递进程序的名字,系统会自动在
path
里进行路径匹配。
参数二:如何运行这个程序,如ls-l
(那么参数二就需要传入"ls"
,"-a"
,NULL
)
execlp("ls","ls","-a","-l",NULL);
(
4
)
(4)
(4)execvp
头文件:#include
函数原型:int execvp(const char* file,char * const argv[]);
参数一:只需要传递进程序的名字,系统会自动在
path
里进行路径匹配。
参数二:和execl```函数的参数二一样,告诉系统如何运行这个程序
char * argv[]={"ls","-a","-l",NULL};
execvp("ls",argv);
(
5
)
(5)
(5)execle
头文件:#include
函数原型:int execle(const char*path,const char* arg,.....,char* const envp[]);
参数一:要替换程序的路径+程序名
参数三:传递环境变量数组,替换的程序会维护这个数组作为自己的环境变量。
关于这里的参数三,我们通过一串代码进行解释:
生成test
和process
可执行程序,在process
可执行程序中调用execle
替换函数执行test
可执行程序。
test
可执行程序:
作用:打印环境变量
int main()
{
extern char** environ;
for(int i=0;environ[i];i++)
printf("%s\n",environ[i]);
}
process
可执行程序
作用:使用execl
替换函数执行test
可执行程序。
int main()
{
char* env[]={"MYENV=myenvenvenv"};
execle("./test","./testt",NULL,env);
}
(
6
)
(6)
(6)execve
头文件:#include
int execve(const char* path,char* const argv[],char* const envp[])
char * argv[]={"./class",NULL};
char* env[]={"MYENV=myenvenvenv"};
execve("./class",argv,env);
注:替换函数失败会返回1
,成功没有返回值,通过替换函数,还可以调用其他语言的程序
execlp,execl,execle,execvp,execv
都是库函数,本质都是execve
系统调用函数的上层封装。
#include
#include
#include
#include
#include
char command[128];
int main()
{
while(1)
{
char* argv[64]={NULL};(6)
command[0]=0;//(3)
printf("[fengli@iZ8vbhcpwdmnwq6ejjs26zZ ~]$");//(1)
fflush(stdout);//(2)
fgets(command,128,stdin);//(4)
command[strlen(command)-1]=0;//(5)
argv[0]=strtok(command," ");(7)
int i=1;
while(argv[i]=strtok(NULL," "))(8)
{
i++;
}
if(strcmp(argv[0],"cd")==0)//(12)
{
if(argv[1])
chdir(argv[1]);
continue;
}
if(fork()==0)//(9)
{
execvp(argv[0],argv);//(10)
exit(1);
}
waitpid(-1,NULL,0);//(11)
}
}
(
1
)
(1)
(1)
(
2
)
(2)
(2)打印提示符
使用fflush()
进行刷新,不能使用\n
,\n
会进行换行。
(
3
)
(
4
)
(
5
)
(3)(4)(5)
(3)(4)(5)接收命令行输入的命令
设置一个command
数组,用于搭配接收命令行的命令输入。
通过fgets
函数将命令行的命令输入输出到command
数组中。
回车键也属于字符串,在接收命令行命令时也会输出到command
数组中,需要将其去掉。
(
6
)
(
7
)
(
8
)
(6)(7)(8)
(6)(7)(8)分割字符串
command
数组接收的字符串格式为:"ls -a -b"
,需要将其分解成"ls","-a","-b"
的形式保存到argv
数组中。
注:strtok函数用法
(
9
)
(
10
)
(
11
)
(9)(10)(11)
(9)(10)(11)使用替换函数
创建子进程,使用execvp
替换函数,让子进程执行shell
命令解析。
注意:不能让父进程执行替换函数,这样会使得父进程的代码和数据被更改。
这时我们也就能理解:为什么shell
作为媒婆,需要让实习生子进程去替自己办事情,因为子进程死了父进程不会受到影响。
当子进程完成任务以后,父进程进行清理回收工作。
(
12
)
(12)
(12)内建命令
Linux
中有很多内建命令是需要父进程自己执行,如cd,export
,所以对于内建命令需要进行特判。
cd
命令也是一个内建命令,子进程修改自己的路径不会影响到父进程,所以需要特判,让父进程修改自己的路径。
一个进程的资源最终是由操作系统进行回收的,父进程只会读取子进程的退出信息,不会对子进程资源进行回收。