12 Linux进程的控制


一、fork的补充

在之前已经了解了fork函数,这个函数是以父进程为“模板”创建子进程。父子进程的所有代码共享,这是因为代码是不可被修改的,所以各自私有代码的话会浪费空间。

其返回值为:
子进程中返回0,父进程中返回子进程的PID,子进程创建失败返回-1。因为一个父进程可以创建多个子进程,而一个子进程只能有一个父进程。因此,对于子进程来说,父进程是不需要被标识的;而对于父进程来说,子进程是需要被标识的,因为父进程创建子进程的目的是让其执行任务的,父进程只有知道了子进程的PID才能很好的对该子进程指派任务。

fork的工作过程具体如下:

  1. 父进程初始化。
  2. 父进程调用fork创建子进程,fork为系统调用,因此进入内核。
  3. 内核根据父进程复制出一个子进程。父进程和子进程的PCB信息相同,代码和数据也相同。因此,子进程和父进程一样,做完初始化,刚掉用了fork进入内核,还没有从内核返回。
  4. 现在又两个一模一样的进程都调用了fork进入内核等待从内核返回(实际上只有父进程调用了fork一次),此外系统中还有很多其他进程也等待从内核返回。是父进程先返回还是子进程先返回,还是这两个进程都等待,系统调度执行了其他的进程,取决于内核的调度算法。
  5. 如果某个时刻父进程被调度指向,从内核返回后就从fork函数返回,返回值是子进程的PID。
  6. 如果某个时刻子进程被调度执行了,从内核返回后就从fork函数返回,返回值是0.

在这里插入图片描述

fork函数的特点概括起来就是“调用一次,返回两次”,在父进程中调用一次,在父进程和子进程中各返回一次。开始是一个控制流程,调用fork之后发生分叉,变成两个控制流程,这也是fork(分叉)名字的由来。子进程中fork返回值是0,父进程是子进程的PID(从根本上说fork是从内核返回的,内核自有办法让父进程和子进程返回不同的值),这样当fork函数返回后,可以根据返回值的不同让父进程和子进程执行不同的代码。

另外,一般而言,通常要让子进程先退出,因为父进程可以很容易对子进程进行管理(垃圾回收),而且子进程创建出来是用来处理业务的,所以需要父进程帮忙拿到子进程执行的结果

1.1.写时拷贝

父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本:
在这里插入图片描述

写时拷贝相比于创建进程时就拷贝节约了内存空间,因为子进程不对数据进行写入的情况下,没有必要对数据进行拷贝。

写时拷贝可以保证在多进程运行时,各进程独享各自的资源,多进程运行期间互不干扰,不让子进程的修改影响到父进程。实现进程独立性

另外,写时拷贝并不会把全部的数据都拷贝过去,需要多少就拷贝多少,比如数据一共有10M,子进程只需要对其中的1M进行修改,操作系统只需要拷贝修改的那1M。

1.2.fork调用失败的原因

  • 系统中有太多的进程
  • 实际用户的进程数超过限制,一个用户创建的进程数量是有限的。

二、进程终止

进程退出只有三种情况:

  1. 代码运行完毕,结果正确。

  2. 代码运行完毕,结果不正确。

  3. 代码异常终止(进程崩溃)。

进程退出,在系统层面,意味着该进程的相关资源都要释放掉,比如PCB,mm_struct,页表以及各种映射关系,代码和数据申请的空间。

2.1.退出码

可以通过 echo $?查看最近一次进程的退出码:
退出码分为以下几类:

  • 从main函数中return返回。(正常退出)(0表示正常退出,非0表示错误退出)
  • 调用exit。(正常退出)
  • 调用_exit。(正常退出)
  • ctrl + c,信号终止。(异常退出)

在这里插入图片描述

Linux中自带的命令也是一个可执行程序,所以它们也会有进程退出码:
在这里插入图片描述

这些退出码都有含义,从而帮助用户确认执行失败的原因,而这些退出码具体代表什么含义是人为规定的,不同环境下相同的退出码的字符串含义可能不同。可以使用strerror函数确认这些退出码的含义:

在这里插入图片描述

在这里插入图片描述

2.2.正常退出

return

这种方式是最常用的退出方式,这也是为什么main函数最后要写一个return 0的原因,因为0表示正常退出。

exit

使用它

exitreturn是有差别的,exit是退出整个进程,在进程的任何地方都可以调用从而退出整个进程,而return是终止当前函数,并不会将进程终止,在main函数中调用的return则会使进程退出。

执行return num等同于执行exit(num),因为调用main函数运行结束后,会将main函数的返回值当做exit的参数来调用exit函数。

exit的参数就是一个进程的退出码:
在这里插入图片描述

在这里插入图片描述

_exit和exit的区别

exit()函数定义在stdlib.h中,而_exit()定义在unistd.h中。exit()_exit()都用于正常终止一个函数。但_exit()直接是一个sys_exit系统调用,而exit()则通常是普通函数库中的一个函数。它会先执行一些清除操作,例如调用执行各终止处理函数、关闭所有标准IO等,然后调用sys_exit
在这里插入图片描述

exit的退出:
在这里插入图片描述

在这里插入图片描述

_exit退出:
在这里插入图片描述
在这里插入图片描述

2.3.异常退出

异常退出的情况一般有下面两种:

  • 向进程发生信号导致进程异常退出。
    在进程运行过程中向进程发生kill -9信号使得进程异常退出,或是使用Ctrl+c使得进程异常退出等。

  • 代码错误导致进程运行时异常退出。
    比如代码指针越界导致进程异常退出,或是出现除0的情况使得进程运行时异常退出等。


三、进程等待

由于需要保证子进程先退出(不这么做会造成僵尸进程,使内存泄漏),所以父进程需要通过进程等待的方式,回收子进程资源,获取子进程的退出信息。

3.1.进程等待的方法

wait

在这里插入图片描述

wait()等待任一僵死的子进程,将子进程的退出状态(退出值、返回码、返回值)保存在参数status中。即进程一旦调用了wait,就立即阻塞自己,由wait分析是否当前进程的某个子进程已经退出,如果找到这样一个已经变成僵尸的子进程,wait就会收集这个子进程的信息,并把它彻底销毁后返回;如果没有找到这样一个子进程,wait就会一直阻塞在这里,直到有一个出现为止。如果成功,返回该终止进程的PID,否则返回-1。其参数为获取子进程的退出状态,不关心可设置为NULL。

使用下面的程序验证:
在这里插入图片描述

使用以下监控脚本对进程进行实时监控:

while :; do ps axj | head -1 && ps axj | grep test |
 grep -v grep;echo "######################";sleep 1;done

在这里插入图片描述

可以看到子进程并没有变成僵尸进程,而是被父进程清理掉了,另外父进程在运行到wait(NULL)一句的时候会阻塞等待,直到清理完子进程才往下执行。

如果把wait(NULL)一句去掉:
在这里插入图片描述

在这里插入图片描述

可以看到子进程在退出后会变成僵尸进程。

waitpid

在这里插入图片描述

相比于waitwaitpid等待标识符为pid的子进程退出。将该子进程的退出状态(退出值、返回码、返回值)保存在参数status中。
其三个参数:

  1. pid:待等待子进程的pid,若设置为-1,则等待任意子进程。
  2. status:获取子进程的退出状态,不关心可设置为NULL。
  3. options:规定调用的行为,当这个参数设置为WNOHANG表示如果没有子进程退出,则立即返回0,不等待子进程退出;设置为WUNTRACED表示返回一个已经停止但尚未退出的子进程的信息。

status

status是一个输出型参数,也就是会一个整形变量的地址传进去,子进程退出,操作系统会从进程PCB中读取信息保存在status指向的变量中,将子进程的退出信息反馈给父进程。如果传递NULL,表示不关心子进程的退出状态信息。

status不能简单的当作整形来看待,可以当作位图来看待,在status的低16比特位当中,高8位表示进程的退出状态,即退出码。进程若是被信号所杀,则低7位表示终止信号,而第8位比特位是core dump标志。

在这里插入图片描述

因此如果想检测进程是否被信号所杀,只需要检测第七位是否为0即可,如果为0则为正常终止。

以下面的程序为例:
在这里插入图片描述

在这里插入图片描述

可以看到st之所以是256,是因为正常终止时前八位全是0,后八位才是退出码,所以如果相获取退出码的话需要把st右移八位然后按位与上1111 1111即可。

在这里插入图片描述

同时由于只有后八位才是退出码,因此退出码不能超过255,否则会因为越界而无法存储,比如:
在这里插入图片描述
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

如果是被信号所杀且要拿到退出信号,只需要按位与上0x7F即可:
在这里插入图片描述

在这里插入图片描述

如果这个值为0,就说明没有收到任何信号:
在这里插入图片描述

如果子进程中存在错误:
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

SIGFPE是除零异常信号。

因此可以通过status这个参数判断子进程是否运行正确,并且判断其运行成功后的退出码:

在这里插入图片描述

当然上面这些如果自己来写的话就太麻烦了,所以系统当中提供了两个宏来获取退出码和退出信号:

  • WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。如果正常终止子进程则为真。相当于!(status&0x7F)
  • WEXITSTATUS(status):如果WIFEXITED(status)非零,则说明正常终止子进程,此时这个宏用于获取进程的退出码。相当于(status>>8)&0xFF

因此上面的程序可以改成这样:
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

3.2.创建多进程

我们还可以同时创建多个子进程,然后让父进程依次等待子进程退出:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	pid_t ids[10];
	for (int i = 0; i < 10; i++){
		pid_t id = fork();
		if (id == 0){
			//child
			printf("child process created successfully...PID:%d\n", getpid());
			sleep(1);
			//子进程要执行的代码
			//... ...
			exit(i); //将子进程的退出码设置为该子进程PID在数组ids中的下标
		}
		//father
		ids[i] = id;
	}
	for (int i = 0; i < 10; i++){
		int st = 0;
		pid_t ret = waitpid(ids[i], &st, 0);
		if (ret >= 0){
			printf("wiat child success..PID:%d\n", ids[i]);
			if (WIFEXITED(st)){
				//exit normal
				printf("exit code:%d\n", WEXITSTATUS(st));
			}
			else{
				//signal killed
				printf("killed by signal %d\n", st & 0x7F);
			}
		}
	}
	return 0;
}

在这里插入图片描述

3.3.非阻塞等待子进程

前面提到过,options的参数设置为WNOHANG表示如果没有子进程退出,则立即返回0,不等待子进程退出。所以可以使用这个参数让父进程不等子进程而是做别的事情:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
	pid_t id = fork();
	if (id == 0){
		//child
		int count = 3;
		while (count--){
			printf("child do something...PID:%d, PPID:%d\n", getpid(), getppid());
			sleep(3);
		}
		exit(0);
	}
	//father
	while (1){
		int st = 0;
		pid_t ret = waitpid(id, &st, WNOHANG);
		if (ret > 0){
			printf("wait child success!\n");
			printf("exit code:%d\n", WEXITSTATUS(st));
			break;
		}
		else if (ret == 0){
			printf("child is not quit,check later!\n");
			sleep(1);
		}
		else{
			printf("child exit error!\n");
			break;
		}
	}
	return 0;
}

在这里插入图片描述

虽然阻塞式等待在等待的时候不能干别的事情,但是计算机中大部分等待方式都是阻塞式等待,因为阻塞式等待更简单。

3.4.总结

什么是进程等待:是父进程通过wait等待系统调用,用来等待子进程状态的一种现象。

为什么要进程等待:1.防止子进程发生僵尸问题,进而产生内存泄漏 2.读取子进程的进程状态


四、进程程序替换

父子进程之间代码是共享的,所以实际上父子进程执行的是同一个程序,若想让子进程执行另一个和子进程不同的程序,往往需要调用exec函数。

在这里插入图片描述

程序替换并是创建一个新的进程,因为PCB没有被重新创建,PID也没有重新生成。

4.1.进程替换的函数

进程替换的函数一共有六个,统称exec函数,都在头文件<unistd.h>中:
在这里插入图片描述

这六个函数的第一个参数代表的是替换的目标程序路径(路径或者程序名字)。
第二个参数和后面的…代表如何执行目标程序,在命令行中怎么调用执行,就怎么传递。

exec系列函数如果函数返回了,或者执行了后续的代码,那一定是程序替换错了。因为函数如果调用成功,则加载指定的程序并从启动代码开始执行,不再返回。如果调用出错,返回-1。

这六个函数的名字是由exec加其他字母组成,每个字母表示其参数的含义:

  • l(list) : 表示参数采用列表,其参数是可变参数列表,可以传多个参数,并以NULL结尾。
  • v(vector) : 参数用数组,参数要写到数组里,然后传入一个数组。
  • p(path) : 有p在执行的时候会自动搜索环境变量PATH,带p的第一个参数是file,不带p则是path,因为如果要进行程序替换,必须要先找到要替换的程序,以ls为例,带p的就可以不用传路径而是只传名字就行,因为会自动搜索环境变量,不带p的则必须传路径。
  • e(env) : 表示自己维护环境变量
函数名参数格式是否带路径是否使用当前环境变量
execl列表
execlp列表
execle列表否,需自己组装环境变量
execv数组
execvp数组
execvpe数字否,需自己组装环境变量
execve数组否,需自己组装环境变量
int execl(const char *path, const char *arg, ...);

由于不带p不能自动搜索环境变量,因此第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示如何执行这个程序,并以NULL结尾。

以目标程序是ls -a -l -i为例:

在这里插入图片描述

注意这里的"/usr/bin/ls代表的是找到这条命令,后面的"ls","-a","-l"才是执行,所以后面不能省略ls

在这里插入图片描述

一旦替换成功,接下来的进程就会执行被替换的程序,原来程序后面的代码由于已经被替换,就不会执行了。

int execlp(const char *file, const char *arg, ...);

带上p之后第一个参数就不需要写全路径了,因为会自动搜索环境变量,当然如果环境变量中没有,还是要带上路径的:
在这里插入图片描述

int execv(const char *path, char *const argv[]);

第一个参数是全路径,第二个参数是一个数组,不再是可变参数列表:
在这里插入图片描述
在这里插入图片描述

int execvp(const char *file, char *const argv[]);

带上p之后第一个参数就不需要写全路径了,因为会自动搜索环境变量,当然如果环境变量中没有,还是要带上路径的:
在这里插入图片描述

int execle(const char *path, const char *arg, ...,char *const envp[]);
int execvpe(const char *file, char *const argv[],char *const envp[]);

exec系列函数能调用系统程序,也可以调用自己写的程序。所以可以在自己写的程序中调用自己定义的环境变量,execle的第三个参数的作用就是传入一个自己定义的环境变量,比如让myexe程序调用test程序,然后在test输出自己定义的环境变量MYENV:
在这里插入图片描述

在这里插入图片描述

在这里插入图片描述

4.2. execve

在这里插入图片描述

上面这些函数都是基于execve函数做的封装,只有execve函数才是真正的系统调用:
在这里插入图片描述

之所以设计这么多的exec函数主要是为了满足不同的场景需求


五、实现一个简单的shell

shell需要执行的逻辑非常简单,其只需循环执行以下步骤:

  1. 获取命令行。
  2. 解析命令行。
  3. 创建子进程。(fork)
  4. 替换子进程让子进程执行指令。(execvp)
  5. 等待子进程退出。(wait)

在这里插入图片描述

之所以要创建子进程是因为如果要执行的命令错误,子进程挂掉并不影响父进程。

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

#define SIZE 256
#define NUM 16 //命令行参数的个数
int main()
{
  char cmd[SIZE];
  const char* cmd_line="[hjl@VM-0-16-centos ~]$ ";
  while(1)
  {
    cmd[0]=0;

    printf("%s",cmd_line);
    fgets(cmd,SIZE,stdin);
    cmd[strlen(cmd)-1]='\0';//将最后的'\n'替换为'\0'
    //将命令字符串分割
    char*args[NUM];
    args[0]=strtok(cmd," ");    
    int i=1;    
    do    
    {    
      args[i]=strtok(NULL," ");    
      if(args[i]==NULL)    
      {    
        break;    
      }    
      i++;    
    }while(1);   
    //创建子进程让其执行命令字符串  
    pid_t id=fork();    
    if(id<0)    
    {    
      perror("fork error!\n");  
      continue;
    }
    if(id==0)//子进程
    {
      execvp(args[0],args);//替换子进程使用exec系列函数
      exit(1);
    }
    int status=0;
    pid_t ret=waitpid(id,&status,0);
    if(ret>0)
    {
      printf("status code:%d\n",(status>>8)&0xFF);
    }
    

  }

  return 0;
}                        

在这里插入图片描述


六、补充和总结内容

进程创建的两种方式:1.运行一个可执行程序(由bash创建)2.fork创建(由我们自己创建)

进程创建出来,操作系统除了将进程的二进制代码和数据加载到内存之外,为了便于管理还要给进程创建对应的数据结构(PCB、地址空间、页表等)

父子进程相互之间是独立的,不会相互影响,数据各自私有,采用写时拷贝

在进程的任何一个地方调用exit()都会终止进程,return只会终止当前函数,exit()_exit()的区别在于exit()会做一系列清理工作(执行清理函数,冲刷缓冲区等)。

终止一个进程时操作系统要回收进程的资源,代码和数据可以优先被释放(因为永远也不会被访问了),数据结构释放的比较晚(因为要记录退出信息)。

进程替换是将原来进程的数据结构大体不变的情况下(PCB不变,页表的映射关系改变),将新程序的代码和数据覆盖原来的进程。程序替换要由操作系统来完成,因为新程序存储在磁盘上,而进程在内存中。

  • 6
    点赞
  • 19
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

今天也要写bug、

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值