我是靠谱客的博主 背后秀发,最近开发中收集的这篇文章主要介绍[入门篇]世界上把操作系统进程控制讲的最全面的博客,30K字匠心制作(进程创建+销毁+等待+程序替换)0.前言1.进程的创建(再补充)2.进程的退出/终止 3.进程等待4.进程程序替换,觉得挺不错的,现在分享给大家,希望可以做个参考。

概述

目录

0.前言

1.进程的创建(再补充)

1.1基础知识回忆

1.1.1进程创建的过程

1.1.2PC指针讲解

1.1.3不完全映射的进程进程地址空间

1.2 写时拷贝如何触发

 1.3子进程创建类比例子

1.4 fork创建子进程失败

2.进程的退出/终止

2.1 进程退出要做什么

2.2 *进程退出的三大情况

2.3 创建子进程及进程退出的意义

2.4 退出码是什么

2.4.1 现实故事类比

2.4.2 C语言中的错误码及对应错误原因

 2.4.3 return 0的意义

2.4.4 失去意义的退出码--第三种情况

 2.5进程退出的第一种方式

2.6*进程退出的第二种方式

2.6.1 exit(int status)函数

2.6.2 _exit与exit函数

2.7进程退出总结

 3.进程等待

3.1为什么要进行进程等待&&什么是进程等待

3.1.1子进程退出的两大使命

3.1.2子进程与父进程退出的先后顺序

3.1.3 进程等待的意义

3.1.4 易错点补充

3.2 如何做到进程等待-wait接口

 3.3 如何做到进程等待-waitpid接口-初识

3.3.1 waitpid函数简介

3.3.2 waitpid函数简单实战

3.3.3等待失败的场景

3.4 wait/waitpid获取退出信息

3.4.1 初步获取输出型参数status

 3.4.2 status的内部结构(引入)

3.4.3 解析status内部16位

 3.4.4 解析status内部16位(前两种情况)

  3.4.5 解析status内部16位(第三种情况)

  3.4.6 实战解析status退出信息

  3.4.7 实战解析status退出信息(宏使用)

3.5 系统层面看wait/waitpid

3.6 waitpid的第三个参数-阻塞/非阻塞等待

3.6.1 初识第三个参数&&阻塞等待/非阻塞等待

3.6.2 生动例子说明阻塞/非阻塞等待

3.6.3 系统中阻塞/非阻塞等待实质

3.7 如何实现非阻塞等待(轮询方案)

3.7.1 预备知识补充

3.7.2 代码实现

4.进程程序替换

4.1 进程程序替换的目的

4.2 进程替换的原理

4.2.1简单预备知识

4.2.2具体讲解进程替换的原理

4.3 实现进程替换-exec*接口

4.3.1 exec*接口所需参数简述

4.3.2 简单示例1(程序替换父进程)

4.3.3* 具体应用2(程序替换子进程)

 4.4 exec*返回值----进程程序替换失败

 4.5 exec*接口的区别

4.5.1 l和v----命令行执行传参方式的区别

4.5.2 p----环境变量PATH自动搜索路径(方便)

4.5.3 e----自定义替换后进程的环境变量


0.前言

本篇主要讲解Linux操作系统当中对于进程控制的操作,同时也是Linux进程篇的第六篇主题博客,也是最后一篇博客,阅读本篇博客可以让你感受到完全控制进程的快乐。本文所有代码以及资料已经上传至gitee,君可自取:

practice12 · onlookerzy123456qwq/Linuxtwo - 码云 - 开源中国 (gitee.com)https://gitee.com/onlookerzy123456qwq/linuxtwo/tree/master/practice12进程控制主要包括对进程的创建和中止,进程的等待和替换。这四个概念与四种对进程的操作。而我们在之前的博客中已经学习了对进程的创建等有了比较清晰的认识,下面是博客链接:(4条消息) [入门篇]Linux操作系统fork子进程的创建以及进程的状态 超超超详解!!!我不允许有人错过!!!_yuyulovespicy的博客-CSDN博客_fork创建多个进程结果分析https://blog.csdn.net/qq_63992711/article/details/127574290在此基础上,我们开启控制进程之旅:

1.进程的创建(再补充)

1.1基础知识回忆

1.1.1进程创建的过程

Linux创建进程有两种方式:1.命令行上,路径+文件名创建  2.进程内部fork创建子进程

fork系统调用接口在创建子进程的时候,本质上是在系统中多了一个进程,而什么是进程呢,进程不止是加载到内存的代码和数据,还有相关的数据结构。所以OS一定会给子进程
(以父进程为模板),创建进程PCB task_struct,进程地址空间(mm_struct),页表,以及构建映射关系
。所以进程的创建并不是简单把代码和数据加载到内存。而是OS为了管理进程必须维护相关的数据结构。

所以从今天开始,对于创建出的一个个进程,我们必画 PCB,必画进程地址空间,必画页表,必画物理内存。

1.1.2PC指针讲解

进程的PCB,在Linux中我们叫task_struct中有一个pc指针,这个pc指针指向了当前进程执行到的代码地址位置,通俗说进程是从pc指针这里知道我进程执行到哪句代码了。

而我们知道,子进程的相关数据结构,是以父进程为模板创建的(不能说是极其相似,也可以看做是一模一样了)。所以事实上,子进程task_struct中的pc指针的值和父进程task_struct中pc指针的值是相同的。换句话说,子进程和父进程下一句执行的代码,是一样的子进程也会继承父进程执行到的代码的位置。7

同时我们也要记住,子进程一开始是共享父进程的代码区和数据区的,所以虽然子进程是从代码区fork()创建出来这一行才开始执行,但是子进程是可以看到完整的代码区的,即使最上面这些代码子进程没有执行。

#include<stdio.h>    
#include<unistd.h>    
int main()    
{    
  //before child created    
  printf("I'm father process,PID:%d,code address:%pn",getpid(),main);    
    
  if(fork()==0){//create the child     
    //child    
    printf("I'm child process,PID:%d,code address:%pn",getpid(),main);    
  }    
  else{    
    //parent    
    printf("Welcome,my child,I'm your father.n");                                                                                   
  }                                                                                                                             
    //child&&parent                                                                                                             
    printf("Byebye,my dear!n");                                                                                                
  return 0;                                                                                                                     
} 

也就是说,对于子进程来说代码区都是透明的,而和父进程执行的代码区都是一样的,但是父子进程的数据区的不同,可以使得父子进程执行这同一代码区不同区域段的代码

如子进程的pc指针初始指向if(fork()==0)这一行,而fork函数对子进程返回0,所以子进程在执行代码的时候,执行的是分支语句中的printf("I'm child process,PID:%d,code address:%pn",getpid(),main); ,以及后面的printf("Byebye,my dear!n"); 

1.1.3不完全映射的进程进程地址空间

我们知道一个进程地址空间就有4GB,1GB就有1024MB*1024KB*1024Byte==2^30Byte,所以4GB就是4*2^30==2^32个字节,而页表需要对每一个字节的虚拟地址+物理地址建立映射,以及还有对每个字节规定权限,所以页表每对一个字节建立映射我们假设就需要在页表上用10字节(4字节虚拟地址+4字节物理地址+2字节其他信息)去记录,所以对这2^32个字节需要2^32个*10字节 = 40GB,我们光光只设计一个进程的页表,就需要40GB的空间。 这内存肯定是盛不下的。

所以页表本身并不是要全部加载到内存,把有需要的部分进行存储构建就可以。这个后面博客中再说。

1.2 写时拷贝如何触发

发生写时拷贝,我们已经知道会发生什么,那具体写时拷贝是如何触发的呢?

答:在页表中,把代码和数据的部分设计成只读的!!!

 如图所示,如果无论是父进程,还是子进程无论是对这块共享的数据区,还是对共享的代码区中的任意一个字节发生写入的时候在页表中,这些字节只有r权限),作为内存的管理者和页表的维护者,操作系统OS就会知道这件事,此时就会发生缺页中断

说人话就是,当OS检测到你要对这个只读r的区域(父子共享的 数据区/代码区)进行写入的时候,此时就会发生中断,OS会暂停你(父进程/子进程)要写入的动作,然后重新开辟空间,把你老的这一块进行写入前的数据区的空间进行拷贝,此时他们就有一个分别的数据区了。此时数据区不再共享,两个数据区的空间就不再是只读r的了,对于父子进程都是rw权限的。

这个期间父进程/子进程什么都不知道,因为他们要写入共享区的时候发生了中断,只是感觉时间暂停了一会,这一切还是由OS操作系统在为我们的进程默默的做好一切。父子进程运行时的独立性的保证,就是OS所做的写时拷贝。

 1.3子进程创建类比例子

讲完写时拷贝句中间过程,我们再讲两个问题

想到我们之前开鞋厂的那个例子,一个是父子的样貌相似,一个是子承父业,可以做类似的事情,父子的样貌上是这个就是说PCB等这些数据结构的创建。子进程是一定会以父进程的(task_struct,mm_struct等)为模板创建的,而有自己的样貌。

而以后儿子做的产业:可以是继承父亲的同一个鞋厂,也可以是自己新开一个鞋厂,共享的数据区和代码区就是以后共用同一个鞋厂,而数据区/代码区的写时拷贝就是开一个新的鞋厂。

1.4 fork创建子进程失败

fork创建子进程失败的场景是存在的,那为什么会失败呢?

fork失败的原因:

1.系统中有过多的进程,系统资源十分紧张。

2.实际用户创建进程的个数超过了限制。

创建进程是有成本的!这个成本体现在 时间+空间。创建一个进程,除了加载到内存进程的代码和数据,OS要管理这个进程,还需要创建 PCB,mm_struct进程地址空间,以及页表。所以进程的创建是需要占用相应的系统资源

而如果此时有一个非法的用户,用户恶意的创建过多的进程,会使得系统资源被占用严重,所以其实每个用户所能创建的进程个数是有限制的。我们让zy用户一直fork创建子进程,在创建子进程一会之后fork就会失败,因为zy用户创建进程的个数超过了限制。

#include<stdio.h>                                                                                                                    
#include<unistd.h>   
#include<stdlib.h>    
int main()    
{    
  printf("I'm father process!n");    
  while(1)    
  {    
    pid_t id = fork();    
    if(id==0){    
      //child created success    
      sleep(10);//保留这个创建的子进程10s。    
    }    
    else if(id>0){    
      //father    
      usleep(10000);       
    }    
    else{    
      //father create child failed!    
      printf("fork error,return val<0n");  
      sleep(11);
      exit(1);  
    }                                                                                                                             
  }                                                                                                                               
  return 0;                                                                                                                       
}  

2.进程的退出/终止

2.1 进程退出要做什么

创建进程就是给导入进程的代码和数据,给进程创建各钟数据结构。创建的时候创建了什么,进程最终退出/终止的时候,就要释放什么。进程退出肯定也要释放掉进程的代码和数据,肯定要释放进程的页表,进程地址空间,PCB等数据结构。

2.2 *进程退出的三大情况

 从应用层上说,进程终止,退出的场景,有大概三种情况:

 1.代码跑完,结果正确。

 2.代码跑完,结果错误。

 3.代码异常,进程崩溃。

不要低估这三种情况,之后进程退出,进程等待都要围绕这三种情况展开。

2.3 创建子进程及进程退出的意义

为什么要创建一个子进程,本质目的是为了完成父进程交给子进程的任务。子进程跑的一行行代码其实就是在完成某项任务。可是,子进程执行任务就有成功/失败两种可能,失败的原因也是多样的,子进程的退出不能只是单纯的释放自己的资源,还必须对父进程反馈退出信息,告诉父进程最终自己任务完成的怎么样。所以我们知道进程在退出的时候,会首先保存自己的退出信息到PCB中,首先进入Z僵尸状态,等待自己的退出信息被父进程获取回收,才会真正X死亡。

那子进程是以什么方式向父进程反馈自己最终完成任务的结果呢?父进程是如何获取到子进程把任务完成的怎么样呢?

第二个问题我们在进程等待小节中会得到解决。第一个问题子进程反馈自己执行任务的完成结果,子进程是把任务成功完成了,还是把任务完成失败了,还是子进程直接半路崩溃了,其实就是在向父进程反馈如下三种情况,子进程隶属于这三种情况的哪种情况

 1.代码跑完,结果正确。 2.代码跑完,结果错误。3.代码异常,进程崩溃。

事实上,子进程是通过退出码作为标识,来向父进程反映自己完成任务的结果。

PS:更准确来说,退出码只能是子进程代码跑完,向父进程反馈自己是结果正确还是结果错误,并不能反映第三种情况,第三种情况我们会在进程等待小节中学习获取。

2.4 退出码是什么

每个退出码代表了一种代码跑完结果错误的具体原因,比如退出码1代表这个进程执行结果错误是由于XXX原因导致的退出码2代表进程执行结果错误是由于YYY原因导致的(当然除0之外,0代表结果正确)。通过退出码这一个数字,我们就可以对之翻译,获取到其执行结果错误的原因。即退出码(数字)==具体原因说明(字符串)

2.4.1 现实故事类比

首先举个很生动的例子:

你去考试,然后你告诉你爹,你考完了,你和你爹说:“我考了一百分”,这就是进程执行完成成功了!然后你爹肯定不会问你为什么考了一百分,而是回去带你庆祝吃饭等事情。

但是如果你考了0分,你告诉你爹,:“我考0分”,你爹一定会问你:“为什么只考了0分”这个一定会问你理由。而此时你会说出各种理由,例如你生病了,不舒服或者是什么原因。这其实就是进程执行失败,要返回一个特定非0的退出码,来表明你的失败是因为什么特定的原因。

比如 1代表你昨天晚上没睡好觉 2代表你没有好好学习 3代表考试的时候拉肚子 4代表.…..

其中你去考试,你就是去执行某个任务的进程你爹就代表父进程/系统你爹需要知道你考的怎么样以及如果考砸了的原因,也就是获取子进程执行结果错误的原因错误原因是使用退出码这个编号的原因来标识。

2.4.2 C语言中的错误码及对应错误原因

退出码(数字)《=》具体执行失败原因说明(字符串),通过退出码就可以标识进程执行结果的正确或错误原因。在C语言中我们就有这样一套对应关系:

#include<stdio.h>    
#include<string.h>    
int main()    
{    
  for(int i = 0;i<140;++i)    
  {    
    printf("errornum[%d]:%sn",i,strerror(i));    
  }    
  return 0;    
}         

 2.4.3 return 0的意义

在C语言中,写函数最后都有一个返回值,通过子函数可以return给main函数/子函数,比如int Add函数就是给main函数返回加的结果数值。那main函数的返回值又是给谁的呢?我们平常写的main函数最后为什么是return 0;而不是return别的数呢?

这个return的0,其实就是这个进程的退出码,翻译出来就是0->success,其实就是代表了进程退出的第一种情况:代码跑完,执行正确。proc3这个子进程在向其父进程反馈自己是执行正确。

main函数通过return可以向父进程反馈自己的执行结果,return的退出码大小代表不同的执行结果及原因。

所以子进程proc3是向父进程返回了自己的退出码。在命令行上执行的进程,其父进程其实就是bash,bash可以通过echo $?指令来获取到自己最近一次执行的进程的退出码。

 echo $?是获取打印最近一次命令行解释器执行的进程的退出码。我们需要知道指令,例如ls,pwd,echo等,其实都是磁盘上的一个个可执行文件,在命令行上敲击执行后,最终都会变成了系统中的一个个进程

2.4.4 失去意义的退出码--第三种情况

再次回到你爸问你考试成绩及原因这个场景,我们知道退出码0代表了你考了100分,其他数值的退出码代表了你考砸了的各种原因。但是以上只能类比代表进程退出的前两种情况(代码跑完,执行正确/执行错误),其实我们还能类比出第三种情况(代码异常,进程崩溃),在第三种情况下其实退出码也失去了意义,因为代码根本没有跑完,这个错误码也不是子进程根据自己的代码逻辑正常返回给父进程的。

先用现实情况来类比进程退出的第三种情况--此时进程码失去意义:

这天,你去数学考试的时候,在考试过程中,你拿出了藏在夹在衣服里面的手机,使用小猿搜题,可就在此时你的行为迅速被无处不在的监控摄像头&&监考老师看到了!你居然作弊!然后被监考老师抓住了,此时你的成绩分数100分还是0分,其实也就没有任何意义,你的失败理由没有任何意义解释的理由都很苍白无力!!!

事实上,代码异常,进程崩溃,这第三种情况是由于OS对这个进程发送信号,而使这个执行非法代码的进程崩溃的。此时进程的退出码就失去意义了,因为此时代码并没有跑完,子进程返回自己是失败的原因,这其实是失去意义了,因为子进程根本就没有跑完代码就自己崩溃了。我们会在进程等待小节中具体讲解,这里我们还是主要讲解跑完代码,进程正常退出,返回0/非0退出码的这前两种情况。

 2.5进程退出的第一种方式

进程退出第一种方式就是从一开始我们就一直使用的main函数return。main函数中执行到return语句就可以使该进程退出,return具体的值就是这个进程对父进程返回的退出码。具体可以参考2.4.3return 0;的意义这一小节。

2.6*进程退出的第二种方式

2.6.1 exit(int status)函数

之前我们是通过main函数return的方式结束退出这个进程,那如果我们不想在main函数中,而是想在main函数调用的子函数中实现进程退出,这时候如果在子函数中return,其实结束的是子函数这个函数栈帧,并不会退出整个进程,所以此时我们不用return这个方法,而使用exit(int)接口来实现进程的退出。

 值得注意的是:exit可以在任意位置(main函数任意该进程调用的子函数)中使用,只要执行到了exit(int status)函数,该进程代码就会跑完,即进程就会退出,进程退出码为status。所以我们可以说exit函数其实是对main函数return法的超级加强版

#include<stdio.h>      
#include<stdlib.h>      
char* PrintStr(char* str)      
{      
  if(str==NULL){ 
    //直接退出进程,进程退出码为1     
    exit(1);      
  }      
  printf("%sn",str);      
  return str;    
}    
int main()    
{    
  char* pstr = NULL;    
  PrintStr(pstr);    
  return 0;    
}      

 exit最典型的特点是:在任意地方调用,代表了这个进程的退出,对exit所传参数是退出码。

2.6.2 _exit与exit函数

exit和_exit极其相似,使用方法,参数意义都是相同的,在实战中可以相互替代。

#include<unistd.h>:_exit        vs        #include<stdlib.h>:exit 

但是exit和_exit还是有微小区别的, 我们通过下面的实践进行引出:

实验1:main函数return终止进程

 实验2:exit函数终止进程

实验3:_exit终止进程

为什么_exit会如此奇怪呢?_exit样式的退出,为什么不会刷新C语言缓冲区呢?

你是否发现exit和_exit所在头文件的不同,exit是C语言的头文件stdlib里面的函数,而_exit是系统头文件unistd里面的。我们C语言中printf的实质是把内存刷新到C语言级别的缓冲区,只有遇到n才会把C语言缓冲器的内容刷新到系统缓冲区。

所以_exit作为系统级别的函数是顾不到更上层的C语言级别的缓冲区的,而exit作为C语言级别的函数是有机会事实上也把C语言级别的缓冲区刷新到系统缓冲区中。所以我们看到_exit退出进程没有打印C语言缓冲区的hello world,而exit在进程退出的时候是把C语言缓冲区的hello world刷新到系统缓冲区的,所以在最后进程退出的时候才会将系统缓冲区的内容刷新到屏幕上。用一个图说明是这样的:

是否在进程退出的时候把C语言缓冲区的内容刷新到系统缓冲区最终进程退出刷新到屏幕上,是exit和_exit的一个微小的区别。实战中没必要这么讲究,exit和_exit是可以相互替代使用的。

2.7进程退出总结

 3.进程等待

3.1为什么要进行进程等待&&什么是进程等待

3.1.1子进程退出的两大使命

子进程在退出的时候,有两个十分重要的使命需要完成。

第一使命:创建子进程目的是为了完成父进程的任务,而子进程退出的时候,子进程当然需要告诉父进程自己完成任务的情况,父进程需要获取子进程的退出信息。换句话说,父进程需要知道子进程是三种进程退出情况的哪一种。【退出信息角度】

第二使命:子进程在退出的时候,并不会立即进入死亡X状态,而是会首先进入僵尸Z状态。换句话说, 进程要退出,并不会立刻释放掉所有的资源,而是会保留这个进程的退出信息到PCB当中,然后供父进程/系统去读取。而只要子进程的退出信息没有被父进程/系统读取,就会一直处于僵尸状态,僵尸资源就会一直占有系统资源,而回收资源这件事需要由父进程来做。【回收资源角度】

3.1.2子进程与父进程退出的先后顺序

我们其实是非常不希望看到一件事:父进程先于子进程退出。

从【退出信息角度】出发,进程如果正常跑完代码,也就是在前两种进程退出情况下,这个退出信息其实就是子进程的退出码一个退出码对应一种执行错误的原因(除0,0代表执行结果正确)。而退出信息在大多数情况下是需要被父进程知晓的,因为父进程一定需要知道子进程把他交给子进程的任务完成的怎么样,如果父进程不关心子进程完成的情况,不关心子进程完成任务的失败与否,那创建这个子进程的意义又何在呢。父进程需要获取子进程退出信息。

如果父进程先于子进程退出,那子进程还没有退出,子进程的退出信息就没法交到创建他的父进程手中,从【退出信息角度】我们不希望父进程先于子进程退出。

从【回收资源角度】出发,一个进程退出一定是首先进入僵尸状态的,如果一直处于僵尸状态的话,僵尸资源就会一直占用系统资源如果系统中存在大量的僵尸资源,很容易造成系统的卡顿僵尸资源只有在父进程退出的时候(或者是采用今天讲的进程等待)才会对处于Z状态的子进程的僵尸资源进行回收僵尸资源的回收需要由父进程来做,当然啦,如果父进程先于子进程退出,此时子进程会被1号进程操作系统领养,操作系统成为新父进程,最终由操作系统回收其僵尸资源。僵尸资源是一定会被回收的。

但是我们总不能一直让操作系统去回收僵尸资源吧,这个任务本就应该有创建你的父进程来做。从【回收资源角度】我们不希望父进程先于子进程退出。

所以我们需要父进程的退出晚于子进程退出,以读取子进程的退出信息以及回收子进程的僵尸资源。如何做到这一点呢?我们使用 进程等待 来完成这两重使命。

3.1.3 进程等待的意义

进程等待通常是由父进程wait/waitpid,此时父进程就会进入等待状态等到子进程的代码执行完毕,子进程退出后父进程会获取子进程的退出信息以及回收所等子进程的僵尸资源此时父进程才会再退出/继续向下执行代码。

所以我们可以吧父进程等待的意义总结为如下三点:

1.可以保证时序问题,让子进程先退出,然后父进程再退出。

2.通过进程等待获取子进程退出信息,进而能够得知子进程的执行结果。

3.进程退出的时候会首先进入僵尸状态,会造成内存泄漏的问题,就需要通过父进程wiat/waitpid来释放该子进程的僵尸资源

3.1.4 易错点补充

如果一个进程变成了僵尸,这个进程确实是死掉了,这个进程只是在希望自己的退出信息被接收and僵尸资源的回收。然后僵尸状态是一个进程退出时必须要经历的状态经历过僵尸状态然后才会真正死亡R->Z->X , kill -9所做的事情是让这个正在运行/睡眠进程立刻退出退出就要首先Z状态,所以kill -9是可以使得进程退出R->Z,使进程进入僵尸状态,直到等到其退出信息&&僵尸资源回收才X死亡状态

所以kill -9无法杀死一个僵尸状态的进程,即kill -9你无法杀死一个已经死亡的进程,僵尸进程只能是被回收掉退出信息/僵尸资源才会X死亡,这是僵尸进程的诉求!,不然会一直处于僵尸状态被读取退出被回收我才会真正死亡!!!!!

3.2 如何做到进程等待-wait接口

在Linux操作系统中,我们要让父进程去等待子进程的退出回收僵尸资源并获取退出信息,我们通常是让父进程使用wait接口或waitpid接口。下面我们用功能较为简单的wait为例先实现一个简单的进程等待场景。

 wait接口的作用是:让调用wait函数的父进程进行等待等待任意一个子进程退出等待成功之后,可以获取子进程的退出信息以及回收子进程的僵尸资源

wait接口的返回值等待成功则返回等到的那个子进程的PID等待失败返回-1

wait接口所传参数传入的int* pstatus其实就是获取到的子进程的退出信息

对于status退出信息的获取,我们在后面再讲,我们现在暂时填NULL不获取子进程的退出信息

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  
  if(id==0)
  {
    //below child code 
    int life_span = 6;//子进程的只会存活约6s(打印6次)就会退出->Z
    while(life_span)
    {
      --life_span;
      printf("I'm child process,My PID:%d,My PPID:%dn",getpid(),getppid());
      sleep(1);
    }
    exit(2); //子进程退出,退出码为2  
  }

    //below parent code
    /*父进程等待*/
    pid_t ret = wait(NULL);
    if(ret>0){
      printf("I'm father process,I wait child (PID:%d) exit success!n",ret);
    }
    else{
      printf("I'm father process,I wait child failed!n");
    } 
    return 0;
}

 这是父进程进行进程等待的结果,如果父进程不等待子进程就先退出的话,那此时子进程就会成为孤儿进程。如下演示:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  
  if(id==0)
  {
    //below child code 
    int life_span = 6;//子进程的只会存活约6s(打印6次)就会退出->Z
    while(life_span)
    {
      --life_span;
      printf("I'm child process,My PID:%d,My PPID:%dn",getpid(),getppid());
      sleep(1);
    }
    exit(2); //子进程退出,退出码为2  
  }

    //below parent code
    /*父进程等待*/
    /*pid_t ret = wait(NULL);*/
    //父进程直接先于子进程退出
    printf("I'm father process,I exit straightly!n");
    return 0;
}

 3.3 如何做到进程等待-waitpid接口-初识

3.3.1 waitpid函数简介

waitpid接口的功能相较于wait来说,更加丰富,我们此后主要是使用waitpid接口来进行对子进程的等待。

waitpid的功能调用waitpid的父进程可以等待特定的或者是任意的一个子进程(pid_t pid),同时获取等待到的子进程的退出信息&&回收等待到的子进程的僵尸资源(int* status),waitpid还能够选择父进程自己等待的方式(int options)

这三个功能分别由三个参数所体现。

waitpid的返回值:和wait接口一样,等待成功则返回等到的那个子进程的PID等待失败返回-1

waitpid的第一个参数:pid_t pid,该参数如果填的是具体父进程要等待的子进程的PID,填入子进程的PID,则父进程此次waitpid等待就是这个特定子进程退出。同时第一个参数如果填-1的话,就与wait接口一样,等待任意一个子进程退出即可。

waitpid的第二个参数:int* pstatus,和wait接口所需填的参数是一样的,该参数可以获取到子进程的退出信息。如果不想获取子进程的退出信息,填写NULL即可。

waitpid的第三个参数:int options,可以选择父进程等待子进程的方式,options==0:阻塞等待,options==1:非阻塞等待。

3.3.2 waitpid函数简单实战

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  
  if(id==0)
  {
    //below child code 
    int life_span = 6;//子进程的只会存活约6s(打印6次)就会退出->Z
    while(life_span)
    {
      --life_span;
      printf("I'm child process,My PID:%d,My PPID:%dn",getpid(),getppid());
      sleep(1);
    }
    exit(2); //子进程退出,退出码为2  
  }

    //below parent code
    /*父进程等待*/
    pid_t ret = waitpid(-1,NULL,0);//waitpid(-1,NULL,0)<==>wait(NULL),等待任意一个子进程
    //pid_t ret = waitpid(id,NULL,0);//等待Pid为id的子进程
    if(ret>0){
      printf("I'm father process,I wait child (PID:%d) exit success!n",ret);
    }
    else{
      printf("I'm father process,I wait child failed!n");
    } 
    return 0;
}

3.3.3等待失败的场景

父进程等待子进程也是有失败的场景,比如等待一个根本不存在的子进程例如填错了要等子进程的PID, 这时候会等待失败waitpid返回的就是-1

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  
  if(id==0)
  {
    //below child code 
    int life_span = 6;//子进程的只会存活约6s(打印6次)就会退出->Z
    while(life_span)
    {
      --life_span;
      printf("I'm child process,My PID:%d,My PPID:%dn",getpid(),getppid());
      sleep(1);
    }
    exit(2); //子进程退出,退出码为2  
  }

    //below parent code
    /*父进程等待*/
    pid_t ret = waitpid(id+1,NULL,0);//等待Pid为id的子进程 
    //不小心填错了要等待的子进程PID,等待一个不存在的进程,等待失败,waitpid返回-1。
    if(ret>0){
      printf("I'm father process,I wait child (PID:%d) exit success!n",ret);
    }
    else{
      printf("I'm father process,I wait child failed!waitpid returnval:%dn",ret);
    } 
    return 0;
}

3.4 wait/waitpid获取退出信息

3.4.1 初步获取输出型参数status

wait接口的唯一一个参数int* pstatus,以及waitpid接口的第二个参数int* pstatus,他们的作用都是一样的,都是获取所等待子进程的退出信息

int* pstatus,首先这是一个输出型参数,也就是说,我们自己在wait/waipid之外定义的int status变量在wait/waitpid中传入&status之后,父进程wait/waitpid等待成功之后status的值就会在waitpid函数体内部被改变此时status所蕴含的,其实就是所等待子进程的退出信息。

具体进程等待接口获取子进程的退出信息,我们看如下代码:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  
  if(id==0)
  {
    //below child code 
    int life_span = 6;//子进程的只会存活约6s(打印6次)就会退出->Z
    while(life_span)
    {
      --life_span;
      printf("I'm child process,My PID:%d,My PPID:%dn",getpid(),getppid());
      sleep(1);
    }
    exit(2); //子进程退出,退出码为2  
  }

    //below parent code
    /*父进程等待*/
    int status = 0;//32位的status作为承接等待子进程的退出信息的参数。
    pid_t ret = waitpid(id,&status,0);//等待Pid为id的子进程 
    //status作为输出型参数,调用完waitpid后,现在status的32位存储的就是子进程的退出信息。
    if(ret>0){
      printf("I'm father process,I wait child (PID:%d) exit success!n",ret);
      //我们现在只是验证status确实是有值,反映了子进程的退出信息。
      //退出信息不止一个,而是有结构的分布在status的32位中的。
      //后续需要仔细剖析status的32位数据,才能真正的获取到有效的退出信息。
      printf("Father Get Child Exit Information:%dn",status); 
    }
    else{
      printf("I'm father process,I wait child failed!waitpid returnval:%dn",ret);
    } 
    return 0;
}

 3.4.2 status的内部结构(引入)

在我们传入&status之后,在调用waitpid/wait之后,输出型参数status的32位就存储了子进程的退出信息。那status的内部是如何划分存储子进程的退出信息的呢?

  首先我们回忆一下,子进程的退出是有三类情况的,输出型参数status一定能代表这三种情况。或者说,所谓子进程的退出信息,其实就是这三种情况之一。status在进程代码正常跑完,执行正确肯定是一种赋值;status在进程代码正常跑完,执行错误肯定是另一种结构;而进程代码异常,进程崩溃时(OS对进程发生信号),status则会是不同于前面两种情况的一种赋值获取status,实际上是在获取退出信息,实际上也是获取这三类情况,获取这三类退出情况的具体情况Status如何来表明这三种情况呢?这就要了解status的构成。

PS:退出码作为前两种进程退出情况中子进程对父进程反馈的执行正确/错误原因主要退出信息,一定会作为一种退出信息被填入到status当中

3.4.3 解析status内部16位

首先我们要树立一个观念,status是int类型的,是有32个bit位,但是现阶段,我们只需要看status的低16个bit位就可以获取表达这三类退出情况。所以我们后续的讨论针对的就只是status的低16位。

 

 3.4.4 解析status内部16位(前两种情况)

退出码作为前两种进程退出情况中(代码跑完,执行正确/执行错误)子进程对父进程反馈执行正确/错误原因主要退出信息,一定会作为一种退出信息被填入到status当中。事实上,此时status的低16位的次高8位(8~15)存储的正是退出码的值。用这次高8位来表明进程的正常退出后是 结果正确还是结果不正确,也即这次高8位所存储的是退出码的大小。而此时低8位(0~7)则赋值为全0。

  3.4.5 解析status内部16位(第三种情况)

第三种情况(代码异常,进程崩溃):进程是异常终止,本质是这个进程因为异常问题,导致自己收到了某种信号!举个例子,当进程越界错误,除0错误的时候,进程的崩溃即异常退出,其实就是进程收到了OS发送的某种信号。也就是说我们可以通过进程是否收到了信号,来判断这个进程是否是第三种情况,即是否是异常终止的。

所以此时status的低16位当中,次低7位存储的就是终止信号,也即使子进程异常退出所收到的几号信号。PS:第8位core dump标志位我们在后面的博客中会具体讲解。

  3.4.6 实战解析status退出信息

在开启实战之前需要说明一件事:那就是我们使用status来窥探三种退出情况,我们肯定首先是要看这个子进程是否收到了OS发生的异常终止信号,先排除第三种情况,再看前两种情况。因为进程收到信号异常终止,此时退出码是没有意义的,执行结果正确/执行结果错误原因,我们不需要关注,因为压根没有执行完就崩溃了,所以我们不能直接无脑取出退出码(次高8位),而是应该先看是否收到信号(次低七位)如果次低七位非0,那就是说明该子进程是因为收到信号而异常退出的这种情况。如果次低七位全0,那就是说明该子进程是正常退出的,返回退出码这种情况。

实战解析父进程等待获取解析status,即子进程退出信息的代码如下:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<string.h>
int main()
{
  pid_t id = fork();
  
  if(id==0)
  {
    //below child code 
    int life_span = 6;//子进程的只会存活约6s(打印6次)就会退出->Z
    while(life_span)
    {
      --life_span;
      printf("I'm child process,My PID:%d,My PPID:%dn",getpid(),getppid());
      sleep(1);
    }
    //exit(0); //子进程正常退出,退出码为0(代表执行结果正确)
    exit(2); //子进程正常退出,退出码为2(代表执行结果错误及特定原因说明)
    exit(5); //子进程正常退出,退出码为5(代表执行结果错误及特定原因说明)
  }

    //below parent code
    /*父进程等待*/
    int status = 0;
    pid_t ret = waitpid(id,&status,0);//等待Pid为id的子进程 
    //status作为输出型参数,调用完waitpid后,现在status的32位存储的就是子进程的退出信息。
    if(ret>0)
    {
      printf("I'm father process,I wait child (PID:%d) exit success!n",ret);
      int exit_code = (status>>8)&(0xFF); //提取次高8位
      int signal_val = (status)&(0x7F);   //提取次低7位
      printf("The child process signal_val:%d,exit_code:%dn",signal_val,exit_code);
      if(signal_val!=0)
      {
        //次低七位非0,子进程收到过信号,异常退出,退出码失去意义
        printf("The child process once receive signal[%d],Exit Abnormally!n",signal_val);
      }
      else{
        //次低七位全0,没有收到过信号,正常退出,获取退出码-执行正确/错误原因
        printf("The child process exit normally,Exit_Code[%d],Exit_Reason[%s]n",exit_code,strerror(exit_code));
      }
    }
    else
    {
      printf("I'm father process,I wait child failed!waitpid returnval:%dn",ret);
    } 
    return 0;
}

子进程退出情况一:正常退出,结果正确

子进程退出情况二:正常退出,结果错误

第三种情况:子进程异常终止,进程崩溃(进程收到了OS发送的信号)

第三种情况:子进程异常终止,进程崩溃(子进程运行非法代码,比如发生除0错误/非法越界访问这些代码,此时进程就会收到OS发送的信号,异常终止)

我们首先稍微修改一下代码,让子进程在第四次执行的时候执行非法代码,从而会收到OS信号,异常终止退出。

 

  3.4.7 实战解析status退出信息(宏使用)

我们获取子进程的退出信息是对status的位细致剖析得到的,但是我们能不能不要每次都(status>>8)&(0xFF),(status)&(0x7F),这样手动的移位来获取退出码和信号状态了。所以系统中我们有两个接口可以直接判断/提取出具体的信号和退出码信息

我们无非是想了解两个问题,信号状态【是否正常退出】和 退出码状态【退出码大小】,解决这两个问题,我们可以配套使用WIFEXITED(status)和WEXITSTATUS(status)两个接口来判断+提取。

WIFEXITED(int status)若等待到的子进程的退出方式是跑完代码,正常退出,没有收到信号,则返回真(非零)。否则,若子进程是因为收到信号,异常终止退出的,则返回假(零)。
WEXITSTATUS(int status)返回提取出该等待到的子进程的退出码(PS:当且仅当子进程是正常退出时退出码才有意义,也就是说WIFEXITED(status)为真时,WEXITSTATUS(status)才有意义)

使用这两个接口,所以此时可以替换成如下代码:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<string.h>
int main()
{
  pid_t id = fork();
  
  if(id==0)
  {
    //below child code 
    int life_span = 6;//子进程的只会存活约6s(打印6次)就会退出->Z
    while(life_span)
    {
      --life_span;
      printf("I'm child process,My PID:%d,My PPID:%dn",getpid(),getppid());
      sleep(1);
    }
    exit(0); //子进程正常退出,退出码为0(代表执行结果正确)
    //exit(2); //子进程正常退出,退出码为2(代表执行结果错误及特定原因说明)
    //exit(5); //子进程正常退出,退出码为5(代表执行结果错误及特定原因说明)
  }

    //below parent code
    /*父进程等待*/
    int status = 0;
    pid_t ret = waitpid(id,&status,0);//等待Pid为id的子进程 
    //status作为输出型参数,调用完waitpid后,现在status的32位存储的就是子进程的退出信息。
    if(ret>0)
    {
      printf("I'm father process,I wait child (PID:%d) exit success!n",ret);
      if(!WIFEXITED(status))
      {
        //子进程收到过信号,异常退出,退出码失去意义
        printf("The child process once receive signal,Exit Abnormally!n");
      }
      else{
        //没有收到过信号,正常退出,获取退出码-执行正确/错误原因
        int exit_code = WEXITSTATUS(status);
        printf("The child process exit normally,Exit_Code[%d],Exit_Reason[%s]n",exit_code,strerror(exit_code));
      }
    }
    else
    {
      printf("I'm father process,I wait child failed!waitpid returnval:%dn",ret);
    } 
    return 0;
}

3.5 系统层面看wait/waitpid

 waitpid是一个系统调用接口(System call),是Linux系统kernel对上,也就是对用户层应用层提供的接口。

子进程不管什么原因,子进程退出了,会首先进入僵尸状态,即会保存子进程的退出数据到PCB当中,在系统中保留僵尸资源。同时退出码(exit_code)&&收到信号数据(signal),当然也是子进程重要的退出数据,当然也在子进程退出时保存到了的task_struct当中。直到子进程的退出信息被父进程读取以及僵尸资源被父进程回收,子进程才会真正X死亡,这之前子进程都是Z僵尸状态,因为这就是僵尸进程的特性与诉求的体现。

1.status的*地址被用户写入到waitpid接口当中,然后作为参数被传入到父进程的内部,其实也是&status从用户层传入到了操作系统内部kernel的父进程当中

2.父进程等待到了子进程退出了,waitpid成功了,然后父进程获取到了这个子进程的退出信息(僵尸子进程PCB中的退出信息),也就是子进程PCB里的包括退出码,退出信号大小信息都在里面了。

3.后*status |=  (exit_code<<8);    *status |= signal; 就可以使status包含子进程退出信息。

//对全0的值,使用 | 逻辑或运算,可以进行赋值。

//对于非全0的值,使用 & 逻辑与运算,可以进行提取 用0过滤,用1提取位

3.6 waitpid的第三个参数-阻塞/非阻塞等待

3.6.1 初识第三个参数&&阻塞等待/非阻塞等待

pid_t waitpid(pid_t pid, int *status, int options);

一般情况下int options第三个参数,我们设置为0。那为什么要默认赋值成0呢?int options == 0时,此时父进程对子进程的等待方式就是阻塞等待。那什么是阻塞等待呢?

所谓阻塞等待,就是父进程就一直在等,此时父进程什么都不干,就干等,这就是阻塞等待

子进程不退出,我父进程还是会一直在等你这个进程。父进程一直卡在waitpid这句代码而函数不返回

除了阻塞等待,还有一种等待的方式,叫非阻塞等待,此时int options设置为WNOHANG设置成这个宏值,就是采用非阻塞等待的方式等待这个子进程的退出。

非阻塞等待过程中,父进程并不是在一直干等,而是父进程可以间歇性的去做别的事情。即父进程不会卡在waitpid这句代码waitpid会快速返回,父进程可以暂时去执行别的代码

3.6.2 生动例子说明阻塞/非阻塞等待

后天就要考试了,马原考试,呜呜呜,我什么都没复习,我甚至连考什么都不知道。不过我有一个同学叫张三,张三住在18楼405宿舍,张三是我的一个很好的朋友~事情紧急,我就走到了张三的宿舍楼下,在下面喊话: 张三好兄弟,能不能现在下来一起去图书馆学习,给我划划重点,我马原要寄了!张三在楼上听到了,他说:”我才拿到外卖,得干完饭,能不能在楼底下等我十五分钟呢?”此时你必须得在楼下等,因为你很需要张三。可是此时有一个问题,你说十五分钟就十五分钟?又没个准信?万一等一个小时?所以你就要打电话监督张三。

此时你在楼下有两种等待张三的方式。[其实也是两种打电话的方式]

WaitingMode_Op_one:

在张三说:我刚刚拿到外卖,要干饭 ,能不能在楼底下等我十五分钟呢?的时候,你就说:“可以,我在下面等你,但是你必须一直不准挂电话,当你好的时候,你在电话里和我说一声,此时我就知道你要下来了。你别挂电话,然后等到你正儿八经下来了,你再挂电话,然后你再下来。我会一直守在电话旁边,一直用眼盯着电话,什么都不干。一直打着电话,你不挂电话,我也不挂电话对方不完成,你也不返回,这个过程,我就会一直被电话束缚,一直盯着电话。这种叫阻塞这种等待的方式叫做阻塞等待

WaitingMode_Op_two:

在张三说:我刚刚拿到外卖,要干饭 ,能不能在楼底下等我十五分钟呢?的时候,你就说:“可以,我在下面等你,但是每过两分钟我就给你摇一个电话,询问你好没好。”而这两分钟之内,考虑到四六级马上就来了,我完全可以背两个单词你在楼下,不断的给他打电话,从而达到一个轮询检测目的。

其中,在这个场景下进行类比,我就是父进程,张三就是子进程,张三吃完外卖下楼&&在电话中告诉我就是子进程退出&&父进程等待成功,打一次电话其实就是调用一次waitpid函数两种等待方式主要区别在于打电话方式的不同。

阻塞等待,打电话方式是只打一次电话,且直到张三下来之前,这一次电话就会一直不挂我也就会一直盯着电话被电话束缚着即waitpid函数就一直不返回,父进程一直卡在waitpid这一句进行等待

与之相对的,非阻塞等待,其打电话方式是每几分钟向张三打一次电话询问到张三没好就挂掉过几分钟再打再问,而这几分钟我可以先背单词干点自己的事情。即父进程的waitpid函数会快速返回,反馈成功与否,如果暂时没有等到,父进程就会先去执行别的代码,过一会再继续调用快速检查并返回的waitpid函数。完成整个等待的过程,可能需要多次检测,即调用多次waitpid,这种检测叫做:基于非阻塞等待的轮询方案。

3.6.3 系统中阻塞/非阻塞等待实质

阻塞等待下,父进程一直被卡在waitpid这句代码,一直在等待子进程的退出,此时我们说父进程是一直处于S等待状态的(因为根本没有执行任何代码)。而采用非阻塞等待的轮询方案下,非阻塞等待waitpid是可以快速检测返回让父进程在等待过程中也可以去做自己的事情,即运行其他的代码,所以此时父进程在等待过程中是可以处于运行R状态的。 

而从系统内部的角度出发,进程处于S状态,其实就是系统当中,进程的PCB(task_struct)一直处于系统中的等待队列当中,从而不会被CPU调度,也就无法被执行了。所以事实上,父进程采用阻塞等待的方式等待子进程的退出,(直观体现是父进程卡在waitpid这句代码,子进程在退出之前,waitpid也一直不会返回)本质上,就是父进程的PCB被CPU一直放在了等待队列当中,一直处于S状态,无法被CPU调度执行。

相应的非阻塞等待下的轮询检测方案,首先父进程调用waitpid,此时父进程一定在CPU上运行,即处于运行队列(R状态)的。而调用waitpid生效后,也就是父进程等待的过程此时父进程是处于S状态(即从运行队列调度到等待队列中)。可是非阻塞等待,waitpid可以快速的返回(结束本次waitpid等待),所以此时父进程就可以去暂时执行其他的代码也就可以快速从等待队列切换回运行队列当中,等执行完这些代码,就会再度waitpid进行快速检测,即由运行队列进入到,waitpid检测完毕就会立即返回,继续运行(S->R)

基于非阻塞等待的轮询方案,本质就是其实就是父进程不断调用waitpid的过程,使得进程从运行队列到等待队列,每次调用waitpid快速返回又从等待队列到运行队列循环往复,直到子进程退出,检测waitpid等待成功。

3.7 如何实现非阻塞等待(轮询方案)

3.7.1 预备知识补充

如何理解WNOHANG:我们知道要进行父进程的非阻塞等待,在操作上就要把waitpid的第三个参数int options设置为WNOHANG。从字面的意义上进行理解,W的意思是wait等待的意思,NO是不要的意思,HANG这个单词是卡住的意思,例如我们平时看到某些应用(进程)或者OS本身,卡住了长时间不运行了,我们也可以叫这个进程应用hang住了,其实就是卡住了不运行了。连起来W NO HANG,意思就是父进程在等待的过程中不要卡住等待过程不要不运行。非阻塞等待我们也可以看到每一次调用waitpid轮询检测之后,进程是可以到运行队列去运行的。

接下来我们从代码实践的角度实现基于非阻塞等待的轮询方案。首先我们需要看待使用非阻塞方案waitpid函数的传参以及返回,会和平常我们阻塞等待的waitpid的使用有较大的区别

之前我们学习到的调用一次waitpid结束返回:当返回-1的时候,表明等待失败,其主要体现在等待一个根本不存在的子进程。当返回正数时,其实就是等待子进程退出成功,返回的也是等待成功子进程的PID。 在阻塞等待中,我们只需要调用一次waitpid,同时也只需使用如上两个返回值即可。

    int status = 0;
    pid_t ret = waitpid(id,&status,0);//等待Pid为id的子进程 
    if(ret>0) //等待成功
    {
      printf("I'm father process,I wait child (PID:%d) exit success!n",ret);
      if(!WIFEXITED(status))
      {
        printf("The child process once receive signal,Exit Abnormally!n");
      }
    else if(ret<0) //等待失败
    {
        int exit_code = WEXITSTATUS(status);
        printf("The child process exit normally,Exit_Code[%d],Exit_Reason[%s]n",exit_code,strerror(exit_code));
    }

而在非阻塞等待当中,waitpid是可以快速检测并返回的,轮询检测的方案就要求我们多次调用waitpid,此时我们就需要waitpid的第三个返回值,也就是非阻塞等待的waitpid可以返回0值waitpid返回0值就意味着此时我每隔两分钟给张三打电话了解到张三还没有下楼,子进程还在运行没有退出,后续我还需要继续轮询检测,继续下一次waitpid直到waitpid返回正值表示等待子进程退出成功。

waitpid的返回值
返回<0 (-1)等待失败:等待一个不存在的子进程
返回>0 (子进程PID)_

等待成功:子进程此时退出,父进程等待到了这个子进程

返回0

继续等待:子进程还未退出,但是此次等待确认了这个子进程还未退出,表明需要父进程重复等待 ->基于轮询的非阻塞等待方式:

非阻塞最大的意义是把等待过程中父进程的精力腾出来了!

3.7.2 代码实现

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
#include<string.h>
int main()
{
  pid_t id = fork();
  
  if(id==0)
  {
    //below child code 
    int life_span = 6;//子进程的只会存活约6s(打印6次)就会退出->Z
    while(life_span)
    {
      --life_span;
      printf("I'm child process,My PID:%d,My PPID:%dn",getpid(),getppid());
      sleep(1);
    }
    exit(0); //子进程正常退出,退出码为0(代表执行结果正确)
  }
    //below parent code
    /*父进程基于非阻塞等待的轮询检测方案*/
    int status = 0;
    while(1)
    {
      pid_t ret = waitpid(id,&status,WNOHANG);
      if(ret==0)
      {
        //子进程还未退出,可先执行自己代码,后续继续轮询检测
        printf("Father wait no hang,Child've not exit,Father can do his own thingsn");
        printf("Father remember one English word!n");
        sleep(1);
      }
      else if(ret>0)
      {
         //子进程退出,父进程等待成功
        printf("I'm father process,I wait child (PID:%d) exit success!n",ret);  
        if(!WIFEXITED(status))
        {
          //子进程收到过信号,异常退出,退出码失去意义
          printf("The child process once receive signal,Exit Abnormally!n");                                       
        }                                                                                                           
        else{
          //没有收到过信号,正常退出,获取退出码-执行正确/错误原因
          int exit_code = WEXITSTATUS(status);
          printf("The child process exit normally,Exit_Code[%d],Exit_Reason[%s]n",exit_code,strerror(exit_code));
        }
        break;
      }
      else //if(ret<0)
      {         
        //父进程等待失败,很可能是等待了一个根本不存在的子进程
        printf("I'm father process,I wait child failed!waitpid returnval:%dn",ret);
        perror("waitpid error");
        break;
      }
    }
    return 0;
}

4.进程程序替换

4.1 进程程序替换的目的

首先需要明白,创建子进程的目的就是父进程让子进程去执行一些不同的任务而子进程是和父进程共享同一片代码区的。我们在这同一代码区下,利用fork对父子进程的不同返回值,使用if else分流,从而执行不同的逻辑。

但是此时他们共享使用的还是同一段代码区,只不过是if else分流执行了不同的代码逻辑,其实就是执行了这个父进程代码区的一部分

那如果我想让子进程去执行一个“全新的程序”呢?也就是说我现在不想让子进程执行共享代码区的一部分,而是让子进程执行全新的代码区,使用全新的数据区,使得子进程此时就像在执行一个全新的程序一样。就比如说,我现在磁盘中有一个可执行程序,我能不能让一个进程去执行这个磁盘中的可执行程序呢?那此时我们就要依靠进程的程序替换了。

执行进程替换主要针对的对象是fork创建的子进程。

4.2 进程替换的原理

4.2.1简单预备知识

1.程序的本质是什么?答:程序的本质是一个文件。程序是存储在磁盘上的一个文件,被加载到内存之后成为进程。

2.那程序文件的内主要存储着什么呢?答:程序代码+程序数据

3.程序要执行成为一个进程,就需要首先加载到内存当中,程序如何被加载到物理内存当中的呢?

答:其实,所谓的加载就是把程序文件的代码和数据拷贝到物理内存当中,然后创建的进程对这块加载的代码进行执行。而加载的方式是通过加载器加载

4.如何简单理解exec*系列接口?

答:exec*系列的程序替换函数,同时也是系统调用接口,可以对调用它的进程进行程序替换,而exec*系列接口的内部逻辑,其实就是在依托加载器实现的!

//这里的exec系列函数接口其实充当的就是加载器

//这个exec承担的任务就是把磁盘中的代码和数据加载到物理内存中

4.2.2具体讲解进程替换的原理

我们首先回忆一下父进程fork创建出子进程之后,在系统中父子进程的样子。父进程子进程共享同一片数据区和代码区子进程使用的是父进程的数据区,执行的也是父进程的代码区。

下面是父进程/子进程对共享的数据区发生写入时,由于共享的数据区的字节内容都只有r权限,所以此时OS会检测到,此时会发生缺页中断,拷贝数据区,使得父子进程各自独占一份数据区,以维护父进程和子进程的独立性。

 而进程替换是让子进程去执行新的程序,也就是使用新的数据区执行新的代码区使子进程像是在执行一个全新的程序。整个过程大致是这样的:系统中有一个父进程,现在父进程fork出一个子进程,父进程和子进程共同映射同一个代码区和数据区。此时子进程调用系统调用接口发生进程程序替换,此时磁盘上对应可执行文件的数据和代码就会被加载器载入到物理内存当中,同时改变子进程页表的映射关系,子进程映射使用的数据区和代码区,变成这个新程序载入的新数据区和代码区

此时我们可以发现,进程PCB,进程地址空间,页表这三个东西是不用变的,我们相当于在背后做了一个“偷天换日”,程序替换之后,让子进程后续再去被CPU执行的时候,会发现自己要执行的代码就变成了新的代码

无形之中替换更改程序所使用的代码区和数据区,进程不变仅仅替换当前进程的代码和数据的技术,叫做,进程的程序替换

相当于我们用一个老的进程的壳子执行了一个新的代码,使用了一段新的数据。这其实是有一种瞒着进程,OS在其底层做了一层“偷天换日”,偷偷改变了进程的数据区和代码区。这点其实我们在理解进程地址空间的第二个作用,软件层分割的时候也有体现。

进程在程序替换的时候,没有创建新的进程!

4.3 实现进程替换-exec*接口

如何让子进程去执行新代码区和使用新的数据区,也即如何把新程序的代码和数据加载到内存,让进程改变映射关系,使用新的代码和数据呢?在实操层面,我们是通过使用exec*接口来做到进程的程序替换。当我们使用exec*接口的时候,就可以使得调用该接口的进程,做到程序替换使用新的代码区和数据区,也就可以让这个进程看起来是在执行新加载到内存的新程序一样

4.3.1 exec*接口所需参数简述

现在我们知道exec*接口就是进行进程的程序替换的系统调用,要进行程序替换,我们需要什么呢(exec*接口的传参需要解决什么问题呢)

第一件事,那当然是需要知道,要替换成的新程序存储在磁盘的什么路径位置,只有告诉了文件的具体位置,OS才能找到该程序,并把该程序的代码和数据载入到物理内存中。

第二件事,我们知道程序的运行是可以带参数的,带不同的命令行参数,不同的程序可以运行出不同的效果,如可执行程序ls,我们在命令行上,ls执行和ls -a执行和ls -l -a执行出的结果是不同的,./mrpoc执行和./myproc -a -b的执行逻辑也有可能是不同的,所以需要知道如何在命令行上执行这个可执行程序(直观来说就是告诉你main函数传参的char* argv[]长什么样)。

第一件事其实是解决程序路径问题,这其实是exec*接口第一个参数所要解决的问题。

第二件事是解决程序在命令行上如何运行的问题,这其实是exec*接口第一个后面的参数需要解决的问题。

例如execl("/usr/bin/ls","ls","-a","-l",NULL);第一个参数/usr/bin/ls表明可执行程序的位置,第一个参数后面的ls -n -l代表命令行如何执行该可执行程序

接下来我们在实践中练习使用程序替换接口:

4.3.2 简单示例1(程序替换父进程)

#include<stdio.h>
#include<unistd.h>
int main()
{
  printf("Hello 1n");
  printf("Hello 2n");
  printf("Hello 3n");
  
  //调用exec*系列接口,对该进程进行程序替换
  //使得该进程的代码区和数据区执行的是
  //磁盘上ls这个可执行文件的代码(和数据)
  execl("/usr/bin/ls","ls","-n","-l",NULL);

  printf("Hello 4n");
  printf("Hello 5n");
  printf("Hello 6n");
  return 0;
}

观察现象,我们看到一开始myproc进程执行自己的代码,打印出"Hello 1","Hello 2","Hello 3",之后发生进程的程序替换指令ls作为一个磁盘中的一个可执行程序(文件),我们进程myproc后续执行ls程序的代码,打印出当前路径下的文件信息,这些都没问题。

但是为什么myproc程序的后三行代码printf("Hello 4"),printf("Hello 5"),printf("Hello 6")却没有被执行呢?这就要我们回到进程替换的原理上,进程的程序替换,实际上是进程已经抛弃了原来的代码区和数据区。

从第一个图到第二个图,不难发现,当子进程进行程序替换(调用了exec*接口)之后,其实子进程就不再也永远不会再去执行原来和父进程共享的那块代码区了(使用的数据区也不再会是原来的那个了),当子进程把新程序的代码执行完毕,子进程其实就退出了,当然原来旧代码区剩余没有执行的代码,此时与我毫无关联,我当然也不会再去执行它了。

4.3.3* 具体应用2(程序替换子进程)

使用fork,对子进程进行程序替换:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  if(id==0)
  {
    //child
    execl("/usr/bin/ls","ls","-n","-l",NULL);
    //子进程执行程序替换后,下面代码区不再执行。
    printf("hello world!n");
    exit(0);
  }
    //parent
  waitpid(id,NULL,0);
  printf("I'm father process,I wait child exit success!n");
  sleep(1);
  return 0;
}

 4.4 exec*返回值----进程程序替换失败

我们此时考虑一个问题,exec*接口在调用成功之后,就会去执行新的代码区和使用新的数据区,那此时exec返回什么值其实都不重要了,因为我们压根不会再使用原来的数据区和执行原来的代码了,所以在exec*调用成功的时候,exec*函数的返回值是没有任何意义的。所以我们查看man手册,看到exec*在调用成功时亦是not return不返回的!

但是为什么exec*要有返回值呢?

答:exec*是有失败可能的,比如进行程序替换的存储位置写错了,比如,此时exec*的返回值就有意义了,因为程序替换失败进程就还得继续使用自己现在的数据区和代码区,也就是说继续执行原来的代码,此时我们就可以获取到exec*的返回值,同时我们查看man手册看到exec*调用失败的时候,返回-1并且全局错误码errno会被设定

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  if(id==0)
  {
    //child
    printf("child start process replace!n");

    //调用exec*,对子进程进行程序替换。
    //传入错误的路径位置,找不到该可执行文件,替换失败。
    int ret = execl("/usr/bin/xswl/llss","ls","-n","-l",NULL);
    
    //子进程执行程序替换后,下面代码区不再执行,如果执行了,那就是替换失败。
    printf("exec* error,exec* return %d,global errno set.n",ret);
    perror("exec*");
    exit(1);
  }
    //parent
  waitpid(id,NULL,0);
  printf("I'm father process,I wait child exit success!n");
  sleep(1);
  return 0;
}

 4.5 exec*接口的区别

exec*接口,我们看到有很多的种类,这其实是为了方便我们使用,而设计出这么多接口的,每个接口都是同一个作用----进行进程的程序替换,他们也是同宗同源的,区分他们其实并不难,exec*系列接口只需要我们了解每一个字母后缀(l v p e)的意思,就可以很快的掌握使用。

4.5.1 l和v----命令行执行传参方式的区别

execl和execv,更具体的说字母后缀 l和v 是互斥的(即不能同时出现)。无论是execl,还是execv,都是针对程序在命令行如何运行这个问题上,在exec*上两种传参形式。说人话就是,你怎么传参v代表一种传参形式l代表一种传参形式决定的是如何在命令行上执行这个可执行程序,也就是说 l和v 决定的是第一个参数后面的参数 怎么传。

我们用两个例子说明l和v的使用:

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  if(id==0)
  {
    //child
    printf("child start process replace!n");

    //以list形式传参(第一个参数后面的参数)
    execl("/usr/bin/ls","ls","-n","-l",NULL);
    
    //以vector形式传参(第一个参数后面的参数)
    char* argv[] = {"ls","-n","-l",NULL};
    execv("/usr/bin/ls",argv);

    //子进程执行程序替换后,下面代码区不再执行,如果执行了,那就是替换失败。
    printf("exec* errorn");
    exit(1);
  }
    //parent
  waitpid(id,NULL,0);
  printf("I'm father process,I wait child exit success!n");
  sleep(1);
  return 0;
}

l的意思是list,我们是以list列表的形式传入第一个参数后面的参数(命令行上如何执行程序)

v的意思是vector,我们是以vector数组列出的形式传入第一个参数后面的参数(命令行上如何执行程序)

可以看到第一个参数----程序存储路径,前后都是一样的“/usr/bin/ls”;然后后面的程序命令行参数执行传参上,是分别采用了两种形式(list和vector)的传参

在实战当中,两种传参方式都是可以的,哪种方便就用哪种

4.5.2 p----环境变量PATH自动搜索路径(方便)

exec*第一个参数是为了解决替换成的程序的存储路径,其实很多时候我们要执行替换的文件的存储路径是很难记忆的,而且很多程序,就像指令啊,工具呀等可执行程序,其实都是存储在系统级别的路径下,也就是说是存储在环境变量PATH下的一个个默认路径中。很多时候这些路径是难以记忆但却非常常用的,那我们能不能不写路经,只写这些路径名,让OS自动在系统环境变量PATH下的一个个默认路径中自动寻找呢?execlp / execvp(p的出现)就是来方便我们使用的,此时只需要在第一个参数填入可执行程序的名字即可,不再需要我们写路径了,因为会在PATH中一个个自动寻找

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  if(id==0)
  {
    //child
    printf("child start process replace!n");

    //以list形式传参(第一个参数后面的参数)
    //execl("/usr/bin/ls","ls","-n","-l",NULL);
    execlp("ls","ls","-n","-l",NULL);
    
    //以vector形式传参(第一个参数后面的参数)
    char* argv[] = {"ls","-n","-l",NULL};
    //execv("/usr/bin/ls",argv);
    execv("ls",argv);

    //子进程执行程序替换后,下面代码区不再执行,如果执行了,那就是替换失败。
    printf("exec* errorn");
    exit(1);
  }
    //parent
  waitpid(id,NULL,0);
  printf("I'm father process,I wait child exit success!n");
  sleep(1);
  return 0;
}

当我们替换成系统级别路径下的程序,也就是PATH下的默认路径时,例如ls可执行程序的存储路径就是PATH下的系统路径/usr/bin,如下等价关系存在:

execl("/usr/bin/ls","ls","-n","-l",NULL);<=>execlp("ls","ls","-n","-l",NULL);

execv("/usr/bin/ls",argv);<=>execvp("ls",argv);

你是否看到了p给第一个参数带来的便利!

4.5.3 e----自定义替换后进程的环境变量

我们都知道,新创建的一个子进程,其环境变量都会被父进程传入子进程内部,而通常使用的都是系统级别的环境变量,但是能不能让进程使用自己维护定义的环境变量呢?e这个字母后缀(例如execle,execve等),其作用就是,当一个进程发生程序替换的时候,可以将这个发生程序替换的进程的环境变量,维护成自己设计的环境变量。

维护成自己的环境变量,首先就要传入自己设计的环境变量,所以此时带e后缀的程序替换接口就多了一个参要传入,那就是需要在最后一个参数传入自己想要维护成的环境变量----字符串数组

下面我们在实践当中使用:

无效实验1,理论说明:

有效实验:对proc22.c中创建的子进程执行程序替换,在替换后,使得该子进程维护自己的环境变量。替换成的程序,myexe所执行的代码逻辑是打印出当前进程的环境变量,此时我们就可以看到执行程序替换后,打印出子进程的环境变量,就是我们让子进程新维护的自己设计的环境变量。

如下代码段,第一个是proc22.c作为主进程,第二个是myexe.c作为替换成的程序,、打印当前进程的环境变量。

#include<stdio.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<stdlib.h>
int main()
{
  pid_t id = fork();
  if(id==0){
    //child
    printf("Child start program replace!Child will have own environ!n");
    //对进程程序替换之后,该子进程使用自己维护的环境变量
    char* myown_env[] = {
      "/home/zy/103/",
      "/home/zy/102/",
      NULL
    };
    //该子进程使用ls程序的代码和数据,执行myexe程序代码,此时子进程的环境变量就是myown_env
    execle("./myexe","./myexe",NULL,myown_env);
    exit(1);
  }
    //parent
  waitpid(id,NULL,0);
  printf("I'm father process,waiting for child exit success!n");
  return 0;
}
#include<stdio.h>
#include<unistd.h>
int main()
{
  //声明全局变量,可以由该二级指针获取当前进程的环境变量
  extern char** environ;

  int i=0;
  printf("Show the proc environ:n");
  for(;environ[i]!=NULL;++i)
  {
    printf("env[%d]:%sn",i,environ[i]);
  }
  return 0;
}

 可以看到,子进程此时的环境变量确实变成了用户自己所维护的环境变量

最后

以上就是背后秀发为你收集整理的[入门篇]世界上把操作系统进程控制讲的最全面的博客,30K字匠心制作(进程创建+销毁+等待+程序替换)0.前言1.进程的创建(再补充)2.进程的退出/终止 3.进程等待4.进程程序替换的全部内容,希望文章能够帮你解决[入门篇]世界上把操作系统进程控制讲的最全面的博客,30K字匠心制作(进程创建+销毁+等待+程序替换)0.前言1.进程的创建(再补充)2.进程的退出/终止 3.进程等待4.进程程序替换所遇到的程序开发问题。

如果觉得靠谱客网站的内容还不错,欢迎将靠谱客网站推荐给程序员好友。

本图文内容来源于网友提供,作为学习参考使用,或来自网络收集整理,版权属于原作者所有。
点赞(52)

评论列表共有 0 条评论

立即
投稿
返回
顶部