文章目录:
- 一、进程终止
- 进程退出码
- 常见的进程退出方法
- exit函数
- _exit函数
- return
- exit vs _exit vs return
- 二、进程等待
- 进程等待的必要性
- 进程等待的方法
- wait
- waitpid - 从子进程获取状态信息
- 如何获取子进程status
- 进程的阻塞等待和非阻塞等待
- 三、进程程序替换
- 替换原理
- 进程替换函数
一、进程终止
在使用 Linux 系统时,我们的进程可能会终止/退出,我们则需要弄清楚进程退出的原因。是正常退出还是故障退出。
进程退出的三种场景:代码运行完毕且结果正确、代码运行完毕且结果不正确、代码异常终止。
进程退出码
在 Linux 系统中,进程退出码(也称为返回码)表示命令或脚本执行后的响应,是可执行程序返回给父进程的代码。取值范围是 0 ~ 255 ,0 表示成功执行,范围内的任何非零退出码表示发生错误。我们可以使用退出码来了解在进程执行期间是否出现了错误或者其它意想不到的情况。🎭
当命令或脚本执行完成之后,我们可以使用 echo $?
来查看最近一个退出进程的退出码;
Linux 文档项目中有一个保留代码列表,它提供了针对特定场景使用哪些代码的建议。一下是 Linux 或 Unix 中的标准错误代码:
错误代码 | 对应信息 |
---|---|
1 | Catchall for general errors |
2 | Misuse of shell builtins(according to bash documentation) |
126 | Command invoked cannot execute |
127 | Command not found |
128 | Invalid argument to exit |
128+n | Fatal error signal “n” |
130 | Script terminated by Control-C |
255\* | Exit status out of range |
程序退出码都有其特定的含义,可以帮助定位程序所出现的错误。这些退出码是可以由写程序的人自己定义其含义的,并不是固定的。
常见的进程退出方法
正常终止(可以通过 echo $?
查看进程退出码)
- 从main函数中返回
- 调用exit
- 调用_exit
异常退出:
- ctrl + c :信号终止
exit函数
在 Linux 系统下,exit 命令用于退出当前运行的 shell。它可以在 Linux CLI 中使用,也可以在 shell 或 bash 中使用。它接受一个参数 N,并返回状态 N 退出 shell。如果没有提供 N ,那么它返回最后执行的命令的状态。
#include<unistd.h>
void exit(int status);
在程序中,exit函数可以在代码的任意一个位置退出进程,在调用 exit 函数前,做了以下工作:
- 执行用户通过 atexit 或 on_exit 定义的清理函数。
- 关闭所有打开的流,所有的缓存数据均被写入。
- 调用 _exit 函数。
以下代码中的 exit 函数在终止前会将缓冲区中的数据刷新出来:
_exit函数
_exit 和 exit 类似,可以使代码在任意位置终止,但是 _exit 会直接终止进程,不会对进程进行推出前的处理工作。
#include<stdio.h>
void _exit(int status);
// 参数:status 定义了进程终止状态,父进程通过 wait 来获取该值
// 说明:虽然 status 是 int,但是仅有低8位可以被父进程使用。所以 _exit(-1) 时,在终端执行 $? 时返回值是255.
以下代码演示使用 _exit 终止进程,进程结束,缓冲区中的数据不会被刷新出来:
return
return 是一种很常见的进程退出方法。执行 return num; 等同于执行 exit(num),因为调用 main 运行时函数会将 main 的返回值当作 exit 的参数。
exit vs _exit vs return
return 是关键字,exit() 和 _exit()是函数。🎯
return 表示函数返回,而 exit() 和 _exit() 表示程序的退出。return 和 exit 在 main 函数中的作用是一样的,退出程序并将返回值给操作系统。而在普通函数中,return 返回值给上层调用函数,exit 则退出程序并返回到操作系统。🎯
在 main 函数中,return 和 exit 都需要执行标准 I/O 库的清理、关闭流等,然后进入内核。而 _exit 函数是直接进入内核,不做任何清理相关操作。🎯
二、进程等待
进程等待的必要性
- 子进程退出,如果父进程不管,那么可能造成 “僵尸进程的问题” ,从而造成内存泄漏。
- 进程一旦变成僵尸状态,kill -9 命令也杀不掉,该进程就会一直处于僵尸状态。
- 父进程需要知道派给子进程的任务完成得怎么样,结果是否正确等。
- 父进程通过进程等待的方式,回收子进程资源,获取子进程的退出信息。
进程等待的方法
wait
bash wait 命令是一个 shell 命令,用于等待后台运行进程完成并返回退出状态。与等待指定时间的 sleep 命令不同,wait 命令等待所有或特定的后台任务完成。
#include<sys/wait.h>
#include<sys/types.h>
pid_t wait(int *status); // 等待子进程停止或者终止
返回值:wait 调用成功执行返回等待进程的 pid,失败则返回 -1。
参数:输出型参数,获取子进程的退出状态,不关心则可以设置为 NULL。
如下所示,创建一个子进程,子进程运行并打印三次语句退出,父进程使用 wait 函数一直等待子进程退出并读取子进程的退出信息:
#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)
{
// 让子进程打印3次之后退出
int count = 3;
while(count)
{
printf("I am a child process! PID : %d --- %d\n",getpid(),count--);
sleep(1);
}
exit(0);
}
int status = 0;
printf("I am a parent process,Prepare to wait for the child process. PID : %d\n",getpid());
pid_t ret = wait(&status);
if(ret > 0)
{
printf("wait child process sucess!\n");
if(WIFEXITED(status))
{
printf("exit code : %d\n",WEXITSTATUS(status));
}
}
sleep(3);
return 0;
}
这里我们可以使用监控脚本对进程所处的状态进行实时查看:
while :; do ps axj | head -1 && ps axj | grep test | grep -v grep | grep -v Ssl;echo "---------------------------------";sleep 1;done
运行结果如下:子进程退出之前,父子进程都处于 S+ 状态,当子进程退出后,父进程回收子进程。父进程等待三秒中之后,父进程也被回收。
waitpid - 从子进程获取状态信息
#include<sys/wait.h>
pid_t waitpid(pid_t pid,int *status,int options);
返回值:
当正常返回的时候 waitpid 返回收集到的子进程的进程 ID;
如果设置了选项 WNOHANG ,而调用中 waitpid 发现没有以退出的子进程可收集,则返回0 ;
如果调用出错,则返回 -1,这时 error 会被设置成相应的值以指示错误所在。
参数:
-
pid:
pid = -1,等待任意一个子进程,与 wait 等效。
pid > 0,等待其进程 ID 与 pid 相同的子进程。 -
status:
WIFEXUTED(status):若为正常终止子进程返回的状态,则为真。(查看进程是否正常退出)
WEXITSTATUS(status):若 WIFEXITED 非零,提取子进程退出码。(查看进程的退出码) -
options:
WNOHANG:若 pid 指定的子进程没有结束,则 waitpid() 函数返回 0 ,不予以等待。若正常结束,则返回该子进程的 ID。
说明:
- 若子进程已经退出,调用 wait/waitpid 时,wait/waitpid 会立即返回,并且释放资源,获取子进程的退出信息。
- 若在任意时刻调用 wait/waitpid ,子进程存在且正在运行,则进程可能发生阻塞。
- 若不存在该子进程,则立即出错返回。
如下所示,创建一个子进程,父进程使用 waitpid 等待子进程,等到子进程退出并读取子进程的退出信息:
#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)
{
fprintf(stderr,"Fork failed !\n");
exit(-1);
}
else if(id == 0)
{
int count = 2;
while(count)
{
printf("I am a child process!PID:%d --- %d\n",getpid(),count--);
sleep(1);
}
printf("Done from child process!\n");
exit(15); // 使用特殊退出码,观察父进程接收到的信息
}
else
{
int status = 0;
printf("I am a parent process.Prepare to wait for the child process!PID:%d\n",getpid());
pid_t ret = waitpid(id,&status,0); // 这里的第三个参数暂时设置为0
if(ret > 0)
{
if(WIFEXITED(status))
{
printf("Child process exit normally,exit code : %d\n",WEXITSTATUS(status));
}
}
}
return 0;
}
运行结果如下,进程正常退出,子进程的退出码和设置的一样。
如何获取子进程status
上面所说的 wait/waitpid 两个函数,其中都有一个参数 status,该参数是一个输出型参数,有操作系统填充。如果传入的参数 status 是 NULL,则表示不关心子进程的退出状态信息。否则,操作系统会更具该参数,将子进程的退出信息反馈给父进程。status 虽然是一个整形变量,但是不能简单的当作整形来看待,可以当作位图来看,具体如下所示(只关注 status 的低16个比特位):
若进程正常终止,则在 status 的16个比特位中,前8位表示进程的退出状态(进程退出码)。若进程非正常终止,而是被信号所杀,那么 status 的低7位表示终止信号,第8位则是 core dump 标志。
如何获取参数 status 的退出码和退出信号呢?
exitCode = (status>>8)&0xFF; // 进程退出码
exitSignal = status&0x7F; // 进程退出信号
系统为了简化使用成本,提供了两个宏来提取进程退出码和退出信号:
WIFEXITED(status):通过检查是否收到信号来判断进程是否正常退出。
WEXITSTATUS(status):获取进程的退出码。
🧩进程终止信号?
我们可以使用命令 kill -l
来查看终止信号列表:
当一个进程退出时,终止信号为0,说明进程是正常退出。若进程被信号所杀,那么对应的信号就是进程终止的原因。
注意:当进程由于信号原因退出时,这时的退出码是没有任何意义的。
进程的阻塞等待和非阻塞等待
阻塞和非阻塞是当进程访问数据时,根据 IO 操作的就绪状态不同而采取的不同处理方式,例如父进程等待子进程,阻塞方式下父进程会一直等到子进程退出读取完数据再继续往下执行;非阻塞方式下,父进程在等待子进程的过程中可以做自己的事,当子进程退出时,父进程再读取子进程的退出信息。
进程的阻塞等待方式:
#include<stdio.h>
#include<stdlib.h>
#include<unistd.h>
#include<sys/types.h>
#include<sys/wait.h>
#include<errno.h>
int main()
{
pid_t id = fork();
if(id < 0)
{
perror("fork false");
exit(1);
}
if(id == 0)
{
printf("I am a child process,pid : %d\n",getpid());
sleep(10);
exit(17);
}
else
{
int status = 0;
int ret = wait(&status);
if(ret>0&&(status&0x7F)==0) // 进程正常退出
{
printf("child process exit code : %d\n",(status>>8)&0xFF);
}
else if(ret > 0) //进程异常
{
printf("signal code : %d\n",status&0x7F);
}
}
return 0;
}
进程的非阻塞等待方式:
如何让父进程进行非阻塞等待呢?在向 waitpid 传入参数时,将第三个参数传入 WNOHANG 。非阻塞等待时,若子进程没有结束,waitpid 函数将返回0,那么父进程不予以等待。当子进程结束时,waitpid 函数将返回子进程的 PID ,父进程则读取子进程的退出信息。
如下所示:
#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)
{
printf("fork false\n");
exit(1);
}
else if(id == 0)
{
printf("I am a child process! PID : %d\n",getpid());
sleep(10);
exit(1);
}
else
{
// 基于非阻塞的轮询等待方案
int status = 0;
while(1)
{
pid_t ret = waitpid(-1,&status,WNOHANG);
if(ret == 0)
{
printf("子进程还没有退出!父进程可以做其它事情!\n");
sleep(1);
}
else if(ret == id&&WIFEXITED(status))
{
printf("wait child process success,exit code : %d,exit signal : %d\n",status&0x7F,(status>>8)&0xFF);
break;
}
else
{
printf("wait child failed,return.exit code : %d\n",status&0x7F);
return 1;
}
}
}
return 0;
}
三、进程程序替换
替换原理
fork 创建子进程之后执行的是和父进程相同的程序(也可能执行的是不同的代码分支),子进程往往需要调用 exec 函数执行另一个程序。当进程调用一种 exec 函数时,该进程的用户空间代码和数据完全被新程序替换,从新程序的启动进程开始执行。调用 exec 并不创建新的进程,所以调用 exec 之后依旧是原来的进程。
- 进程替换不会创建新的进程,进程替换是将该进程的数据和代码替换为指定的可执行程序。而进程依旧是原来的进程,PCB没有改变。
- 进程替换成功之后,替换函数之后的代码不会执行,因为进程替换将原来的整个代码和数据覆盖了。替换之后原来的代码就没了,若进程替换失败,则替换函数之后的代码任然可以执行。
- 进程替换成功后,退出码为替换后的进程退出码。
为什么需要进行进程程序替换? |
fork 创建子进程后,创建的子进程要么和父进程执行相同的代码片段,要么执行不同的代码分支。但是这样的使用不具有灵活性。例如:使用 c++ 程序将 Java 程序调动起来。即很多的程序功能已经用另外的程序写好了,就不需要在父进程中控制子进程执行不同的代码分支,可以直接用一个已有的程序替换掉子进程,使子进程完全执行所替换程序的功能。
子进程进行程序替换,会不会影响父进程的代码和数据? |
不会。子进程被创建后与父进程共享代码和数据,但当子进程进行代码和数据的更改或写入时,这时使用写时拷贝的方式分离父子进程的相应代码。因此,当子进程进行程序替换时,意味着子进程对数据和代码进行写入,因此需要将与父进程共享的代码和数据进行写时拷贝,分离父子进程的代码。所以子进程执行进程程序替换是不会影响父进程的。
进程替换函数
Linux 中的 exec 命令用于从 bash 本身执行命令。此命令不会创建新的进程,它只是将 bash 替换为要执行的命令。若 exec 命令执行成功,则不返回调用进程。在 bash 和 ksh 等 shell 中,它还用于重定向一个完整会话或整个脚本的文件描述符。
exec 函数族说明:
当我们需要子进程去执行另外的程序时,exec 函数就提供了在一个程序中调动另外一个程序的方法(进程需要调用 exec 函数族中某一个函数来进行程序替换)。当调用 exec 函数时,该进程的代码和数据完全被新程序替换,从新程序的启动例程开始执行。
其中有六种以 exec 开头的函数,统称为 exec 函数:
下面,我们以 execl 为例子来讲解一下使用方法:
int execl(const char *path,const char *arg, ...);
第一个参数传入的是你将要替换的进程的路径(首先需要找到所替换的程序的路径),第二个参数是可变参数列表(怎么执行这个程序,即携带选项),需要注意的是,exec 函数族需要以 NULL 进行结尾。
代码示例如下:使用 execl 函数调用 ls 命令。
接下来我们说明一下每一个函数如何使用:
// 带l的,需要将执行程序的选项一个个列出来,最后以NULL结尾
// 带p的,可以自己使用环境变量PATH找到路径,无需写路径
// 带e的,可以自己导入环境变量
int execl(const char* path,const char* arg,...);
execl("/usr/bin/ls","ls","-al",NULL);
int execlp(const char* file,const char* arg,...);
execlp("ls","ls","-al",NULL);
int execle(const char* path,const char* arg,...,char* const envp[]);
char* envp_[] = {MYENV=1234,NULL}; //这是自己设置的环境变量
execle("./process","process",NULL,envp_);
int execv(const char* path,char* const argv[]);
char* argv_[] = {"ls","-a","-l",NULL};
execv("/usr/bin/ls",argv_);
int execvp(const char* file,char* const argv[]);
char* argv_[] = {"ls","-a","-l",NULL};
execvp("ls",argv_);
int execvpe(const char* file,char* const argv[],char* const envp[]);
char* argv_[] = {"process",NULL};
char* envp_[] = {"MYENV=1234",NULL};
execvpe("process",argv_,envp_);
还有一个特殊的:execve
此接口和上面的六个接口使用方法是一样的,此接口是系统接口,上面的接口都是对系统接口的封装。所以 execve 在 man 手册的第2节,其它函数在第 3 节。设计这么多接口的意义在于,在面对不同场景时可以选择合适的接口,简化使用的成本。
函数解释:
- 这些函数如果调用成功则加载新的程序从启动代码开始执行,不再返回。
- 如果调用失败则返回-1。
- 因此调用 exec 函数只有出错才有返回值,调用成功是没有返回值的,因为调用成功程序已经被替换了。
命名解释:
exec 族的函数有7个,我们应该怎样才能记住这些函数呢?实际上每一个函数的命名都是有规律的,它们都以 exec 开头,后面的字符代表下面的意义,理解了就记住了。
- 【l】 list:表示传参数需要使用列表的形式
- 【v】vector:使用数组进行传参
- 【p】path:p可以自动搜索环境变量PATH,不需要具体传路径
- 【e】env:表示自己维护环境变量
函数名 | 参数格式 | 是否带路径 | 是否使用当前环境变量 |
---|---|---|---|
execl | 列表 | 否 | 是 |
execlp | 列表 | 是 | 是 |
execle | 列表 | 否 | 否,需要自己组装环境变量 |
execv | 数组 | 否 | 是 |
execvp | 数组 | 是 | 是 |
execve | 数组 | 否 | 否,自己组装环境变量 |