理解进程控制

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 $?(输出最近一次程序退出时的退出码) 指令查看进程的退出码
假设有以下代码:

#include<stdio.h>
int 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<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>

int main()
{
   printf("hello fl");
   sleep(4);
   return 0;
}

在这里插入图片描述
对于exit,假设有以下程序:

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>

int main()
{
   printf("hello fl");
   sleep(4);
   exit(0);
   return 0;
}

在这里插入图片描述
对于_exit,假设有以下程序:

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>

int 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<stdio.h>
   #include<string.h>
   #include<stdlib.h>
   #include<unistd.h>
   #include<sys/wait.h>
   #include<sys/types.h>
   
      pid_t id = fork();
      if(id == 0)
      {
          int count = 5;
          while(count)
          {
              //child
              printf("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,不能简单的当做整形来看待,应当作位图来看待
在这里插入图片描述
看以下代码:

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>

int main()
{
	pid_t id = fork();
	if (id == 0)
	{
		int count = 3;
		while (count)
		{
			//child
			printf("child[%d] is running; count is: %d\n", getpid(), count);
			count--;
			sleep(1);
		}
		exit(1);
	}

	//parent
	//sleep(10);
	printf("father wait begin!\n");
	int status = 0;
	pid_t ret = waitpid(id, &status, 0);
	if (ret > 0)
	{
		//printf("father wait: %d, success, status: %d\n",ret, status);
		if (WIFEXITED(status))//没有收到任何退出信号
		{
			//正常结束,获取对应的退出码
			printf("exit code: %d\n", WEXITSTATUS(status));
		}
		else
		{
			printf("error get a signal!\n");
		}
	}
	else
	{
		printf("father wait failed\n");
	}
	//sleep(10);
}

在这里插入图片描述
运行代码,我们可以发现,子进程是正常退出的,退出码为1(因为子进程中有exit(1))

把代码稍微改一下:
在这里插入图片描述
在这里插入图片描述

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

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

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

#include<stdio.h>
#include<string.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/wait.h>
#include<sys/types.h>

int main()
{
	pid_t id = fork();
	int count = 3;
	while (count)
	{
		count--;
		sleep(1);
	}
	exit(1);

	//parent
	//sleep(10);
	printf("father wait begin!\n");
	int status = 0;
	while (1)//需要轮询检测
	{
		pid_t ret = waitpid(id, &status, WNOHANG);
		if (ret == 0)
		{
			//子进程没有退出,但是waitpid等待是成功的,需要父进程重复进行等待
			printf("Do father things!\n");
		}
		else if (ret > 0)
		{
			//子进程退出了,waitpid也成功了,获取大了对应的结果
			printf("father wait: %d, success, status exit code: %d, status exit signal: %d\n", ret, (status >> 8) & 0xFF, s    tatus & 0x7F);
			break;
		}
		else
		{
			perror("waitpid");
			break;
		}
		sleep(1);
	}
}

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

阻塞等待(阻塞了)是不是意味着父进程不被调度执行了呢?
答案:是的。因为阻塞的本质其实就是进程的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 <unistd.h>
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);
}
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值