Loading

Linux系统编程之进程控制(进程创建、终止、等待及替换)

进程创建

在上一节讲解进程概念时,我们提到fork函数是从已经存在的进程中创建一个新进程。那么,系统是如何创建一个新进程的呢?这就需要我们更深入的剖析fork函数。

1.1 fork函数的返回值

调用fork创建进程时,原进程为父进程,新进程为子进程。运行man fork后,我们可以看到如下信息:

#include <unistd.h>
pid_t fork(void);

fork函数有两个返回值,子进程中返回0,父进程返回子进程pid,如果创建失败则返回-1。

实际上,当我们调用fork后,系统内核将会做:

  • 分配新的内存块和内核数据结构(如task_struct)给子进程
  • 将父进程的部分数据结构内容拷贝至子进程
  • 添加子进程到系统进程列表中
  • fork返回,开始调度

image-20210815112339172

1.2 写时拷贝

在创建进程的过程中,默认情况下,父子进程共享代码,但是数据是各自私有一份的。如果父子只需要对数据进行读取,那么大多数的数据是不需要私有的。这里有三点需要注意:

第一,为什么子进程也会从fork之后开始执行?

因为父子进程是共享代码的,在给子进程创建PCB时,子进程PCB中的大多数数据是父进程的拷贝,这里面就包括了程序计数器(PC)。由于PC中的数据是即将执行的下一条指令的地址,所以当fork返回之后,子进程会和父进程一样,都执行fork之后的代码。

第二,创建进程时,子进程需要拷贝父进程所有的数据吗?

父进程的数据有很多,但并不是所有的数据都要立马使用,因此并不是所有的数据都进行拷贝。一般情况下,只有当父进程或者子进程对某些数据进行写操作时,操作系统才会从内存中申请内存块,将新的数据拷写入申请的内存块中,并且更改页表对应的页表项,这就是写时拷贝。原理如下图所示:

image-20210815120742835

第三,为什么数据要各自私有?

这是因为进程具有独立性,每个进程的运行不能干扰彼此。

1.3 fork函数的用法及其调用失败的原因

fork函数的用法:

  • 一个父进程希望复制自己,通过条件判断,使父子进程分流同时执行不同的代码段。例如,父进程等待客户端请求,生成子 进程来处理请求。
  • 如子进程从fork返回后,调用进程替换的函数,如exec等(将会在本节4.程序替换中讲解)。

fork函数调用失败的原因:

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

2.进程终止

2.1 进程终止的原因

进程终止的原因有三种

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

2.2 常见的进程退出方法

进程正常终止

1.从main函数return,这是最常见的进程退出方法。在函数设计中,0代表正确,非0代表错误。其中不同的非0的退出码对应了退出原因。

2.调用exit或者_exit

_exit函数是系统调用,执行man _exit可以看到

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

status 定义了进程的终止状态。父进程可以通过wait来获得子进程的status(会在3.进程等待中讲解)。

需要注意的是,

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

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

从作用上来看,_exit和exit是相似的,exit是对_exit的封装,exit的执行实际上是通过调用_exit来实现的。

但是二者也有一些细微的差别,请看如下代码段:

代码1

int main()
{
 printf("Hello world");
 exit(0);
}

image-20210815172538765

代码2

 #include<stdio.h>  
 #include<unistd.h>  
 int main()  
 {  
   printf("Hello world");  
    _exit(0);                                                  }  

image-20210815172816988

相比于_exit函数,exit函数先要执行用户定义的清理函数,在冲刷缓冲区,关闭所有打开的流,将所有的缓存数据写入文件后,再调用_exit。因此我们可以看到,执行exit输出了“hello World",而执行_exit并没有输出。

那么,return和exit有什么区别呢?

在普通函数中,return是用来终止函数的,只有在main函数中才是终止进程,而exit无论在哪里,一旦调用,整个进程就会终止。

3.进程等待

3.1 为什么要有进程等待?

在讲进程概念时我们提到,当子进程退出,父进程如果不管不顾,子进程残留资源(PCB)存放于内核中,就可能会造成僵尸进程。如果该资源不能得到释放,就会导致内存泄漏。僵尸进程是不能使用 kill -9 命令清除掉的。因为 kill 命令只是用来终止进程的, 而僵尸进程已经终止。

同时,父进程派给子进程的任务完成的如何,我们是需要知道的。例如,子进程运行完成,结果对还是不对, 或者是否正常退出。

因此,就需要父进程通过进程等待的方式,回收子进程的资源。

3.2 进程等待的方法

一个进程在终止时会关闭所有文件,释放在用户空间分配的内存,但它的 PCB 还保留着,内核在其中保存了一些信息:如果是正常终止则保存着退出状态,如果是异常终止则保存着导致该进程终止的信号是哪个。当这个进程的父进程调用 wait 或 waitpid 获取这些信息后,才会将这个进程彻底清除掉。

一个进程的退出状态可以在 Shell 中通过运行echo $?查看,因为 Shell 是它的父进程,当它终止时 Shell 调用 wait 或 waitpid 得到它的退出 状态同时彻底清除掉这个进程。

3.2.1 wait函数

#include<sys/types.h>
#include<sys/wait.h>
pid_t wait(int*status);
  • 返回值:成功返回被等待进程pid,失败返回-1。
  • status:是一个输出型参数,将wait函数内部计算的结果通过status返回给调用者,父进程从而获取子进程退出状态,如果不关心子进程的退出状态则可以将参数设置成为NULL。

这里提一下输入型参数和输出型参数的区别,输入型参数是调用者给函数传的参数,而输出型参数是是函数将内部计算结果返回给调用者,因此输出型参数往往用指针。

父进程调用 wait 函数可以回收子进程终止信息。该函数有三个功能:

  • 阻塞等待子进程退出
  • 回收子进程残留资源
  • 获取子进程结束状态(退出原因)。

当父进程调用wait得到传出参数status后,可以借助宏函数来进一步判断进程终止的具体原因:

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

WEXITSTATUS(status): 若WIFEXITED非零,说明子进程正常终止,提取子进程退出码。(查看进程的退出码(exit 的参数))

3.2.2 waitpid函数

作用同 wait,但waitpid可指定 pid 进程清理,可以通过非阻塞方式等待子进程退出。

pid_ t waitpid(pid_t pid, int *status, int options);

pid:

  • pid = -1,等待任一子进程退出,此时与wait等效
  • pid > 0, 回收指定 ID 的子进程,pid为指定进程的进程号。如果不存在该子进程,则立即出错返回

status:

  • 同wait

option:

  • 0:阻塞模式,即父进程会阻塞在waitpid处,等到子进程退出后继续。
  • WNOHANG: 非阻塞模式,若pid指定的子进程没有结束,则waitpid函数返回0,不予以等待。若正常结束,则返回该子进程的ID。一般情况下,非阻塞模式需要搭配循环使用。

注意:一次 wait 或 waitpid 调用只能清理一个子进程,清理多个子进程应使用循环。

返回值:

  • 当正常返回的时候waitpid返回收集到的子进程的进程ID;
  • 如果设置了选项WNOHANG,而调用中waitpid发现没有已退出的子进程可收集,则返回0;
  • 如果调用中出错,则返回-1,这时errno会被设置成相应的值以指示错误所在

3.3.3 子进程的status

关于status的用法,我已经在wait函数处讲解,此处不再赘述。这里将从底层的角度剖析status的含义。

status不能简单的当作整形来看待,可以当作位图来看待,具体细节如下图(只研究status低16比特位)。

image-20210815211524186

我们以下一段代码为例,来展示一下非阻塞等待方式

#include <stdio.h> 
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
int main()
{
   pid_t pid;
    
    pid = fork();
    if(pid < 0){
       printf("%s fork error\n",__FUNCTION__);
        return 1;
         
    }else if( pid == 0  ){ //child
     printf("child is run, pid is : %d\n",getpid());
     sleep(5);
     exit(1);
    } else{
       int status = 0;
       pid_t ret = 0;
       do
       {
       ret = waitpid(-1, &status, WNOHANG);//非阻塞式等待
       if( ret == 0  ){
       printf("child is running\n");
       }
       sleep(1); 
       }while(ret == 0);
          
       if( WIFEXITED(status) && ret == pid  ){
          printf("wait child 5s success, child return codeis:%d.\n",WEXITSTATUS(status)); 
          }else{
          printf("wait child failed, return.\n");
          return 1;
          }
    }
     return 0;
}

这段代码先创建子进程,让子进程等待5s再退出,父进程每1s检查一下,5s后子进程退出,ret将变成子进程的进程号,退出循环等待。最终的运行结果如下:

image-20210815213529132

4.进程替换

4.1进程替换的原理

在讲进程替换原理前,我们需要先知道什么是进程替换。在讲fork函数时我们提到,fork 创建子进程后执行的是和父进程相同的程序(但有可能执行不同的代码分支),如果此时我们用一个新的程序替换掉子进程的地址空间、代码段和数据,子进程将会从新程序的启动例程开始执行,这就是进程替换。

进程替换并不是创建新的进程,因为替换前后该进程的PID并未改变。

4.2 环境变量

进程替换需要用到一种exec函数,在讲exec函数族之前,我们先介绍一下环境变量的概念。

4.2.1常见的环境变量

按照惯例,环境变量字符串都是name=value 这样的形式,大多数 name 由大写字母加下划线组成,一般把name 的部分叫做环境变量,value 的部分则是环境变量的值。

环境变量定义了进程的运行环境,具有全局属性,因此设置环境变量时要加export,一些比较重要的环境变量的含义如下:

PATH

可执行文件的搜索路径。ls 命令也是一个程序,执行它不需要提供完整的路径名/bin/ls, 然而通常我们执行当前目录下的程序 a.out 却需要提供完整的路径名./a.out,这是因为 PATH 环境变量的值里面包含了 ls 命令所在的目录/bin,却不包含 a.out 所在的目录。

PATH 环境变量的值可以包含多个目录,用:号隔开。在 Shell 中用 echo 命令可以查看这个环境变量的值: echo $PATH

image-20210815233743515

SHELL

当前 Shell,它的值通常是/bin/bash。

image-20210815233908961

TERM

当前终端类型

image-20210815234151056

HOME

当前用户主目录的路径,很多程序需要在主目录下保存配置文件,使得每个用户在运行该程序时都有自己的一套配置。

image-20210815234257823

4.2.2与环境变量相关的函数

getenv函数

获取环境变量值: char *getenv(const char *name);

成功:返回环境变量的值;失败:NULL (name 不存在)

setenv 函数

设置环境变量的值 :int setenv(const char *name, const char *value, int overwrite);

成功:返回0;失败: 返回-1

参数 overwrite 取值:

1:覆盖原环境变量

0:不覆盖。(该参数常用于设置新环境变量,如:HELLO = “hello”)

unsetenv 函数

删除环境变量 name 的定义: int unsetenv(const char *name);

成功:0;失败:-1

注意事项:name 不存在仍返回 0(成功)。

4.2.3 环境变量的组织形式

image-20210815235403900

environ 变量是一个char * 类型,存储着系统的环境变量。*每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。

4.3 exec函数族

4.3.1 exec函数族的使用

知道了环境变量的概念后,再简要介绍一下命令行参数。当我们在某个目录下输入ls -als -l时,会有如下显示:

image-20210816003940801

我们发现,同样的ls命令,由于后面所跟的字符串不同,显示了不同的结果。这里的“-a”,“-l”被称为参数。实际上,一个程序内可以通过加入参数,让相同的程序执行不同的功能。

接下来我们来介绍进程替换必不可少的函数族——exec函数族。

其实有六种以 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函数只有出错的返回值而没有成功的返回值

这些函数如何使用,我们来看下面这段代码:

#include <unistd.h>
int main()
{
 char *const argv[] = {"ps", "-ef", NULL};//argv[0]始终是程序名
 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);
}

事实上,只有execve是真正的系统调用,其它五个函数最终都调用 execve。

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

  • l(list) : 表示参数采用列表,如果采用列表形式,const char *arg中的第一个参数必须是可执行程序本身,如上例中的 “ps”。
  • v(vector) : 参数用数组 ,v和l只能二选一
  • e(env) : 表示自己维护环境变量,有e参数中就需要有char *const envp[]
  • p(path) : 有p自动搜索环境变量PATH,第一个参数直接输入程序名即可,且有p一定没有e,因为有表示已经自动添加了环境变量,如果没有p则需要输入对应程序的路径

4.3.2 进程替换的应用

我们平时使用的shell读取命令和分析命令就是一个很典型的例子,如下图所示:

image-20210816011210421

我们平时输入的如ls -a等命令实际上是一个个可执行程序。当shell读取一行命令时,shell会对命令进行解析,并且shell创建一个子进程,再通过调用execve,用可执行程序替换掉子进程,当程序执行完毕并且退出后,shell读取子进程的退出信息。这样,即便会出现程序崩溃的情况,也不会影响到shell本身。

以上就是关于进程控制的内容,主要分为四个方面——进程创建,进程终止,进程等待以及进程替换。有了以上的知识,我们已经可以实现一个很简易的shell,如何实现,请读者自行思考!

posted @ 2021-11-06 09:42  乌有先生ii  阅读(836)  评论(0编辑  收藏  举报