1.基本知识:
1). 进程ID:
每一个进程都有唯一的标识符进行表示:操作系统保证在每时刻PID都是唯一的。在缺省情况下,LINUX内核中,内核将进程ID的最大值设置为32768.内核分配进程ID是以线程来递增的,比如主进程的PID为17,这时再创建按一个进程,其PID一定是18,以此类推。但是内核不保证长时间的进程ID的唯一性。
2).进程体系:
创建新进程的那个进程被称为父进程,而新进程被称之为子进程。每个子进程都有一个父进程。
在LINUX系统中:每个进程都属于某个用户的某个组的,每个子进程都继承了父进程的用户和组。
每个进程都是某个进程组的一部分。进程组表示的是该进程和其他进程之间的关系,子进程通常属于父进程所在的那个进程组。
2.函数介绍:
1).获得父进程和进程ID。
#include <unistd.h>
#include <sys/type.h>
pid_t getpid();//返回当前进程的ID;
pid_t getppid();//返回当前进程父进程的ID。
2).fork()函数:
#include <sys/types.h>
#include <unistd.h>
pid_t fork();
当fork()函数调用成功后,会创建一个新的进程,对于子进程结束后,其会返回0。
父进程返回自己的pid_t。
frok()函数调用失败后,会返回-1.
1)).父子进程有如下:
1.子进程的pid与父进程不同,是重新分配的。
2.子进程的ppid会设置为父进程的pid.
3.子进程中的资源统计信息会被清零。
4.所有挂起的信号会被清除,也不会被子进程继承。且所有文件锁也不会被继承。
5.父子进程有各自自己的内存,主要注意共享文件描述符和MMAP创建的映射区,这个注意。
知识补充:在LINUX内核中,开创进程后会"复试"fork()函数之前的内容,为了减小开销,LINUX采用写时复制:即当子进程没有修改“复制”的资源时,每个进程只要保存一个指针指向所有进程的公共资源即可,当某个进程想要修改那一份资源时,就会真正开始复制要修改的资源,并将复制的资源提供给要修改的进程,只有在写的时候才会执行复制操纵(尤其注意全局变量)。写时复制的好处就是可以提升性能,在虚拟内存的情况下,写时复制的基础是以页为基础去执行的。
方便理解:就粗略地认为fork()函数创建的子进程能复制父进程的所有资源(PCB被子进程覆盖)。
2)):
父子进程谁先进行执行不清除,和LINUX中的进程调度算法有关。
for(int i=0;i<5;i++){
pid_t=fork();
}
比如对于一个for循环调用fork()函数。这里要注意,不只是父进程循环5次创建5个进程,比如i=2时,父进程创建了一个子进程,该子进程继承了i=2的资源,下一次循环开始,该子进程也会fork().即创建了孙子进程,如上图例子,一共会创建2^5-1个子进程。
3).exec函数族
此函数被用于子进程,子进程往往要调用exec函数以执行另一个程序,当进程调用一种exec函数时,该进程的用户空间和数据完全被新程序代替(这点十分重要,然后让进程从新的程序第一条命令开始执行调用exec并不会创建新的进程,不会改变进程ID.
注意p和l的区别,file就是文件名。该文件只能在用户工作目录下,path就是绝对路径的文件。
介绍最简单的函数:
#include <unistd.h>
int execl(const char* path,const char*arg,...);
参数一会将path所指向的路径载入内存(可执行二进制文件或者LINUX命令),替换当前进程的程序去执行。
arg是路径指向资源的第一个参数,...表示可变参数列表,可以带多个参数,但是必须要以NULL结尾。
比如用/bin/vi替代当前运行的程序:
ret=execl("bin/vi","vi",NULL);
之所以将第一个参数作为vi是因为,当执行进程时,shell会将路径的最后一个部分,放入新进程的第一个参数
argv[0],解析到argv[0]到argv[0]后,就明白二进制的文件名了,所以一般第一个参数都是路径的最后一个
文件名。
一般情况下,exec函数族不会返回,调用成功后,跳转到新的程序入口点,调用失败则会返回-1.
看下面例子:先创建了kefu.cpp,其二进制可执行文件为p.
然后执行下面代码,结果如下:
include <sys/types.h>
#include <sys/unistd.h>
#include <iostream>
int main(){
std::cout<<"HELLO"<<std::endl;
pid_t a=fork();
if(a>0){
std::cout<<" i am father\n";
}else if(a==0){
//int a=execl("/home/user/p","p",NULL);
int a=execlp("p","p",NULL);
if(a==-1){
perror("execl error");
exit(1);
}
}
std::cout<<"PK"<<std::endl;
}
结果如下:
由此可知,子进程成功打印hello,请再次注意参数问题,如果可执行的二进制文件p,我要打印所有的argv[]中的信息,比如在执行p时,我向argv[]传aa,bb,cc,要打印aa,bb,cc,这时子进程代码中的可变参数列表如下:
int a=execl("/home/user/p","p","aa","bb","cc",NULL);
同时p对应的代码要变为:
for(int i=1;i<=4;i++){
printf("argv[i]\n");
}
4).终止进程:
exit()函数,直接执行exit(1)即可。
5).等待子进程结束。
孤儿进程:父进程先于子进程结束,子进程少了爸爸,这时子进程被称之为孤儿进程。孤儿进程能被回收的原因是,init进程会取代原先的父进程,收养子进程,确保能回收。
僵尸进程:进程终止,但父进程尚未回收(子进程先结束,但是操作系统未回收子进程的PCB给父进程,父进程查询其PCB信息),子进程残留PCB在内核中,变为僵尸进程(就是结束未被回收。),每个进程都会经历僵尸态。
僵尸进程不能用kill进程删除,解决僵尸进程的方法是杀死其父进程,使其变为孤儿进程,然后被init进程收养,或者调用wait()函数。
wait():父进程调用wait()函数可以回收子进程的终止信息,此函数有三个功能:
1.阻塞等待子进程退出。
2.回收子进程残留资源(残留资源清空)。
3.获得子进程退出原因。
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int* status);
调用成功返回子进程PID,失败返回-1.
status为传输参数,即调用完毕将子进程消息存储在status中
对于参数status,可以使用如下六个宏函数进行查看:
注意上述六个函数,一类是判断怎样结束,一类是获取子进程结束后的部分信息,,WIFEXIED是判断子进程是否正常结束。WIFSIGNALED是判断是否异常终止的。
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
int main(){
int status;
pid_t pid,get;
pid=fork();
if(pid<0){
perror("fork()");
}else if(pid==0){
printf("chlid start\n");
sleep(10);
printf("chlid die\n");
return 77;
}else{
get=wait(&status);//wait(NULL) 这种情况下,不关心返回结果,直接进行回收,但是
//一次wait(),waitpid()调用只能回收一个子进程,这个注意
if(WIFEXITED(status)){//子进程正常退出
printf("chlid exit with%d\n",WEXITSTATUS(status));//77
}
if(WIFSIGNALED(status)){
printf("chlid kill with signal %d\n",WTERMSIG(status));
}
printf("father die \n");
}
return 0;
}
WEXITSTATUS(status)。可以查看子进程return的数或者exit()返回的数。这点注意。
等待特定进程结束:调用函数waitpid()
#include <sys/wait.h>
#include <sys/types.h>
pid_t waitpid(pid_t pid,int* status,int options);
此函数作用同wait,可指定pid进行清理,可以不阻塞。
参数返回:
>0:表示成功回收的子进程的pid
0:函数调用使,参数三指定WNOHANG,并且子进程还没有结束。
-1:回收失败。
参数三可以是多个指示命令按照或运算进行
常用是 WNOHANG,不要阻塞等待,如果等待的子进程还没有结束,函数立即返回。
参数一:
pid=0,等待同一个进程组中的任何进程,
pid=-1,等待任何一个子进程退出,行为和wait()一致。
pid>0,等待进程pid=pid的子进程。
pid<-1:等待进程组=pid的绝对值的任意一个子进程
waitpid(-1,&status,0)=wait(&status),阻塞等待任意一个,
waitpid(pid,NULL,0),//阻塞等待指定子线程结束。
如下面代码:
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <sys/wait.h>
int main(){
int i=0;
pid_t pid,wpid;
for(;i<5;i++){
if(fork()==0){//子进程的单独执行区
if(i==2){//2号子进程
pid=getpid();
}
break; //子进程不fork(),即创建了的子进程直接跳出for循环
}
}
if(i==5){
sleep(5);
//wpid=waitpid(-1,NULL,WNOHANG);回收任意子进程,没有结束的子进程,父进程直接返回0
wpid=waitpid(pid,NULL,WNOHANG);//回收i=2的子进程
if(wpid==-1){
perror("waitpid error");
_exit(1);
}
printf("i am father ,wait a child finish:%d\n",wpid);
}else{
sleep(i);
printf("i am %dchild:\n",i+1);
}
return 0;
}
上述代码有个很严重的问题,就是在二号线程调用getpid(),获得二号线程的PID存储在pid变量中,但是这是子进程的pid变量,FORK()函数的写时复制,读时共享,存在子进程的pid变量与父进程变量的PID无关。子进程结束,自己复制的所有资源被回收,所以调用waitpid函数中的pid就是父进程的变量,但父进程没有储存到子进程PID,所以要想办法使得子进程调用的getpid()函数能复制给父进程的pid变量。
#include <unistd.h>
#include <sys/types.h>
#include <stdio.h>
#include <sys/wait.h>
int main(){
int i=0;
pid_t pid,wpid,tmpid;
for(;i<5;i++){
pid=fork();
if(pid==0){
break;
}//子进程跳出循环
if(i==2){
tmpid=pid;//pid有两种 返回0是子进程 返回>0是父进程,这时pid是子进程的pid,父进程从而储存,写时复制,读时共享,注意
printf("----------pid=%d\n",tmpid);
}
}
if(i==5){
printf("i am father ,before waitpid :%d\n",tmpid);
wpid=waitpid(tmpid,NULL,0);//指定一个进程回收,阻塞,
if(wpid==-1){
perror("waitpid error");
_exit(1);
}
printf("i am father ,wait a child finish:%d\n",wpid);
}else{
sleep(i);
printf("i am %dchild:\n",i+1);
}
return 0;
}
注意上面的情况,还有就是主进程循环创建子进程时,不让子进程接着循环fork(),可以在pid==0处判断内进行break,这样子进程会执行循环后面的代码这一点也很重要。.......
由于wait和waitpid函数一次调用只能回收一个子进程,所以想让父进程完全回收所有创建的子进程,只能用while循环搭配waitpid函数进行。
6).进程之间的通信:
虽然调用fork()函数后,子进程会“复制”原来父进程的所有资源并且父子进程的地址空间相互独立,有时我们需要连接父子进程,双方进行通信,所以就有了一些进程通信的方法,主要的方法有:1.利用管道(最简单),2.利用信号(开销最小),3.共享映射区(无血缘关系),4.使用本地套接字(最稳定)。我们接下来只讲述管道。
管道的实现原理:内核借助环形队列的机制,使用内核缓冲区进行缓冲实现(调用pipe()系统调用创建管道)。
特点:其是伪文件,本质是内核缓冲区,接着是管道的数据只能读取一次。数据在管道中,只能单向流动,是半全工通信(双全工通信可以调用socketpair()函数进行创建)。
缺点:自己写,不能自己读,并且数据只能读取一次(这个要注意,一般BUFFER中数据是可以重复读取的,这里只能读取一次是因为采用了环形队列)。且是血缘关系进程间可用,其他无血缘关系的进程是不可用的。
#include <iostream>
#include <sys/types.h>
#include <unistd.h>
#include <string.h>
#include <stdlib.h>
int main(){
int ret;
int fd[2];
pid_t mm;
ret=pipe(fd);
if(ret==-1){
perror("pipe error");
}
char buf[1024];
char* str="hello son\n";
mm=fork();
if(mm>0){
close(fd[0]);
write(fd[1],str,strlen(str));
sleep(1);
close(fd[1]);
}else if(mm=0){
close(fd[1]);
ret=read(fd[0],buf,sizeof(buf));
write(STDOUT_FILENO,buf,ret);
close(fd[0]);
}
}
如上:文件描述符被复制,但是管道资源是被共享的。
对于半全工的pipe函数 一般是父进程写,关闭读端,子进程去读,关闭写端这样的操作,但对于双全攻则无所谓,甚至可用设置非阻塞进行使用。
7).会话和进程组,守护进程。
每个进程都属于某个进程组,一个进程组可以由多个或者一个进程组成。进程组的主要特征为:信号可以发送给进程组中的所有进程:单个操作可以使得进程组的所有进程终止,停止,或者继续活动。
每个进程组都有一个唯一的进程组表示(pgid),且都有一个“进程组的首进程”。进程组ID就是进程组首进程的PID,只要进程组还存在一个进程,那么进程组会存在,不论首进程是否死亡。
会话:当有新的用户登录计算机时,登录进程会为这个用户创建一个新的会话,这个会话只包含单个进程,用户登录的shell.
守护进程;其是Linux中后台的服务进程,通常独立于控制终端且周期性地执行某些任务或者等待处理某些事件的发生。Linux后台的一些系统服务进程,没有控制终端,不能直接和用户交互,不受用户影响。一直在运行着,它们都是守护进程。
如何创建守护进程?一般是调用setsid函数创建一个新的会话,并成为Session leader, 下面是如下步骤:如下六个步骤,主要牢记守护进程是在后台运行且不关联其他进程和文件描述符。
#include <sys/types.h>
#include <unistd.h>
#include <stdlib.h>
#include <iostream>
#include <sys/stat.h>
#include <fcntl.h>
#include <linux/fs.h>
int main(){
pid_t pid;
int ret;
pid=fork();
if(pid>0)
exit(0);//父进程退出
pid=setsid();//创建新的会话
if(pid==-1){
std::cout<<"setpid() error"<<std::endl;
}
ret=chdir("/");//改变工作目录位置为根目录
for(int i=0;i<=2;i++){
close(i);
}
//关闭所有打开的工作文件描述符
ret=open("/dev/null",O_RDWR);//因为open()函数是返回可用的最小文件描述符,由于关闭了0,所以0可用了,此时ret重定向为0 这里是stdin
dup2(ret,STDOUT_FILENO);//重定向stdout
dup2(ret,STDERR_FILENO);//stderror
/*重定向文件描述符0 1 2到/dev/null*/
while(1){
/*模拟守护进程做事*/
}
}
上面就是创建守护进程,守护进程创建后使用kill命令杀死。
补充一点,父进程调用wait/waitpid函数的位置会影响整个程序的运行顺序。,如下例子
#include <sys/types.h>
#include <stdlib.h>
#include <unistd.h>
#include <wait.h>
#include <iostream>
int main ()
{
pid_t pid;
pid=fork();
if(pid>0){
wait(NULL);//阻塞 子进程结束返回 父进程才开始进行
std::cout<<getpid()<<" i am father"<<std::endl;
//wait(NULL);
}else{
std::cout<<getpid()<<" i am child"<<std::endl;
}
std::cout<<"over"<<std::endl;
}
上面的运行结构是不同位置执行的wait()函数。所以,如果想用父子进程执行不同的程序模块,一定要确保其中的程序模块没有太大的顺序牵连(逻辑顺序)。或者在父进程执行开始处直接先启用wait()函数,等待子进程结束回收后,再进行父进程。具体可以参考前面文章的多进程下的服务端模块。