【Linux】进程控制

发布于:2024-04-07 ⋅ 阅读:(112) ⋅ 点赞:(0)

这篇博客主要记录进程创建、进程终止、进程等待、获取子进程状态、进程替换相关知识。

目录

进程创建

fork函数认识

fork函数返回值

写时拷贝

fork常规用法

fork调用失败的原因

进程终止

终止是在做什么

进程终止的3种情况

如何终止?

进程等待

进程等待的方法

wait方法

waitpid方法

获取子进程status

进程程序替换

替换原理

将代码改成多进程版

使用所有的替换方法,并且认识函数参数的含义


进程创建

fork函数认识

有了上一节的学习,我们来更新一下进程的概念:进程,内核的相关数据结构(task_struct + mm_struct + 页表) + 代码和数据。

linux中,fork函数从已存在的进程(父进程)中创建一个新进程(子进程)。

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

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

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

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

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

fork返回,开始调度器调度

代码是父子进程共享,数据是子进程进行写时拷贝。

fork函数返回值

子进程返回0

父进程返回的是子进程的pid

为什么父进程返回的是子进程的pid,给子进程返回0?

答:为了让父进程方便对子进程进行标识,进而进行管理!

写时拷贝

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

fork常规用法

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

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

fork调用失败的原因

1)系统中有太多的进程

2)实际用户的进程数超过了限制

进程终止

终止是在做什么

1)释放曾经的代码和数据所占据的空间

2)释放内核数据结构

需要说明的是,在内核数据结构中,有一个是PCB,即task_struct,在进程终止释放内核数据结构时,会被延期处理,因为有一个状态叫Z(僵尸),也就是说,当一个进程释放时,先把对应的代码和数据干掉,反正也不会被调度了,只不过对应的task_struct先要被维护起来。

进程终止的3种情况

我们先来看一段代码:

#include <stdio.h>
#include <unistd.h>
int main()
{
  printf("I am a process,pid:%d,ppid:%d\n",getpid(),getppid());
  sleep(2);
  return 100;
}

在这段代码中,我们最终return 100,那么我们编译并执行这个文件,然后使用:

echo $?

打印出100,echo是一个内建命令,打印的都是bash内部的变量数据,bash内部维护了一个叫?的变量,其含义是父进程bash获取到的最近一个子进程退出的退出码,也就是return返回的值叫做进程的退出码,$?表示的是访问这个变量。那么这个退出码代表什么意思呢?

0:表示成功    !0(非0):表示失败

所以为什么我们在写C/C++代码时,最后一般是return 0呢,因为我们默认我的的代码运行成功。进程的退出码存在的意义是告诉关心方(一般是父进程),我把任务完成的怎么样了! 

如上图,我们再echo $?一次,为什么变成0了呢?注意,$?是最近一个子进程的退出码,那我们最近一次的子进程是上面第一次的echo $?,虽然echo是内建命令,但是它也会被当做一个进程来看待,这就是bash最近一个子进程。

如果进程运行成功,返回0,父进程不在乎,反正已经成功了;但是,如果进程运行失败,父进程想要知道失败的具体原因,原因非常多,非0的数字也非常多,不同的非0值,一方面表示失败,一方面表示失败的原因,那不同的非0值代表什么意思?它们都有对应的错误描述,为了人进行识别,我们一般把错误描述当成string,那么,我们打印一下这些错误对应的字符串:

#include <stdio.h>
#include <unistd.h>
#include <string.h>
int main()
{
  int errcode;
  for(errcode = 0;errcode<=255;errcode++)
  {
    printf("%d:%s\n",errcode,strerror(errcode));
  }
  return 100;
}

那么父进程bash为什么要得到子进程的退出码呢?因为父进程要知道子进程退出的情况(成功,失败,失败的原因),最终是用户要知道这些情况,要为用户负责!比如:

我们随便执行一个命令,bash会报错,告诉用户错误的原因,错误码2对应的错误描述就是No such file or directory。(子进程给父进程bash说错误码,bash根据错误码找到对应的错误描述,显示给用户,直到用户下一步该怎么操作)

退出码可以使用默认,也可以自定义!比如:

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

enum 
{
  Success = 0,
  Div_zero,
  Mod_zero,
};

int exit_code=Success;

const char* CodeToErrString(int code)
{
  switch(code)
  {
    case Success:
      return "Success";
    case Div_zero:
      return "div zero";
    case Mod_zero:
      return "mod zero";
    default:
      return "unknown error!";
  }
}

int Div(int x,int y)
{
  if(0 == y)
  {
    exit_code = Div_zero;
    return -1;
  }
  else 
  {
    exit_code = Success;
    return x/y;
  }

}

int main()
{
  int result = Div(10,0);
  printf("result:%d [%s]\n",result,CodeToErrString(exit_code));
  result = Div(10,100);
  printf("result:%d [%s]\n",result,CodeToErrString(exit_code));
  return exit_code ;
}

那么,进程终止的3种情况:

a.代码跑完,结果正确

b.代码跑完,结果不正确

结果正确与否,可以通过进程的退出码决定!!

c.代码执行时,出现了异常,提取退出了

vs编程运行时,崩溃了,原因是操作系统发现了你的进程做了不该做的事情,OS杀掉了进程。一旦出现异常,退出码就没有意义了!

c这种情况,比如:

int main()
{
  int* p=NULL;
  while(1)
  {
    printf("I am process,pid:%d\n",getpid());
    sleep(1);
    *p = 1;
  }

  return 0;
}

会报这样一个段错误,原因是OS识别到野指针,对进程发出了终止信号。

下面是kill的一些指令,上面的报错实际是OS对进程发送11号指令,

我们来实验一下:

我们发现,报出了同样的错误。

我们可以看进程退出的时候,退出信号是多少,就可以判断我的进程为什么异常了!!!这和上面的退出码有异曲同工之妙,可以判断进程因为什么异常退出。

当程序退出时,我们的判断顺序为:

1.先确认是否异常,如果异常,则看退出信号是多少

2.如果不是异常,就一定是代码跑完了,看退出码就行

因此,我们可以得出以下结论:衡量一个进程退出,我们只需要两个数字:退出码和退出信号

对于这两个数字,一共有4种组合,退出码和退出数字分别是:0和0、非0和0、0和非0、非0和非0,第一种是正常退出,第二种是代码没出异常但结果不对,第三和第四种是进程异常,退出码是多少已经无意义。

由于当前进程是由父进程创建的,因此,当前进程退出时,父进程需要获取子进程退出信息,只需要将这两个数字告知父进程即可。

那当子进程退出时,怎么能让父进程读到子进程退出信息呢?当子进程退出时(僵尸状态),其PCB要维持一段时间(PCB种包含int exit_signal,int exit_code),以便父进程读取,

如何终止?

1.main函数return,表示进程终止(非main函数,return,函数结束)。

2.代码调用exit函数

exit函数的作用是引起正常的程序终止,exit的参数就相当于main函数的return值。注意:在代码的任意位置调用exit,都表示进程退出!

3._exit,这是一个系统调用函数,用于终止调用_exit的进程,其作用和exit几乎完全一样,唯一不同的是,exit在进程退出的时候,冲刷缓冲区,_exit不会。

exit是库函数,而_exit是系统调用,exit内部调用了_exit,所以缓冲区不在内核缓冲区,否则_exit也应该能刷新缓冲区。所以缓冲区只能在_exit之上(系统调用之上),_exit看不到缓冲区。

进程等待

我们先输出一个结论:任何子进程,在退出的情况下,一般要被父进程进行等待,进程在退出的时候,如果父进程不管不顾,就退出进程,就等会导致子进程一直为僵尸状态(Z),会存在内存泄漏的问题

因此,为什么要进程等待呢?

1.父进程通过等待,解决子进程退出的僵尸问题,回收系统资源(一定要考虑的)

2.获取子进程的退出信息,知道子进程是因为什么原因退出的(可选的功能)

进程等待的方法

wait方法

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);

返回值:成功返回被等待进程pid,失败返回-1
参数:输出型参数,获取子进程退出状态,不关心则可以设置成为NULL 

等待父进程的任何一个子进程退出。 如果子进程没有退出,父进程其实一直在进行阻塞等待!

子进程本身就是软件,父进程的本质就是在等待某种软件条件就绪,如何理解阻塞等待子进程呢?就是把父进程的状态设为S状态,父进程的PCB链入子进程的队列中!此时父进程在等待子进程,一旦子系统退出了,OS调度时,发现子进程退出了,OS就直接把子进程PCB里面的进程队列中的父进程唤醒,wait再返回,继续向后执行,不就把子进程的退出结果拿到了吗!所以,阻塞等待子进程的本质就是把父进程状态设为非运行状态,把对应PCB链入到子进程当中,此时就叫做父进程在阻塞等待。

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:

是一个输出型参数,类似int a;scanf("%d",&a)中的a,输出子进程的退出信息。

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

WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)

options:

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

如果子进程已经退出,调用wait/waitpid时,wait/waitpid会立即返回,并且释放资源,获得子进程退出信息。

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

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

获取子进程status

因为子进程退出时,退出情况也就3种,为了能让父进程得知子进程是因为什么退出,因此,我需要的信息是进程退出码+退出信号

我们来思考一下,有必要有这样一个status的输出型参数吗?定义exit_code和exit_signal两个全局变量,当子进程退出时,不就可以让父进程拿到吗?答案是不可以!当子进程退出时,当然可以设置这两个全局变量,但是父进程根本看不到,因为父子进程具有独立性,当子进程临退出时修改,OS会发生写时拷贝。所以,只能通过输出型参数得到退出信息。

下一个问题,我们想要拿到进程退出码+退出信号两个数,所以不能把status当成一个整数,它有自己特定的格式。具体如下:

status是一个int类型,共32位,只使用其低16位,次8位(退出码)(8-15)表示退出状态(0-255),最低7位(0-6)表示终止信号(退出信号)。

为了方便查看status返回结果所代表的含义,有两个宏:

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

WEXITSTATUS(status): 若WIFEXITED非零,提取子进程退出码。(查看进程的退出码)

等待子进程退出是必须的,但是获取退出信息不是必须得。


如果子进程没有退出,而父进程在执行waitpid进行等待,其实是在阻塞等待(类似scanf),是进程阻塞,状态从R状态变为非R状态,把PCB从运行队列移动到其他的等待队列中,不被调度就可以了。所以,与其说waitpid在等待子进程退出,倒不如说我们在等待某种条件发生(子进程退出)。

其实,当父进程在上面所说的阻塞等待时,父进程什么都没有干,一直在等待,那么我们想让父进程在等待的时候做一些事情,这就是非阻塞等待。通过设置waitpid的第三个参数options就可以实现,将这个参数设置为WNOHANG就可以。

在非阻塞等待中,WEXITSTATUS(status)的返回值:

pid_t > 0 : 等待成功的,子进程退出了,并且父进程回收成功

pid_t < 0 : 等待失败了。

pid_t == 0 : 检测是成功的。

 使用waitpid非阻塞等待时,每次只能等待一次,因此要加上循环:

非阻塞等待 + 循环 = 非阻塞轮询

在非阻塞轮询时,允许父进程做一些其他的事情。         

下面是验证非阻塞等待的代码:

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

void ChildRun()
{
  //int* p= NULL;
  int cnt=5;
  while(cnt--)
  {
    printf("I am child process,pid:%d,ppid:%d,cnt:%d\n",getpid(),getppid(),cnt);
    sleep(1);
    //*p = 1;
  }
}


int main()
{
  printf("I am father process,pid:%d,ppid:%d\n",getpid(),getppid());
  pid_t id = fork();
  if(id == 0)
  {
    //child
    ChildRun();
    printf("child quit...\n");
    exit(123);
  }
  //father
  while(1)
  {
    int status = 0;
    pid_t rid = waitpid(id,&status,WNOHANG);// no block
    if(rid == 0)
    {
      printf("child is running.father check next time!\n");
      sleep(1);
      //doOtherThing();
    
    }
    else if(rid > 0)
    {
      if(WIFEXITED(status))
      {
        printf("child quit success,child exit code:%d\n",WEXITSTATUS(status));
      }  
      else 
      {
        printf("child quit unnormal!/n");
      }
      break;
    }
    else 
    {
      printf("waitpid failed!\n");
      break;
    }
  }
  return 0;
}

进程程序替换

替换原理

用fork创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),子进程往往要调用一种exec函数以执行另一个程序。当进程调用一种exec函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动例程开始执行。调用exec并不创建新进程,所以调用exec前后该进程的id并未改变。

我们自己的代码编译运行后变成一个进程,OS会为当前进程创建task_struct,创建地址空间,构建页表,建立各种堆、栈、数据段、代码段各种映射关系,当进程开始时,进程本身的代码已经执行起来了,当我们执行execl的时候,把老程序的代码用新程序的代码覆盖掉,把老程序的数据用新程序的数据覆盖掉,所以,进程替换的本质就是将当前进程的代码和数据进行替换的一种技术,在替换的时候,不会创建新进程,站在被替换进程的角度:本质就是这个程序被加载到内存中了!那么是怎么加载的呢?exec*类似于Linux上的加载函数,通过这个函数进行加载。加载的本质其实是把数据从一个设备拷贝到另一个设备,这个过程必须由操作系统来参与,因为操作系统是内存的管理者,由此,我们可以推断出exce*肯定是系统调用或者包含系统调用,exec*系列的函数,执行完毕之后,后续的代码不见了,因为被替换了execl函数的返回值可以不用关心了,只要替换成功,就不会向后继续运行,反之,只要继续运行了,一定是替换失败了! 

下面是进程替换的代码:

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

int main()
{
  printf("testexec ... begin\n");
  execl("/usr/bin/ls","ls","-l","-a",NULL);
  printf("testexec ... end\n");

  return 0;
}

将代码改成多进程版

上面是单进程进行替换,会把所有的代码全替换了,所以一般在执行程序替换时,有没有一种可能,我要程序替换,还不影响我的父进程本身,那么我们可以使用fork创建子进程,让子进程自己去替换,父进程等待就可以了,这样既可以完成任务,又可以使父进程不受影响。

创建子进程时,让子进程完成任务:

1.让子进程执行父进程代码的一部分

2.让子进程执行一个全新的程序(程序替换带来的)(为代码的数据都发生写时拷贝)

实现代码如下:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
  printf("testexec ... begin\n");

  pid_t id = fork();
  if(id == 0)
  {
    sleep(2);
    //child 
    execl("/usr/bin/ls","ls","-l","-a",NULL);
    exit(1);
  }
  //father
  int status=0;
  pid_t rid = waitpid(id,&status,0);
  if(rid > 0)
  {
    printf("father wait success,child exit code:%d\n",WEXITSTATUS(status));
  }
  printf("testexec ... end\n");

  return 0;
}

使用所有的替换方法,并且认识函数参数的含义

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

#include <unistd.h>

第1个: 

int execl(const char *path, const char *arg, ...);

这个函数名中的l代表列表,第一个参数path:我们执行的程序,需要带路径(怎么找到程序,你得告诉我),代表的是你想执行谁;剩下的参数:在命令行中怎么执行,你就怎么传参,代表的是你想怎么执行它,比如我们在命令行中执行的是“ls -l -a”,那么就要这样:execl("usr/bin/ls","-l","-a",NULL),当我们把命令传完了,必须以NULL结尾, 

第2个: 

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

函数名中的v有点像vector,后面的参数argv表示指针数组,可以把上面所说的“ls”、“-l”、“-a”放到一个数组里,再通过agrv传进来。

char*const argv[]=
{
  (char*)"ls",
  (char*)"-l",
  (char*)"-a",
  (char*)"--color",
  NULL 
};
execv("/usr/bin/ls",argv);

第3个和第4个: 

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

函数名中带p的含义是:用户可以不传要执行的文件路径(但是文件名要传),直接告诉exec*,我要执行谁就行,查找这个程序,系统会自动在环境变量PATH中进行查找。

char*const argv[]=
{
   (char*)"ls",
   (char*)"-l",
   (char*)"-a",
   (char*)"--color",
   NULL 
};
execvp("ls",argv);

第5个和第6个:

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

函数名中的e是环境变量的意思,最后一个参数envp是要整体替换所有的环境变量。

其实,上面6个函数接口都是在3号手册中,2号手册为系统调用,这就说明上面这6个都不是系统调用,这6个函数其实是在C语言层面做了一个封装,真正的系统调用是execve,(使用的2号手册)

除了替换系统命令外,我们也可以替换自己写的可执行程序,如C++、python、Java和shell脚本等:

替换C++程序:

#include <iostream>
#include <unistd.h>
#include <sys/types.h>
using namespace std;

int main()
{
  cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
  cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
  cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
  cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
  cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
  cout<<"Hello C++,I am a C++ pragma!:"<<getpid()<<endl;
  return 0;
}
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
  printf("testexec ... begin\n");

  pid_t id = fork();
  if(id == 0)
  {
    printf("child pid:%d\n",getpid());
    sleep(2);
    execl("./mypragma","mypragma",NULL);

  return 0;
}

从上面的执行结果可以看出,C++程序替换成功,在程序替换时,并没有创建新进程。

在Linux中,所有的脚本语言都有其对应的解释器,解释器本身是用C/C++写的,它们是边解释边读取文件内容、边翻译边运行,编译器就相当于一个可执行程序,

在执行程序时,

python3后面的test.py就是命令行参数,把这个命令行参数传进去就知道要执行哪个文件了,然后一行行执行,

 这个shell脚本也是如此,所有的语言写出来的程序,只要在Linux或Windows下跑,都直接间接变成进程,只要是个进程,就能被替换

下面是使用execvpe中的envp参数替换的例子:

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
int main()
{
  printf("testexec ... begin\n");

  pid_t id = fork();
  if(id == 0)
  {
    char *const argv[]=
    {
     (char*) "mypragma",
      NULL 
    };
    char *const envp[] = 
    {
     (char*)"HAHA=111111",
     (char*)"HEHE=222222",
      NULL 
    };
    extern char** environ;
    printf("child pid:%d\n",getpid());
    sleep(2);
    execvpe("./mypragma",argv,environ);//bash的环境变量
    //execvpe("./mypragma",argv,envp);//自定义的环境变量
    exit(1);
  }
  return 0;
}

当我们使用execvpe传递环境变量时,可以有三种方式:

1.用全新的环境变量给子进程

2.用老的环境变量给子进程(即environ)

3.用老的环境变量稍微修改,给子进程

对于3,使用putenv函数给老的环境变量添加新的环境变量。在子进程中,putenv("HHHH=1111111111111111"); 即可。

事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve,所以execve在man手册第2节,其它函数在man手册第3节。这些函数之间的关系如下图所示:


网站公告

今日签到

点亮在社区的每一天
去签到