Linux系统编程之进程控制(进程创建、终止、等待及替换)
进程创建
在上一节讲解进程概念时,我们提到fork函数是从已经存在的进程中创建一个新进程。那么,系统是如何创建一个新进程的呢?这就需要我们更深入的剖析fork函数。
1.1 fork函数的返回值
调用fork创建进程时,原进程为父进程,新进程为子进程。运行man fork
后,我们可以看到如下信息:
#include <unistd.h>
pid_t fork(void);
fork函数有两个返回值,子进程中返回0,父进程返回子进程pid,如果创建失败则返回-1。
实际上,当我们调用fork后,系统内核将会做:
- 分配新的内存块和内核数据结构(如task_struct)给子进程
- 将父进程的部分数据结构内容拷贝至子进程
- 添加子进程到系统进程列表中
- fork返回,开始调度
1.2 写时拷贝
在创建进程的过程中,默认情况下,父子进程共享代码,但是数据是各自私有一份的。如果父子只需要对数据进行读取,那么大多数的数据是不需要私有的。这里有三点需要注意:
第一,为什么子进程也会从fork之后开始执行?
因为父子进程是共享代码的,在给子进程创建PCB时,子进程PCB中的大多数数据是父进程的拷贝,这里面就包括了程序计数器(PC)。由于PC中的数据是即将执行的下一条指令的地址,所以当fork返回之后,子进程会和父进程一样,都执行fork之后的代码。
第二,创建进程时,子进程需要拷贝父进程所有的数据吗?
父进程的数据有很多,但并不是所有的数据都要立马使用,因此并不是所有的数据都进行拷贝。一般情况下,只有当父进程或者子进程对某些数据进行写操作时,操作系统才会从内存中申请内存块,将新的数据拷写入申请的内存块中,并且更改页表对应的页表项,这就是写时拷贝。原理如下图所示:
第三,为什么数据要各自私有?
这是因为进程具有独立性,每个进程的运行不能干扰彼此。
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);
}
代码2
#include<stdio.h>
#include<unistd.h>
int main()
{
printf("Hello world");
_exit(0); }
相比于_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比特位)。
我们以下一段代码为例,来展示一下非阻塞等待方式
#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将变成子进程的进程号,退出循环等待。最终的运行结果如下:
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
SHELL
当前 Shell,它的值通常是/bin/bash。
TERM
当前终端类型
HOME
当前用户主目录的路径,很多程序需要在主目录下保存配置文件,使得每个用户在运行该程序时都有自己的一套配置。
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 环境变量的组织形式
environ 变量是一个char * 类型,存储着系统的环境变量。*每个程序都会收到一张环境表,环境表是一个字符指针数组,每个指针指向一个以’\0’结尾的环境字符串。
4.3 exec函数族
4.3.1 exec函数族的使用
知道了环境变量的概念后,再简要介绍一下命令行参数。当我们在某个目录下输入ls -a
和ls -l
时,会有如下显示:
我们发现,同样的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读取命令和分析命令就是一个很典型的例子,如下图所示:
我们平时输入的如ls -a
等命令实际上是一个个可执行程序。当shell读取一行命令时,shell会对命令进行解析,并且shell创建一个子进程,再通过调用execve,用可执行程序替换掉子进程,当程序执行完毕并且退出后,shell读取子进程的退出信息。这样,即便会出现程序崩溃的情况,也不会影响到shell本身。
以上就是关于进程控制的内容,主要分为四个方面——进程创建,进程终止,进程等待以及进程替换。有了以上的知识,我们已经可以实现一个很简易的shell,如何实现,请读者自行思考!