> 文档中心 > 理解进程控制

理解进程控制

文章目录

  • 1、再次理解fork函数
    • 1.1 fork函数回顾
    • 1.2 独立、共享以及写时拷贝
    • 1.3 fork的常规用法
    • 1.4 fork调用失败的原因
  • 2、进程终止
    • 2.1 进程退出场景
    • 2.2 进程正常终止和异常退出(程序崩溃)
    • 2.3 进程常见的退出方法
    • 2.4 进程退出,OS层面做了什么呢?
  • 3、进程等待
    • 3.1 进程等待是什么以及为什么要有进程等待?
    • 3.2 进程等待的方式
  • 3、进程替换

1、再次理解fork函数

1.1 fork函数回顾

①在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。
②进程调用fork,当控制转移到内核中的fork代码后,内核做:

  1. 分配新的内存块和内核数据结构给子进程
  2. 将父进程部分数据结构内容拷贝至子进程
  3. 添加子进程到系统进程列表当中
  4. fork返回,开始调度器调度

③关于fork函数的返回值:

  1. 在父进程中,fork返回新创建子进程的进程ID
  2. 在子进程中,fork返回0
  3. 如果出现错误,fork返回一个负值

④当一个进程调用fork之后,就有两个二进制代码相同的进程。子进程在fork函数调用后开始执行。
⑤fork之后,谁先执行完全由调度器决定。

1.2 独立、共享以及写时拷贝

先引入一个概念:父子进程具有共享性,也具有独立性。这句话并不矛盾。
在说这个话题之前,我们先要知道进程地址空间和页表。每个进程都有自己的进程地址空间(mm_struct),通过这个地址空间,我们可以知道每个变量,每个函数的地址。在Linux下地址下,这种地址叫做虚拟地址,不是实际的物理地址,所以我们在C/C++语言中所看到的地址全部都是虚拟地址!物理地址,用户一概看不到,由OS统一管理。那OS又是如何知道虚拟地址所对应的物理地址呢?每个进程除了有独立的mm_struct之外,还有独立的页表。页表就是对虚拟地址和物理地址的一种映射,OS拿到虚拟地址,通过查页表就可以知道数据在物理地址中实际位置。

关于共享性:因为子进程是父进程通过fork函数创建的,所以子进程会继承父进程的绝大多数资源(包括环境变量、堆栈、共享内存等),但有些东西是不会继承的(包括PID、父进程号、挂起信号等)。继承的资源其中就有mm_struct和页表,通过mm_struct和页表就能找到物理地址所对应空间的数据。所以说父子进程具有共享性。

关于写时拷贝和独立性:写时拷贝是一种延时拷贝,为了避免不必要的拷贝,从而产生的一种挺高性能而产生的技术(STL——string也使用的是这种技术)。其中就会用到引用计数。引用计数的目的就是记录一块空间被多少指针指向的个数。当父进程通过fork创建子进程时,子进程继承了父进程的mm_struct和页表,子进程也能访问父进程的数据,因为页表映射到的是相同的物理地址。但仅限于读数据。当父子进程哪一方想修改数据时,OS就会介入,先查看引用计数,如果引用计数大于1的话,OS就会在为子进程开辟自己的空间,将数据拷贝进去,然后再修改页表的映射关系。后面子进程修改数据时,就不会修改父进程的数据了,这就很好的体现了父子进程的独立性。

总的来说,就是父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本

在这里插入图片描述

1.3 fork的常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求
  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数

1.4 fork调用失败的原因

  • 系统中有太多的进程
  • 实际用户的进程数超过了限制

2、进程终止

2.1 进程退出场景

  • 代码运行完毕,结果正确
  • 代码运行完毕,结果不正确
  • 代码异常终止

2.2 进程正常终止和异常退出(程序崩溃)

正常终止:

  1. 从main函数返回
  2. 调用exit
  3. 调用_exit

在Linux下,因为一个进程结束后,退出码会被父进程读取的,所以我们可以用echo $?(输出最近一次程序退出时的退出码) 指令查看进程的退出码
假设有以下代码:

#includeint main(){printf("FL");return 0;}

main函数中的return后面所带的数字就是退出码,退出码为0,表示正常退出,所以我们写C程序的main函数时,返回的基本都是0

理解进程控制
当我们再次输入这个命令:结果还是为0,是因为echo也是程序,这次输出的是上次echo命令的退出码
为什么会有退出码?
当程序正常运行结束,我们可以通过退出码判断该程序的结果是否正确(0表示success,!0表示failed)。为什么程序结果错误退出码需要用!0表示,因为!0有多个数,1,2,3,4等数字都可以表示!0。导致程序运行结构错误的原因可能有很多种,所以每一个!0的退出码,都对应着一个错误信息,用来表示结果为什么不对,这也是程序员需要关心的。

Linux中的退出码:
在这里插入图片描述
在这里插入图片描述
在这里插入图片描述
可以通过上述操作查看Liunx中的退出码,我们发现退出码最大为133,超过后就没有对应的错误信息了。
注意:程序正常退出,退出码才有价值,因为它表示程序结果是否正确

异常退出:
程序运行时,到了中途就异常退出了,这就叫程序崩溃
在这里插入图片描述

本来应该在/0操作后打印错误码所对应的错误信息,但结果不是是这么回事,原因大家也应该知道,对于/0,该操作是非法的。因为这个操作,导致程序异常退出,也就没有打印后面的信息了
理解进程控制
此时的退出码为:
理解进程控制
通过对比前面的退出码,我们发现136并没有对应的错误信息,这也更加验证了程序正常退出,退出码才有价值,如果程序崩溃(异常退出),退出码也就没有意义了

2.3 进程常见的退出方法

  1. 从main返回
    main函数的return表示进程退出,是一种常见的退出方法,return后面所跟的数字就是进程的退出码,而非main函数的其他函数中的return表示函数返回

  2. 调用exit
    我们可以通过调用exit函数来终止进程
    在这里插入图片描述
    对应的参数status表示进程退出时的退出码
    注意:eixt最典型的特点是在程序任意的地方去调用,都代表进程退出

  3. 调用_exit
    在这里插入图片描述
    _exit和eixt类似,在程序的任意地方调用都能终止进程

关于刷新缓冲区——return、eixt和_exit的对比

对于return,假设有以下程序:

#include#include#include#includeint main(){   printf("hello fl");   sleep(4);   return 0;}

理解进程控制
对于exit,假设有以下程序:

#include#include#include#includeint main(){   printf("hello fl");   sleep(4);   exit(0);   return 0;}

理解进程控制
对于_exit,假设有以下程序:

#include#include#include#includeint main(){   printf("hello fl");   sleep(4);   _exit(0);   return 0;}

理解进程控制
通过对比我们发现,有return和exit的程序都打印了hello fl,而_exit则没有打印hello fl。其原因是前两者在终止进程时,都会进行收尾工作,比如刷新了缓冲区,将缓冲区中的内容打印到了前台,而后者却没有进行收尾工作,也就没有刷新缓冲区,这也导致了结果的不同
注意:这里的缓冲区是用户级缓冲区
在这里插入图片描述
扩展:。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。

2.4 进程退出,OS层面做了什么呢?

系统层面,少了一个进程:free PCB,free mm_struct,free 页表和和各种映射关系,程序的代码和数据申请的空间也要被释放掉,归还系统。
进程加载和进程退出,OS做的工作是相反的

3、进程等待

3.1 进程等待是什么以及为什么要有进程等待?

通过fork()创建子进程是为了帮助父进程完成某种任务,父进程就需要通过某种方式去获得(或者知道)子进程完成任务的情况如何(是完成了,还是没完成)。所以此时就需要父进程在fork之后,通过wait/waitpid等待子进程退出,这种现象就叫做进程等待。

为什么要让父进程等待?

  1. 通过获取子进程退出的信息,能够得知子进程的执行结果
  2. 可以保证时序问题:子进程先退出,父进程后退出
    因为父进程需要获得子进程的退出信息,所以父进程一定会后于子进程退出。换句话说,如果父进程先退出了,子进程还在运行,那么父进程就无法获取子进程的退出信息。进程等待就保证了父进程活的时间比子进程长
  3. 子进程退出,父进程如果不管不顾,就可能造成‘僵尸进程’的问题,进而造成内存泄漏。所以就需要父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息

注意:进程一旦变成僵尸状态,那就刀枪不入,“杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程

3.2 进程等待的方式

wait方法:

   #include   #include   #include   #include   #include   #include  pid_t id = fork();      if(id == 0)      {   int count = 5;   while(count)   {//childprintf("child[%d] is running; count is: %d\n",getpid(),count);count--;sleep(1);   }      exit(0);      } //parent      sleep(10);      printf("father wait begin!\n");      pid_t ret  = wait(NULL);      if(ret > 0)      {  printf("father wait: %d, success\n",ret);      }  else      {   printf("father wait failed\n");      }  sleep(10);  } 

利用fork函数创建子进程。最开始父子进程都为R状态,在5秒之内,子进程每隔1秒就会打印ID以及count,5秒之后子进程将从R状态转换为Z状态,因为子进程结束后,父进程还在sleep中,当父进程sleep完后,通过wait(),将子进程回收掉,此时我们发现只有父进程为R状态,而子进程已经消失,当父进程中的第二个sleep结束后,父进程也将被回收。

在这里插入图片描述
通过while :; do ps ajx | head -1 && ps ajx | grep "test"| grep -v grep; sleep 1;echo"###################################"; done查看进程父子进程的状态
在这里插入图片描述
理解进程控制
在这里插入图片描述
waitpid方法:
在这里插入图片描述
返回值:
当正常返回的时候waitpid返回收集到的子进程的进程ID;
如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在;

参数:
pid:
Pid=-1,等待任一个子进程。与wait等效。
Pid>0.等待其进程ID与pid相等的子进程。

status:
WIFEXITED(status): 若为正常终止子进程返回的状态,则为真。(查看进程是否是正常退出)
WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)

options:
WNOHANG: 若pid指定的子进程没有结束,则waitpid()函数返回0,不予以等待。若正常结束,则返回该子进程的ID。

代码异常终止的本质就是这个进程因为异常问题,导致自己收到了某种信号!
我们可以让父进程通过status得到子进程执行的结果,是正常终止还是异常终止
wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统填充。
如果传递NULL,表示不关心子进程的退出状态信息。否则,操作系统会根据该参数,将子进程的退出信息反馈给父进程。

我们将上述代码修改一下:
在这里插入图片描述
理解进程控制
如果是正常情况下,信号大部分都是0

关于status,不能简单的当做整形来看待,应当作位图来看待
在这里插入图片描述
看以下代码:

  1 #include<stdio.h>  2 #include<string.h>  3 #include<stdlib.h>  4 #include<unistd.h>  5 #include<sys/wait.h>  6 #include<sys/types.h>  7   8 int main()  9 { 10     pid_t id = fork(); 11     if(id == 0) 12     { 13  int count = 3; 14  while(count) 15  { 16      //child 17      printf("child[%d] is running; count is: %d\n",getpid(),count); 18      count--; 19      sleep(1); 20  } 21  exit(1); 22     } 23  24     //parent 25     //sleep(10); 26     printf("father wait begin!\n"); 27     int status = 0; 28     pid_t ret  = waitpid(id, &status,0); 29     if(ret > 0) 30     { 31  //printf("father wait: %d, success, status: %d\n",ret, status); 32  if(WIFEXITED(status))//没有收到任何退出信号 33  { 34      //正常结束,获取对应的退出码 35      printf("exit code: %d\n", WEXITSTATUS(status)); 36  } 37  else 38  { 39      printf("error get a signal!\n"); 40  } 41     } 42     else 43     { 44  printf("father wait failed\n"); 45     } 46     //sleep(10); 47 } 

理解进程控制
运行代码,我们可以发现,子进程是正常退出的,退出码为1(因为子进程中有exit(1))

把代码稍微改一下:
在这里插入图片描述
理解进程控制

注意:
如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。
如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。
如果不存在该子进程,则立即出错返回。

关于options:
如果waitpid的第三个参数是0,则是默认行为,表示阻塞等待。如果参数是WNOHANG,则是非阻塞等待
阻塞等待和非阻塞等待的区别:
生活中的案例:
假如有位帅哥,他叫张三。张三有个女朋友,名字叫小花。有一天呢,张三去找小花,想让小花一起去逛街,当张三走到了小花居住地的楼下,然后给小花打了个电话,说:小花呀,我到楼下了,你下来吧。此时,小花却说到,我现在在做作业,而且必须要做,你等我30分钟左右吧。张三说:行吧。
此时张三有两种等待方式:
1.因为张三想时时刻刻了解到小花做完没有,所以就不挂断电话,也让小花不挂电话,就这么把手机放在耳边,眼睛一直盯着小花住房的窗子,随时了解小花是否然做完了,除此之外,什么也不干。
2.张三觉得如果一直干等着太浪费时间了,但又想了解小花的情况,所以就决定看视频,玩游戏,然后每隔2分钟就给小花打一个电话,了解一下小花的情况,直到小花说做完了,那么张三也就不需要打电话了
对于上面所说两种等待方式,我们将第一种称为阻塞等待。第二种称为非阻塞等待。

对于父进程,因为需要对子进程进行多次检测,所以采用基于非阻塞等待的轮询方案

  1 #include<stdio.h>  2 #include<string.h>  3 #include<stdlib.h>  4 #include<unistd.h>  5 #include<sys/wait.h>  6 #include<sys/types.h>  7   8 int main()  9 { 10     pid_t id = fork(); 13  int count = 3; 14  while(count) 15  { 18      count--; 19      sleep(1); 20  } 21  exit(1); 22     } 23  24     //parent 25     //sleep(10); 26     printf("father wait begin!\n"); 27     int status = 0; 28     while(1)//需要轮询检测 29     { 30  pid_t ret  = waitpid(id, &status, WNOHANG); 31  if(ret == 0) 32  { 33      //子进程没有退出,但是waitpid等待是成功的,需要父进程重复进行等待 34      printf("Do father things!\n"); 35  } 36  else if(ret > 0) 37  { 38      //子进程退出了,waitpid也成功了,获取大了对应的结果 39      printf("father wait: %d, success, status exit code: %d, status exit signal: %d\n",ret,(status>>8)&0xFF,s    tatus&0x7F); 40      break; 41  } 42  else 43  { 44      perror("waitpid"); 45      break; 46  } 47  sleep(1); 48     } 49 }

在这里插入图片描述
通过实验发现,子进程在运行的时候,父进程进行轮询检测(非阻塞等待)

阻塞等待(阻塞了)是不是意味着父进程不被调度执行了呢?
答案:是的。因为阻塞的本质其实就是进程的PCB被放入了等待队列,并将进程的状态改为S状态。相反的,返回的本质就是将进程的PCB从等待队列拿到运行队列,从而被CPU调度

3、进程替换

进程不变,仅仅替换当前进程的代码和数据的技术,叫做进程的程序替换
程序替换的本质就是把程序的进程代码+数据,加载到特定进程的上下文中
替换原理:
用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变
在这里插入图片描述
替换函数
其实有六种以exec开头的函数,统称exec函数:
int execl(const char *path, const char *arg, …);
int execlp(const char *file, const char *arg, …);
int execle(const char *path, const char *arg, …,char *const envp[]);
int execv(const char *path, char *const argv[]);
int execvp(const char *file, char *const argv[]);
int execve(const char *path, char *const argv[], char *const envp[]);

  • 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回
  • 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。如果调用出错则返回-1

所以exec函数只有出错的返回值而没有成功的返回值。

命名理解
这些函数原型看起来很容易混,但只要掌握了规律就很好记

  • l(list) : 表示参数采用列表
  • v(vector) : 参数用数组
  • p(path) : 有p自动搜索环境变量PATH
  • e(env) : 表示自己维护环境变量

在这里插入图片描述

#include int main(){    char *const argv[] = {"ps", "-ef", NULL};    char *const envp[] = {"PATH=/bin:/usr/bin", "TERM=console", NULL};    execl("/bin/ps", "ps", "-ef", NULL);    // 带p的,可以使用环境变量PATH,无需写全路径    execlp("ps", "ps", "-ef", NULL);    // 带e的,需要自己组装环境变量    execle("ps", "ps", "-ef", NULL, envp);    execv("/bin/ps", argv);    // 带p的,可以使用环境变量PATH,无需写全路径    execvp("ps", argv);    // 带e的,需要自己组装环境变量    execve("/bin/ps", argv, envp);    exit(0);}