Linux | 进程控制 — 进程终止 & 进程等待
1、进程终止
进程常见退出方法
进程退出场景
- 代码运行完毕,结果正确
- 代码运行完毕,结果不正确
- 代码异常终止
正常终止(可以通过echo $?
查看进程退出码)
1.从
main
返回2.调用exit
3._exit
异常退出:
- ctrl + c,信号终止
1.1退出码
在 Linux 系统中,进程的退出码(也称为返回值)是进程结束时返回给其父进程或系统的值,用于表示进程执行的结果。下面从基本概念、获取方式、常见约定、使用场景等方面详细讲解。
基本概念
- 进程的退出码是一个整数值,范围通常是 0 - 255。在 C 语言编写的程序中,通常通过
main
函数的return
语句或者exit()
函数来设置退出码。例如:
#include <stdio.h>
#include <stdlib.h>
int main() {
// 使用 return 语句设置退出码
return 0;
// 或者使用 exit() 函数
// exit(0);
}
获取退出码的方式
- 父进程获取子进程退出码:父进程可以使用
wait()
或waitpid()
等系统调用获取子进程的退出状态信息,然后通过一些宏来提取退出码。示例如下:
#include <stdio.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
return 42;
} else if (pid > 0) {
// 父进程
int status;
waitpid(pid, &status, 0);
if (WIFEXITED(status)) {
int exit_code = WEXITSTATUS(status);
printf("子进程退出码: %d\n", exit_code);
}
}
return 0;
}
- shell 中获取进程退出码:在 shell 里,可以使用
$?
变量获取上一个执行命令的退出码。例如:
./my_program
echo $?
常见退出码约定
虽然 Linux 没有强制统一所有程序使用特定的退出码,但存在一些被广泛遵循的约定:
- 退出码 0:表示进程正常结束,任务成功完成。这是最常见的退出码,表示程序按预期执行完毕。
- 退出码 1:一般代表通用的错误,当程序遇到一些未明确分类的错误时,常返回这个退出码。
- 退出码 2:通常意味着程序在使用命令行参数时出现了错误,例如参数数量不对、参数格式错误等。
- 退出码 126:表示命令虽然找到了,但由于权限问题或其他原因无法执行。
- 退出码 127:表明命令未找到,可能是因为命令拼写错误或者该命令不在系统的搜索路径中。
- 退出码 128 + N:其中
N
是信号编号。当进程因接收到信号而终止时,退出码通常是128 + 信号编号
。例如,进程因接收到SIGTERM
(信号编号 15)而终止,退出码就是 143(128 + 15)。
使用场景
- 错误处理与调试*:开发人员可以根据退出码快速定位程序出现问题的大致原因。例如,如果程序返回退出码 2,就可以先检查命令行参数的处理逻辑。
- 脚本流程控制:在 shell 脚本中,根据命令的退出码决定后续的操作。比如,如果某个依赖程序执行失败(返回非零退出码),脚本可以选择终止执行或者尝试其他替代方案。
./dependency_program
if [ $? -ne 0 ]; then
echo "依赖程序执行失败,脚本终止"
exit 1
fi
- 系统监控:系统监控工具可以根据进程的退出码判断进程是否正常运行。如果进程频繁以非零退出码结束,可能表示系统存在潜在问题,需要进一步排查。
1.2 strerror函数 & errno宏
头文件:#include<string.h>
返回值:指向描述error errnum
的错误字符串的指针,简单来说可以将退出码和对应的错误对应上。
举例:
#include<string.h>
int main()
{
for(int i=0;i<10;i++){
printf("%d: %s\n",strerror(i));
}
return 0;
}
效果如下,后面输出的就是退出码对应的错误描述:
头文件:#include<errno.h>
简单的说,errno会返回最后的一次错误码,使用errno可以获得退出码,通过返回退出码,在多进程中也可以让父进程知道子进程的状况。
注意:但是当进程异常退出的时候,本质可能就是代码没有跑完,那么进程的退出码就无意义了,所以应该要先看进程退出的时候,如果要关心进程的推出情况,要先关心退出时后有没有出异常,如果没有异常,再看结果是否正确,然后关心退出码。
- 父进程关心子进程的退出,只需要确定:
- 父进程是否收到来自子进程的信号,若没有,说明没有异常,代码正常跑完
- 查看退出结果:0表示成功,非0表示错误,对应各自的原因
举例:
#include<stdio.h>
#include<stdlib.h>
#include<string.h>
#include<errno.h>
int main()
{
int ret = 0;
char *p = (char*)malloc(1000*1000*1000*4);
if(p==NULL){
printf("malloc error, %d: %s\n".errno,strerror(errno));
ret = errno;
}
else{
printf("malloc success\n");
}
return ret;
}
1.3 _exit函数
头文件:#include<unistd.h>
函数格式:void _exit(int status);
exit函数最后也会调用_exit,但是在调用exit之前,还会做以下工作:
1、执行用户通过
atexit
或on_exit
定义的清理函数2、关闭所有打开的流,所有的缓存数据均被写入
3、调用_exit
例1:
int main()
{
printf("hello linux\n");
exit(12);
}
或者:
int main()
{
printf("hello linux\n");
return 12;
}
编译执行完后再使用echo $?
查询退出码,效果均如下:
区别在于:exit在任意地方被调用,都表示调用进程直接退出,如果调用的是return,只表示当前函数返回,原进程继续运行,如果调用一个含exit或者return的函数,就可以明显观察到。
1.4_exit和exit的区别
在 Linux 系统中,exit
和 _exit
都用于终止进程,但它们在功能实现、调用过程以及使用场景等方面存在明显区别,下面为你详细介绍:
1.4.1 所属头文件与函数原型
exit
:它是标准 C 库中的函数,其原型定义在<stdlib.h>
头文件中,函数原型为void exit(int status);
。这里的status
是进程的退出状态码,通常 0 表示正常退出,非零表示异常退出。_exit
:这是一个系统调用,其原型定义在<unistd.h>
头文件中,函数原型为void _exit(int status);
,status
的含义与exit
中的相同。
1.4.2 执行过程差异
-
exit
:- 在调用
exit
时,它会先执行一些清理工作。首先,会调用所有通过atexit
函数注册的清理函数,这些函数可以用于释放资源、关闭文件描述符等操作。 - 接着,会刷新所有打开的标准 I/O 流缓冲区,将缓冲区中的数据写入对应的文件或设备。
- 最后,调用
_exit
系统调用来真正终止进程,并将status
作为退出状态返回给父进程。
- 在调用
-
_exit
:_exit
是一个底层的系统调用,它会直接终止进程,不会执行任何清理工作。也就是说,它不会调用atexit
注册的函数,也不会刷新标准 I/O 流缓冲区。
结合现象分析:
int main()
{
printf("hello world");
sleep(1);//使用sleep能够观察到一些现象,下文会提及
exit(11);
}
运行完毕后再调用echo $?
查看退出码,效果如下:
但是将exit
改为_exit
后:
int main()
{
printf("hello world");
sleep(1);
_exit(11);
}
运行完毕后再调用echo $?
查看退出码,效果如下:
原因:
当代码中输出的内容以\n结尾时,当代码运行到printf这条语句时,程序会直接输出内容,但是如果没有以\n结尾,那么就会先将内容存到缓冲区中,当程序结束前会冲刷缓冲,关闭流,然后就有打印输出的效果,也正因此会发现运行的时候是先等待了一秒钟,输出句子后程序马上结束,而不是先输出句子,等待一秒钟再结束程序。
结合下图
- 调用
exit()
后会先执行用户定义的清理函数,再冲刷缓冲,关闭流等,因此会有打印字符串的效果,最后再调用_exit系统调用_exit()
是一个系统调用接口,调用_exit()
后,其会在操作系统内部直接终止进程,对应缓冲区的数据不做刷新。
2、进程等待
2.1 进程等待的作用
- 之前在Linux | 进程状态一文中有提及过僵尸进程的问题,如果子进程退出,父进程没有反应,可能造成僵尸进程的问题,导致内存泄漏
- 当进程一旦变成僵尸状态,即使使用Kill -9也无法结束进程,需要通过进程等待来结束它,进而解决内存泄漏的问题
- 需要通过进程等待,获得子进程的退出情况和父进程给子进程分配的任务完成的情况,例如子进程执行程序完毕后结果是否正确,或者是否正常退出
- 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。
在Linux中,进程等待是指一个进程(通常是父进程)等待其子进程终止并获取其退出状态。这是通过系统调用 wait()
或 waitpid()
来实现的。进程等待的主要目的是防止子进程成为“僵尸进程”(Zombie Process),并确保父进程能够获取子进程的退出状态。
2.2 僵尸进程(Zombie Process)
当一个子进程终止时,它的退出状态需要被父进程读取。如果父进程没有读取子进程的退出状态,子进程的进程描述符仍然保留在系统中,这种进程称为“僵尸进程”。僵尸进程不占用CPU资源,但会占用进程表中的条目,如果系统中存在大量僵尸进程,可能会导致进程表耗尽,无法创建新的进程。
2.3 wait()
系统调用
wait()
系统调用会使父进程阻塞,直到它的任意一个子进程终止。如果已经有子进程终止,wait()
会立即返回。
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
-
参数:
status
: 一个指向整数的指针,用于存储子进程的退出状态。可以通过宏(如WIFEXITED(status)
、WEXITSTATUS(status)
等)来解析这个状态。
-
返回值:
- 成功时返回终止的子进程的PID。
- 如果没有子进程,返回-1,并设置
errno
为ECHILD
。
2.4 waitpid()
系统调用
waitpid()
提供了比 wait()
更灵活的控制,允许父进程等待特定的子进程,并且可以指定是否阻塞。
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
-
参数:
pid
: 指定要等待的子进程的PID。pid > 0
: 等待进程ID等于pid
的子进程。pid = -1
: 等待任意子进程,与wait()
类似。pid = 0
: 等待与调用进程属于同一个进程组的任意子进程。pid < -1
: 等待进程组ID等于pid
绝对值的任意子进程。
status
: 与wait()
中的status
参数相同,用于存储子进程的退出状态。options
: 控制waitpid()
的行为,常用的选项有:WNOHANG
: 如果没有子进程退出,立即返回,不阻塞。WUNTRACED
: 如果子进程被暂停(例如通过SIGSTOP
信号),也返回。
-
返回值:
- 成功时返回终止的子进程的PID。
- 如果指定了
WNOHANG
且没有子进程退出,返回0。 - 如果出错,返回-1,并设置
errno
。
2.5 示例代码
#include <stdio.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
exit(1);
} else if (pid == 0) {
// 子进程
printf("Child process is running\n");
sleep(2);
printf("Child process is exiting\n");
exit(42);
} else {
// 父进程
int status;
printf("Parent process is waiting for child\n");
wait(&status);
if (WIFEXITED(status)) {
printf("Child exited with status %d\n", WEXITSTATUS(status));
} else {
printf("Child did not exit normally\n");
}
}
return 0;
}
3、进程程序替换
在Linux中,进程程序替换是指将一个进程当前执行的程序替换为另一个全新的程序。这个过程是通过 exec
系列函数来实现的。进程程序替换后,进程的PID、父进程、文件描述符等信息保持不变,但进程的代码段、数据段、堆栈等会被新程序的内容替换。
exec
系列函数是Linux系统调用的一部分,它们的作用是加载并执行一个新的程序,替换当前进程的地址空间。执行成功后,原程序的代码将不再运行,而是由新程序从头开始执行。
3.1 exec
系列函数
exec
系列函数有多个变体,它们的核心功能相同,但在参数传递方式和行为上略有不同。exec系列函数只有失败返回值(-1),没有成功返回值
以下是常用的 exec
函数:
3.1.1 execl()
int execl(const char *path, const char *arg, ..., (char *) NULL);
-
功能: 加载并执行指定路径的程序。
-
参数:
path
: 要执行的程序的完整路径。arg
: 程序的命令行参数,第一个参数通常是程序名,最后一个参数必须是NULL
。
-
示例:
execl("/bin/ls", "ls", "-l", NULL);
这行代码会执行
/bin/ls
程序,并传递-l
参数。
3.1.2 execlp()
int execlp(const char *file, const char *arg, ..., (char *) NULL);
- 功能: 类似于
execl()
,但会在PATH
环境变量中查找可执行文件。 - 参数:
file
: 要执行的程序名(不需要完整路径)。arg
: 程序的命令行参数,最后一个参数必须是NULL
。
- 示例:
这行代码会在execlp("ls", "ls", "-l", NULL);
PATH
中查找ls
并执行。
3.1.3 execle()
int execle(const char *path, const char *arg, ..., (char *) NULL, char *const envp[]);
- 功能: 加载并执行指定路径的程序,并允许指定环境变量。
- 参数:
path
: 要执行的程序的完整路径。arg
: 程序的命令行参数,最后一个参数必须是NULL
。envp
: 自定义的环境变量数组,必须以NULL
结尾。
- 示例:
char *envp[] = {"USER=test", "PATH=/bin", NULL}; execle("/bin/ls", "ls", "-l", NULL, envp);
3.1.4 execv()
int execv(const char *path, char *const argv[]);
- 功能: 加载并执行指定路径的程序,参数通过数组传递。
- 参数:
path
: 要执行的程序的完整路径。argv
: 命令行参数数组,必须以NULL
结尾。
- 示例:
char *argv[] = {"ls", "-l", NULL}; execv("/bin/ls", argv);
3.1.5 execvp()
int execvp(const char *file, char *const argv[]);
- 功能: 类似于
execv()
,但会在PATH
环境变量中查找可执行文件。 - 参数:
file
: 要执行的程序名(不需要完整路径)。argv
: 命令行参数数组,必须以NULL
结尾。
- 示例:
char *argv[] = {"ls", "-l", NULL}; execvp("ls", argv);
3.1.6 execvpe()
int execvpe(const char *file, char *const argv[], char *const envp[]);
- 功能: 类似于
execvp()
,但允许指定环境变量。 - 参数:
file
: 要执行的程序名(不需要完整路径)。argv
: 命令行参数数组,必须以NULL
结尾。envp
: 自定义的环境变量数组,必须以NULL
结尾。
- 示例:
char *argv[] = {"ls", "-l", NULL}; char *envp[] = {"USER=test", "PATH=/bin", NULL}; execvpe("ls", argv, envp);
3.2 exec
系列函数的特点
-
替换当前进程:
exec
系列函数会用新程序替换当前进程的地址空间,包括代码段、数据段、堆栈等。- 进程的PID、父进程、文件描述符等信息保持不变。
-
不创建新进程:
exec
不会创建新进程,它只是替换当前进程的内容。
-
成功时不返回:
- 如果
exec
执行成功,它不会返回,因为原程序的代码已经被替换。 - 如果
exec
失败,它会返回-1
,并设置errno
。
- 如果
-
文件描述符的继承:
- 默认情况下,
exec
会保留进程打开的文件描述符(除非显式设置FD_CLOEXEC
标志)。
- 默认情况下,
3.3 示例代码
- 实例1:
以下是一个完整的示例,展示如何使用 fork()
和 exec()
创建子进程并替换程序:
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
exit(1);
}
else if (pid == 0) // 子进程
{
printf("子进程正在执行\n");
// 替换为 ls 程序
char *argv[] = {"ls", "-l", NULL};
execvp("ls", argv);
//如果进程替换成功,则下面的代码不会执行,如果进程替换失败(例如命令错误、路径错误等原因导致错误),则会执行下面的语句
perror("hello world\n");
exit(1);
}
else // 父进程
{
int status;
wait(&status); // 等待子进程结束
printf("父进程检测到子进程退出\n");
}
return 0;
}
程序运行结果:
-
实例2:
test1.c
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <stdio.h>
#include <sys/types.h>
int main() {
pid_t pid = fork();
if (pid < 0) {
perror("fork failed");
exit(1);
}
else if (pid == 0) // 子进程
{
printf("子进程正在执行\n");
// 替换为 ls 程序
//char *argv[] = {"ls", "-l", NULL};
//execvp("ls", argv);
//execl("/usr/bin/ls","/usr/bin/ls","-ln","-a",NULL);
execl("./test2","test2",NULL);
//如果进程替换成功,则下面的代码不会执行
perror("hello world\n");
exit(1);
}
else // 父进程
{
int status;
wait(&status); // 等待子进程结束
printf("父进程检测到子进程退出\n");
}
return 0;
}
test2.c
#include<stdio.h>
int main(){
printf("这是test2\n");
return 0;
}
将test1.c编译成可执行文件后,执行结果如下: