【Linux】进程控制,进程替换

1.进程创建

fork函数初识

在linux中fork函数时非常重要的函数,它从已存在进程中创建一个新进程。新进程为子进程,而原进程为父进程。

#include <unistd.h>
pid_t fork(void);
返回值:自进程中返回0,父进程返回子进程id,出错返回-1  

进程调用fork,当控制转移到内核中的fork代码后,内核做:

  • 分配新的内存块和内核数据结构给子进程

  • 将父进程部分数据结构内容拷贝至子进程

  • 添加子进程到系统进程列表当中

  • fork返回,开始调度器调度

image-20230907220741961

当一个进程调用fork之后,就有两个二进制代码相同的进程。而且它们都运行到相同的地方。但每个进程都将可以开始它们自己的旅程。

fork之前父进程独立执行,fork之后,父子两个执行流分别执行。

注意,fork之后,谁先执行完全由调度器决定

fork函数返回值

子进程返回0,
父进程返回的是子进程的pid。

fork函数为什么要给子进程返回0,给父进程返回子进程的PID?

道理很简单一个父亲可以有很多孩子,但是一个孩子只能有一个父亲,所以一个父进程可以创建很多个子进程出来,而一个子进程只能有一个父进程。因此,父进程是不需要标记的,而对于父进程来说,它要可能要管理多个子进程,所以父进程要知道子进程PID才好管理好接下来的操作

为什么fork函数有两个返回值?

父进程在调用fork函数的时候,fork函数在内部做了一系列的操作包括但不限于给子进程创建进程控制块task_struct ,进程地址空间mm_struct ,页表等其他信息的处理,也就是说在fork函数内部return之前就已经把子进程创建完毕,那么fork函数的return语句是父进程和子进程同时在执行的,这就是为什么fork有两个返回值的原因。

image-20230908113131115

写时拷贝

通常,父子代码共享,父子再不写入时,数据也是共享的,当任意一方试图写入,便以写时拷贝的方式各自一份副本。具体见下图:

image-20230907221614923

为什么数据要进行写时拷贝?

进程最大的一个特点是具有独立性,如果在其中一个进程中修改了数据没有进行写时拷贝,那么将会影响到其他进程的数据,为了保证各个进程的相互独立,是必须要有写时拷贝的

为什么不在创建子进程的时候就进行数据的拷贝?

其实很多时候,子进程并不一定会使用的父进程的所有数据,并且如果在某些情况下子进程只是单纯的对父进程的数据进行读取,没有必要对数据进行拷贝。所有正确的做法应该是按需分配,在需要修改数据的时候再进行分配,这样可以高效的使用内存空间。

代码会不会进行写时拷贝

很多情况下都不需要代码进行拷贝,但凡是没有绝对,例如在进行进程替换的时候,就需要进行代码拷贝的写时拷贝

fork常规用法

  • 一个父进程希望复制自己,使父子进程同时执行不同的代码段。例如,父进程等待客户端请求,生成子进程来处理请求。

  • 一个进程要执行一个不同的程序。例如子进程从fork返回后,调用exec函数。

fork调用失败的原因

一般不会出错

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

2. 进程终止

进程退出场景

  • 代码运行完毕,结果正确

  • 代码运行完毕,结果不正确

  • 代码异常终止

进程常见退出方法

正常终止(可以通过 echo $? 查看进程退出码):

  1. 从main返回

  2. 调用exit

  3. _exit (系统调用)

异常退出:

ctrl + c,信号终止

_exit函数

#include <unistd.h>
void _exit(int status);
参数:status 定义了进程的终止状态,父进程通过wait来获取该值  

说明:虽然status是int,但是仅有低8位可以被父进程所用。所以_exit(-1)时,在终端执行echo $?发现返回值是255

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

C语言当中的strerror函数可以通过错误码,获取该错误码在C语言当中对应的错误信息

使用_exit函数退出进程的方法我们并不经常使用,_exit函数也可以在代码中的任何地方退出进程,但是_exit函数会直接终止进程,并不会在退出进程前会做任何收尾工作。

例如,以下代码中使用_exit终止进程,则缓冲区当中的数据将不会被输出。

image-20230908125233187

image-20230908125215530

exit函数

#include <unistd.h>
void exit(int status);  

其实exit底层实现最后也会调用exit, 只不过在调用exit之前,它还做了其他工作:

  1. 执行用户定义的清理函数。

  2. 关闭所有打开的流,所有的缓存数据均被写入

  3. 调用_exit

image-20230907224303845

例如,以下代码中exit终止进程前会将缓冲区当中的数据输出。

image-20230908125122546

image-20230908125142529

return退出

return是一种更常见的退出进程方法。执行return n等同于执行exit(n),因为调用main的运行时函数会将main的返回值当做 exit的参数。

return、exit和_exit之间的区别与联系

只有在main函数当中的return才能起到退出进程的作用,子函数当中return不能退出进程,而exit函数和_exit函数在代码中的任何地方使用都可以起到退出进程的作用。

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

img

3. 进程等待

进程等待的必要性

子进程退出,如果父进程放任不管,就可能造成僵尸进程的问题,进而造成内存泄漏,而且进程一旦进入了僵尸状态,那就刀枪不入,连”杀人不眨眼”的kill -9 也无能为力,因为谁也没有办法杀死一个已经死去的进程。

并且,子进程一般都是父进程给派发任务而创建的,所以父进程是需要知道子进程的运行结果和结果状态。

而父进程是需要进行进程等待的方式获取子进程的运行结果和对子进程的资源进行回收

进程等待的方法

wait 方法

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
返回值:
    成功返回被等待进程pid,失败返回-1。
参数:
    输出型参数,获取子进程退出状态,不关心则可以设置成为NULL

waitpid方法

pid_ t waitpid(pid_t pid, int *status, int options);
返回值:
    当正常返回的时候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。
  • 如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。

  • 如果在任意时刻调用wait/waitpid,子进程存在且正常运行,则进程可能阻塞。

  • 如果不存在该子进程,则立即出错返回。

image-20230908132937580

获取子进程status

进程等待所使用的两个函数wait和waitpid,都有一个status参数,该参数是一个输出型参数,由操作系统进行填充。

如果对status参数传入NULL,表示不关心子进程的退出状态信息。否则,操作系统会通过该参数,将子进程的退出信息反馈给父进程。

status是一个整型变量,但status不能简单的当作整型来看待,status的不同比特位所代表的信息不同,具体细节如下(只研究status低16比特位):

image-20230908133104800

//在代码中我们可以这样获取对应的状态码
exitCode = (status >> 8) & 0xFF; //退出码
exitSignal = status & 0x7F;      //退出信号

//对于此,系统当中提供了两个宏来获取退出码和退出信号。
//WIFEXITED(status):用于查看进程是否是正常退出,本质是检查是否收到信号。
///WEXITSTATUS(status):用于获取进程的退出码。
exitNormal = WIFEXITED(status);  //是否正常退出
exitCode = WEXITSTATUS(status);  //获取退出码

不过需要注意的是,如果一个进程非正常退出的时候,说明该进程是给信号所杀,那么对应的退出码就没有任何意义了,只需看它的退出信号即可。

//wait函数测试代码
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
    
    
  pid_t pid=fork();
  if(pid==-1){
    
    
    perror("fork");
    exit(1);
  }else if(pid==0){
    
    
      //子进程
      int cnt=5;
      while(cnt--){
    
    
        printf("我是子进程,我的pid是:%d,我的ppid是%d\n",getpid(),getppid());
        sleep(1);
      }
      exit(2);
  }else{
    
    
      //父进程
      int status;
      int ret=wait(&status);
      if(ret==-1){
    
    
        perror("wait");
        exit(3);
      }else{
    
    
        int exitSignal=status & 0x7F;//退出信号
        int exitCode=(status >>8) & 0xFF; //退出码
        printf("exitSignal:%d exitCode:%d \n",exitSignal,exitCode);
      }
  }
  return 0;
}

image-20230908135532424

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

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

image-20230908135602896

阻塞和非阻塞等待方式

进程的阻塞等待方式

创建子进程后,父进程可使用waitpid函数一直等待子进程(此时将waitpid的第三个参数设置为0),直到子进程退出后读取子进程的退出信息。

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

int main()
{
     
     
  pid_t pid=fork();
  if(pid==-1){
     
     
    perror("fork");
    exit(-1);
  }
  if(pid==0){
     
     
    //子进程
    int cnt=5;
    while(cnt--){
     
     
      printf("我是子进程,我的pid:%d,我的ppid:%d\n",getpid(),getppid());
      sleep(1);
    }
    exit(1);
  }
  //父进程
  int status;
  int ret=waitpid(pid,&status,0);//阻塞等待
  if(ret==-1){
     
     
      perror("wait");
      return 1;
  }else{
     
     
      printf("wait success,exitSignal:%d exitCode:%d\n",status & 0x7F,(status >>8)& 0xFF);
  }

  return 0;
}
//运行结果
//我是子进程,我的pid:5776,我的ppid:5775
//我是子进程,我的pid:5776,我的ppid:5775
//我是子进程,我的pid:5776,我的ppid:5775
//我是子进程,我的pid:5776,我的ppid:5775
//我是子进程,我的pid:5776,我的ppid:5775
//wait success,exitSignal:0 exitCode:1

同样我们可以测试一下在父进程运行过程中,我们可以尝试使用kill -9命令将子进程杀死,这时父进程也能等待子进程成功。(被信号所杀的进程,退出码没有意义)

image-20230908141336705

进程的非阻塞等待方式

上述所给例子中,当子进程未退出时,父进程都在一直等待子进程退出,在等待期间,父进程不能做任何事情,这种等待叫做阻塞等待。

实际上我们可以让父进程不要一直等待子进程退出,而是当子进程未退出时父进程可以做一些自己的事情,当子进程退出时再读取子进程的退出信息,即非阻塞等待。

做法很简单,向waitpid函数的第三个参数potions传入WNOHANG,这样一来,等待的子进程若是没有结束,那么waitpid函数将直接返回0,不予以等待。而等待的子进程若是正常结束,则返回该子进程的pid。

父进程可以隔一段时间调用一次waitpid函数,若是等待的子进程尚未退出,则父进程可以先去做一些其他事,过一段时间再调用waitpid函数读取子进程的退出信息。

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(){
     
     
  pid_t pid=fork();
  if(pid==-1){
     
     
    perror("fork");
    exit(-1);
  }
  if(pid==0){
     
     
    //子进程
    int cnt=5;
    while(cnt--){
     
     
      printf("我是子进程,我的pid:%d,我的ppid:%d\n",getpid(),getppid());
      sleep(1);
    }
    exit(1);
  }
  //父进程
  int status;
  while(1){
     
     
       int ret=waitpid(pid,&status,WNOHANG);//非阻塞等待
      if(ret==-1){
     
     
          perror("wait");
          return 1;
      }else if(ret==0){
     
     
          printf("非阻塞等待中.....正在执行其他任务.......\n");
          sleep(1);
      }else {
     
     
          printf("wait success,exitSignal:%d exitCode:%d\n",status & 0x7F,(status >>8)& 0xFF);
          break;
      }
  }
  return 0;
}

image-20230908142407772

4. 进程程序替换

替换原理

用fork创建的子进程,执行的往往是和父进程相同的程序,虽然也有可能执行不同的代码,但是想要让子进程执行另一个程序,往往需要调用exec函数。

当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。

子进程进行进程程序替换后,会不会影响父进程的代码和数据吗?

显然不会,因为我们说过,进行最大的一个特点就是独立性,如果其中一个进程发生修改数据的操作,就会进行写时拷贝

替换函数

其实有六种以exec开头的函数,统称exec函数:

#include <unistd.h>
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 execl(const char *path, const char *arg, …);

第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。

例如,要执行的是ls程序。

execl("usr/bin/ls","ls","-a","-l",NULL);

image-20230908153527688

image-20230908153551745

事实证明:执行exec函数成功之后,exec函数下面的代码将全部不会并执行,因为在执行exec函数之后,所有代码数据已被替换。

  • 二、int execlp(const char *file, const char *arg, …);

第一个参数是要执行程序的名字,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾。

例如,要执行的是ls程序。

execlp("ls","ls","-a","-l",NULL)

image-20230908153839250

image-20230908153825240

  • 三、int execle(const char *path, const char *arg, …, char *const envp[]);

第一个参数是要执行程序的路径,第二个参数是可变参数列表,表示你要如何执行这个程序,并以NULL结尾,第三个参数是你自己设置的环境变量。

例如,你设置了MYVAL环境变量,在mycmd程序内部就可以使用该环境变量。

char* myenvp[] = {
    
     "MYVAL=2023", NULL };
execle("./mycmd", "mycmd", NULL, myenvp);
  • 四、int execv(const char *path, char *const argv[]);

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。

例如,要执行的是ls程序。

char* myargv[] = {
    
     "ls", "-a", "-l", NULL };
execv("/usr/bin/ls", myargv);
  • 五、int execvp(const char *file, char *const argv[]);

第一个参数是要执行程序的名字,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾。

例如,要执行的是ls程序。

char* myargv[] = {
    
     "ls", "-a", "-l", NULL };
execvp("ls", myargv)
  • 六、int execve(const char *path, char *const argv[], char *const envp[]);

第一个参数是要执行程序的路径,第二个参数是一个指针数组,数组当中的内容表示你要如何执行这个程序,数组以NULL结尾,第三个参数是你自己设置的环境变量。

例如,你设置了MYVAL环境变量,在mycmd程序内部就可以使用该环境变量。

char* myargv[] = {
    
     "mycmd", NULL };
char* myenvp[] = {
    
     "MYVAL=2023", NULL };
execve("./mycmd", myargv, myenvp);

其实还有一个函数叫execve系统调用

#include <unistd.h>
int execve(const char *filename, char *const argv[],
                  char *const envp[]);

实际上有关exec是一个函数族,包括execle,execlp,execvp,execv,execl但是他们具体的实现都是调用execve()之上。它们的区别就在于对路径名,参数以及环境变量的指定上。下面分别从这三个方面来区分这几个函数:

image-20230908152737084

image-20230908155430586

命名理解

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

  • l(list) : 表示参数采用列表

  • v(vector) : 参数用数组

  • p(path) : 有p自动搜索环境变量PATH

  • e(env) : 表示自己维护环境变量

函数解释

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

也就是说,exec系列函数只要返回了,就意味着调用失败。

做一个简易版的命令行解释器shell

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

#define LEN 1024 //命令的最大的长度
#define NUM 32 //命令拆分后的最大个数
int main()
{
    
    
  char cmd[LEN];
  char* myargv[NUM];
  const char* user=getenv("USER");//获取用户名
  const char* path=getenv("PWD");//获取当前路径
  while(1)
  {
    
    
    printf("[%s@ %s]# ",user,path);
    fgets(cmd,LEN,stdin);
    cmd[strlen(cmd)-1]='\0';
    //分割解析命令
    int i=0;
    myargv[i++]=strtok(cmd," ");
    while(myargv[i]=strtok(NULL," ")) i++;
    if(fork()==0)
    {
    
    

      //子进程执行替换任务
      execvp(myargv[0],myargv);
      exit(1);
    }
    int status;
    int ret=waitpid(-1,&status,0);//回收资源
    if(ret==-1){
    
    
      perror("wait");
    }else{
    
    
      printf("exit code:%d\n", (status>>8)& 0xFF);
    }
  }
  return 0;
}

image-20230908163925964

猜你喜欢

转载自blog.csdn.net/dongming8886/article/details/132878102