这篇博客主要记录进程创建、进程终止、进程等待、获取子进程状态、进程替换相关知识。
目录
进程创建
fork函数认识
有了上一节的学习,我们来更新一下进程的概念:进程,内核的相关数据结构(task_struct + mm_struct + 页表) + 代码和数据。
linux中,fork函数从已存在的进程(父进程)中创建一个新进程(子进程)。
#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1
进程调用fork时,当控制转移到内核中的fork代码后,内核做:
分配新的内存块和内核数据结构给子进程
将父进程部分数据结构内容拷贝至子进程
添加子进程到系统进程列表当中
fork返回,开始调度器调度
代码是父子进程共享,数据是子进程进行写时拷贝。
fork函数返回值
子进程返回0
父进程返回的是子进程的pid
为什么父进程返回的是子进程的pid,给子进程返回0?
答:为了让父进程方便对子进程进行标识,进而进行管理!
写时拷贝
通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:
fork常规用法
1)一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。
2)一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。
fork调用失败的原因
1)系统中有太多的进程
2)实际用户的进程数超过了限制
进程终止
终止是在做什么
1)释放曾经的代码和数据所占据的空间
2)释放内核数据结构
需要说明的是,在内核数据结构中,有一个是PCB,即task_struct,在进程终止释放内核数据结构时,会被延期处理,因为有一个状态叫Z(僵尸),也就是说,当一个进程释放时,先把对应的代码和数据干掉,反正也不会被调度了,只不过对应的task_struct先要被维护起来。
进程终止的3种情况
我们先来看一段代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("I am a process,pid:%d,ppid:%d\n",getpid(),getppid());
sleep(2);
return 100;
}
在这段代码中,我们最终return 100,那么我们编译并执行这个文件,然后使用:
echo $?
打印出100,echo是一个内建命令,打印的都是bash内部的变量数据,bash内部维护了一个叫?的变量,其含义是父进程bash获取到的最近一个子进程退出的退出码,也就是return返回的值叫做进程的退出码,$?表示的是访问这个变量。那么这个退出码代表什么意思呢?
0:表示成功 !0(非0):表示失败
所以为什么我们在写C/C++代码时,最后一般是return 0呢,因为我们默认我的的代码运行成功。进程的退出码存在的意义是:告诉关心方(一般是父进程),我把任务完成的怎么样了!
如上图,我们再echo $?一次,为什么变成0了呢?注意,$?是最近一个子进程的退出码,那我们最近一次的子进程是上面第一次的echo $?,虽然echo是内建命令,但是它也会被当做一个进程来看待,这就是bash最近一个子进程。
如果进程运行成功,返回0,父进程不在乎,反正已经成功了;但是,如果进程运行失败,父进程想要知道失败的具体原因,原因非常多,非0的数字也非常多,不同的非0值,一方面表示失败,一方面表示失败的原因,那不同的非0值代表什么意思?它们都有对应的错误描述,为了人进行识别,我们一般把错误描述当成string,那么,我们打印一下这些错误对应的字符串:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
int errcode;
for(errcode = 0;errcode<=255;errcode++)
{
printf("%d:%s\n",errcode,strerror(errcode));
}
return 100;
}
那么父进程bash为什么要得到子进程的退出码呢?因为父进程要知道子进程退出的情况(成功,失败,失败的原因),最终是用户要知道这些情况,要为用户负责!比如:
我们随便执行一个命令,bash会报错,告诉用户错误的原因,错误码2对应的错误描述就是No such file or directory。(子进程给父进程bash说错误码,bash根据错误码找到对应的错误描述,显示给用户,直到用户下一步该怎么操作)
退出码可以使用默认,也可以自定义!比如:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
enum
{
Success = 0,
Div_zero,
Mod_zero,
};
int exit_code=Success;
const char* CodeToErrString(int code)
{
switch(code)
{
case Success:
return "Success";
case Div_zero:
return "div zero";
case Mod_zero:
return "mod zero";
default:
return "unknown error!";
}
}
int Div(int x,int y)
{
if(0 == y)
{
exit_code = Div_zero;
return -1;
}
else
{
exit_code = Success;
return x/y;
}
}
int main()
{
int result = Div(10,0);
printf("result:%d [%s]\n",result,CodeToErrString(exit_code));
result = Div(10,100);
printf("result:%d [%s]\n",result,CodeToErrString(exit_code));
return exit_code ;
}
那么,进程终止的3种情况:
a.代码跑完,结果正确
b.代码跑完,结果不正确
结果正确与否,可以通过进程的退出码决定!!
c.代码执行时,出现了异常,提取退出了
vs编程运行时,崩溃了,原因是操作系统发现了你的进程做了不该做的事情,OS杀掉了进程。一旦出现异常,退出码就没有意义了!
c这种情况,比如:
int main()
{
int* p=NULL;
while(1)
{
printf("I am process,pid:%d\n",getpid());
sleep(1);
*p = 1;
}
return 0;
}
会报这样一个段错误,原因是OS识别到野指针,对进程发出了终止信号。
下面是kill的一些指令,上面的报错实际是OS对进程发送11号指令,
我们来实验一下:
我们发现,报出了同样的错误。
我们可以看进程退出的时候,退出信号是多少,就可以判断我的进程为什么异常了!!!这和上面的退出码有异曲同工之妙,可以判断进程因为什么异常退出。
当程序退出时,我们的判断顺序为:
1.先确认是否异常,如果异常,则看退出信号是多少
2.如果不是异常,就一定是代码跑完了,看退出码就行
因此,我们可以得出以下结论:衡量一个进程退出,我们只需要两个数字:退出码和退出信号!
对于这两个数字,一共有4种组合,退出码和退出数字分别是:0和0、非0和0、0和非0、非0和非0,第一种是正常退出,第二种是代码没出异常但结果不对,第三和第四种是进程异常,退出码是多少已经无意义。
由于当前进程是由父进程创建的,因此,当前进程退出时,父进程需要获取子进程退出信息,只需要将这两个数字告知父进程即可。
那当子进程退出时,怎么能让父进程读到子进程退出信息呢?当子进程退出时(僵尸状态),其PCB要维持一段时间(PCB种包含int exit_signal,int exit_code),以便父进程读取,
如何终止?
1.main函数return,表示进程终止(非main函数,return,函数结束)。
2.代码调用exit函数
exit函数的作用是引起正常的程序终止,exit的参数就相当于main函数的return值。注意:在代码的任意位置调用exit,都表示进程退出!
3._exit,这是一个系统调用函数,用于终止调用_exit的进程,其作用和exit几乎完全一样,唯一不同的是,exit在进程退出的时候,冲刷缓冲区,_exit不会。
exit是库函数,而_exit是系统调用,exit内部调用了_exit,所以缓冲区不在内核缓冲区,否则_exit也应该能刷新缓冲区。所以缓冲区只能在_exit之上(系统调用之上),_exit看不到缓冲区。
进程等待
我们先输出一个结论:任何子进程,在退出的情况下,一般要被父进程进行等待,进程在退出的时候,如果父进程不管不顾,就退出进程,就等会导致子进程一直为僵尸状态(Z),会存在内存泄漏的问题。
因此,为什么要进程等待呢?
1.父进程通过等待,解决子进程退出的僵尸问题,回收系统资源(一定要考虑的)
2.获取子进程的退出信息,知道子进程是因为什么原因退出的(可选的功能)
进程等待的方法
wait方法
#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
返回值:成功返回被等待进程pid,失败返回-1
参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL
等待父进程的任何一个子进程退出。 如果子进程没有退出,父进程其实一直在进行阻塞等待!
子进程本身就是软件,父进程的本质就是在等待某种软件条件就绪,如何理解阻塞等待子进程呢?就是把父进程的状态设为S状态,父进程的PCB链入子进程的队列中!此时父进程在等待子进程,一旦子系统退出了,OS调度时,发现子进程退出了,OS就直接把子进程PCB里面的进程队列中的父进程唤醒,wait再返回,继续向后执行,不就把子进程的退出结果拿到了吗!所以,阻塞等待子进程的本质就是把父进程状态设为非运行状态,把对应PCB链入到子进程当中,此时就叫做父进程在阻塞等待。
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:是一个输出型参数,类似int a;scanf("%d",&a)中的a,输出子进程的退出信息。
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。
如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
如果不存在该子进程,则立即出错返回。
获取子进程status
因为子进程退出时,退出情况也就3种,为了能让父进程得知子进程是因为什么退出,因此,我需要的信息是进程退出码+退出信号!
我们来思考一下,有必要有这样一个status的输出型参数吗?定义exit_code和exit_signal两个全局变量,当子进程退出时,不就可以让父进程拿到吗?答案是不可以!当子进程退出时,当然可以设置这两个全局变量,但是父进程根本看不到,因为父子进程具有独立性,当子进程临退出时修改,OS会发生写时拷贝。所以,只能通过输出型参数得到退出信息。
下一个问题,我们想要拿到进程退出码+退出信号两个数,所以不能把status当成一个整数,它有自己特定的格式。具体如下:
status是一个int类型,共32位,只使用其低16位,次8位(退出码)(8-15)表示退出状态(0-255),最低7位(0-6)表示终止信号(退出信号)。
为了方便查看status返回结果所代表的含义,有两个宏:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)
等待子进程退出是必须的,但是获取退出信息不是必须得。
如果子进程没有退出,而父进程在执行waitpid进行等待,其实是在阻塞等待(类似scanf),是进程阻塞,状态从R状态变为非R状态,把PCB从运行队列移动到其他的等待队列中,不被调度就可以了。所以,与其说waitpid在等待子进程退出,倒不如说我们在等待某种条件发生(子进程退出)。
其实,当父进程在上面所说的阻塞等待时,父进程什么都没有干,一直在等待,那么我们想让父进程在等待的时候做一些事情,这就是非阻塞等待。通过设置waitpid的第三个参数options就可以实现,将这个参数设置为WNOHANG就可以。
在非阻塞等待中,WEXITSTATUS(status)的返回值:
pid_t > 0 : 等待成功的,子进程退出了,并且父进程回收成功
pid_t < 0 : 等待失败了。
pid_t == 0 : 检测是成功的。
使用waitpid非阻塞等待时,每次只能等待一次,因此要加上循环:
非阻塞等待 + 循环 = 非阻塞轮询
在非阻塞轮询时,允许父进程做一些其他的事情。
下面是验证非阻塞等待的代码:
#include <stdio.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
void ChildRun()
{
//int* p= NULL;
int cnt=5;
while(cnt--)
{
printf("I am child process,pid:%d,ppid:%d,cnt:%d\n",getpid(),getppid(),cnt);
sleep(1);
//*p = 1;
}
}
int main()
{
printf("I am father process,pid:%d,ppid:%d\n",getpid(),getppid());
pid_t id = fork();
if(id == 0)
{
//child
ChildRun();
printf("child quit...\n");
exit(123);
}
//father
while(1)
{
int status = 0;
pid_t rid = waitpid(id,&status,WNOHANG);// no block
if(rid == 0)
{
printf("child is running.father check next time!\n");
sleep(1);
//doOtherThing();
}
else if(rid > 0)
{
if(WIFEXITED(status))
{
printf("child quit success,child exit code:%d\n",WEXITSTATUS(status));
}
else
{
printf("child quit unnormal!/n");
}
break;
}
else
{
printf("waitpid failed!\n");
break;
}
}
return 0;
}
进程程序替换
替换原理
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。
我们自己的代码编译运行后变成一个进程,OS会为当前进程创建task_struct,创建地址空间,构建页表,建立各种堆、栈、数据段、代码段各种映射关系,当进程开始时,进程本身的代码已经执行起来了,当我们执行execl的时候,把老程序的代码用新程序的代码覆盖掉,把老程序的数据用新程序的数据覆盖掉,所以,进程替换的本质就是将当前进程的代码和数据进行替换的一种技术,在替换的时候,不会创建新进程,站在被替换进程的角度:本质就是这个程序被加载到内存中了!那么是怎么加载的呢?exec*类似于Linux上的加载函数,通过这个函数进行加载。加载的本质其实是把数据从一个设备拷贝到另一个设备,这个过程必须由操作系统来参与,因为操作系统是内存的管理者,由此,我们可以推断出exce*肯定是系统调用或者包含系统调用,exec*系列的函数,执行完毕之后,后续的代码不见了,因为被替换了,execl函数的返回值可以不用关心了,只要替换成功,就不会向后继续运行,反之,只要继续运行了,一定是替换失败了!
下面是进程替换的代码:
#include <stdio.h>
#include <unistd.h>
int main()
{
printf("testexec ... begin\n");
execl("/usr/bin/ls","ls","-l","-a",NULL);
printf("testexec ... end\n");
return 0;
}
将代码改成多进程版
上面是单进程进行替换,会把所有的代码全替换了,所以一般在执行程序替换时,有没有一种可能,我要程序替换,还不影响我的父进程本身,那么我们可以使用fork创建子进程,让子进程自己去替换,父进程等待就可以了,这样既可以完成任务,又可以使父进程不受影响。
创建子进程时,让子进程完成任务:
1.让子进程执行父进程代码的一部分
2.让子进程执行一个全新的程序(程序替换带来的)(为代码的数据都发生写时拷贝)
实现代码如下:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("testexec ... begin\n");
pid_t id = fork();
if(id == 0)
{
sleep(2);
//child
execl("/usr/bin/ls","ls","-l","-a",NULL);
exit(1);
}
//father
int status=0;
pid_t rid = waitpid(id,&status,0);
if(rid > 0)
{
printf("father wait success,child exit code:%d\n",WEXITSTATUS(status));
}
printf("testexec ... end\n");
return 0;
}
使用所有的替换方法,并且认识函数参数的含义
其实有六种以exec开头的函数,统称exec函数:
#include <unistd.h>
第1个:
int execl(const char *path, const char *arg, ...);
这个函数名中的l代表列表,第一个参数path:我们执行的程序,需要带路径(怎么找到程序,你得告诉我),代表的是你想执行谁;剩下的参数:在命令行中怎么执行,你就怎么传参,代表的是你想怎么执行它,比如我们在命令行中执行的是“ls -l -a”,那么就要这样:execl("usr/bin/ls","-l","-a",NULL),当我们把命令传完了,必须以NULL结尾,
第2个:
int execv(const char *path, char *const argv[]);
函数名中的v有点像vector,后面的参数argv表示指针数组,可以把上面所说的“ls”、“-l”、“-a”放到一个数组里,再通过agrv传进来。
char*const argv[]=
{
(char*)"ls",
(char*)"-l",
(char*)"-a",
(char*)"--color",
NULL
};
execv("/usr/bin/ls",argv);
第3个和第4个:
int execvp(const char *file, char *const argv[]);
int execlp(const char *file, const char *arg, ...);
函数名中带p的含义是:用户可以不传要执行的文件路径(但是文件名要传),直接告诉exec*,我要执行谁就行,查找这个程序,系统会自动在环境变量PATH中进行查找。
char*const argv[]=
{
(char*)"ls",
(char*)"-l",
(char*)"-a",
(char*)"--color",
NULL
};
execvp("ls",argv);
第5个和第6个:
int execle(const char *path, const char *arg, ...,char *const envp[]);
int execvpe(const char *file, char *const argv[], char *const envp[]);
函数名中的e是环境变量的意思,最后一个参数envp是要整体替换所有的环境变量。
其实,上面6个函数接口都是在3号手册中,2号手册为系统调用,这就说明上面这6个都不是系统调用,这6个函数其实是在C语言层面做了一个封装,真正的系统调用是execve,(使用的2号手册)
除了替换系统命令外,我们也可以替换自己写的可执行程序,如C++、python、Java和shell脚本等:
替换C++程序:
#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;
int main()
{
cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
return 0;
}
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("testexec ... begin\n");
pid_t id = fork();
if(id == 0)
{
printf("child pid:%d\n",getpid());
sleep(2);
execl("./mypragma","mypragma",NULL);
return 0;
}
从上面的执行结果可以看出,C++程序替换成功,在程序替换时,并没有创建新进程。
在Linux中,所有的脚本语言都有其对应的解释器,解释器本身是用C/C++写的,它们是边解释边读取文件内容、边翻译边运行,编译器就相当于一个可执行程序,
在执行程序时,
python3后面的test.py就是命令行参数,把这个命令行参数传进去就知道要执行哪个文件了,然后一行行执行,
这个shell脚本也是如此,所有的语言写出来的程序,只要在Linux或Windows下跑,都直接间接变成进程,只要是个进程,就能被替换。
下面是使用execvpe中的envp参数替换的例子:
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
printf("testexec ... begin\n");
pid_t id = fork();
if(id == 0)
{
char *const argv[]=
{
(char*) "mypragma",
NULL
};
char *const envp[] =
{
(char*)"HAHA=111111",
(char*)"HEHE=222222",
NULL
};
extern char** environ;
printf("child pid:%d\n",getpid());
sleep(2);
execvpe("./mypragma",argv,environ);//bash的环境变量
//execvpe("./mypragma",argv,envp);//自定义的环境变量
exit(1);
}
return 0;
}
当我们使用execvpe传递环境变量时,可以有三种方式:
1.用全新的环境变量给子进程
2.用老的环境变量给子进程(即environ)
3.用老的环境变量稍微修改,给子进程
对于3,使用putenv函数给老的环境变量添加新的环境变量。在子进程中,putenv("HHHH=1111111111111111"); 即可。
事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册第2节,其它函数在man手册第3节。这些函数之间的关系如下图所示: