深入理解进程控制:退出、等待与替换

在Linux系统中,进程是程序执行的基本单位。理解进程如何结束、父进程如何回收子进程资源,以及进程如何执行新的程序,是掌握系统编程的关键。本篇博客将深入探讨进程的终止、等待和程序替换。

一、进程终止

当一个进程完成其任务或遇到异常时,它需要终止。进程终止的本质是操作系统回收其占用的资源(如内存、文件描述符等)。

1. 进程退出的场景

进程退出主要有三种场景:

  • 代码运行完毕,结果正确:这是最理想的情况。

  • 代码运行完毕,结果不正确:程序逻辑执行完成,但可能由于输入或逻辑错误,得到了错误的结果。

  • 代码异常终止 :进程在运行过程中被信号(如 SIGSEGV段错误)终止。

2. 进程退出的方法

正常终止

  1. main函数返回return 0等同于调用 exit(0)

  2. 调用 exit函数:这是标准的库函数,在终止进程前会执行清理工作。

  3. 调用 _exit_Exit函数:这是系统调用,直接终止进程,不做任何清理。

异常退出

  • 通过 Ctrl+C产生 SIGINT信号终止进程。

  • 其他信号,如 kill -9发送的 SIGKILL

3. 退出码

进程退出时,会有一个 退出码 ,用于向启动它的进程(通常是父进程或Shell)报告自己的终止状态。可以通过 echo $?命令查看上一个命令的退出码。

常见的退出码及其含义如下:

退出码 解释
0 命令成功执行
1 通用错误代码
2 命令或参数使用不当
126 权限被拒绝或无法执行
127 未找到命令(PATH错误)
128+n 命令被信号终止(n为信号编号)
130 (128+2) 通过 Ctrl+C(SIGINT) 终止
143 (128+15) 通过 SIGTERM(默认终止信号)终止

注意_exit(int status)函数中,虽然 statusint类型,但只有低8位会被父进程使用。所以 _exit(-1)在 Shell 中查看到的退出码是 255

4. exit_exit的区别

这是理解进程终止的一个关键点。

  • _exit系统调用。直接使进程终止,立即关闭所有文件描述符,不会刷新(flush)标准I/O缓冲区。

  • exit库函数 。它在调用 _exit之前,会先执行以下清理工作:

    1. 执行用户通过 atexit()on_exit()注册的清理函数。

    2. 关闭所有打开的流(标准I/O流,如 stdout),并将缓冲区中的数据写入文件。

示例对比

复制代码
// 示例1:使用 exit
int main() {
    printf("hello"); // 注意:字符串后没有换行符 \n
    exit(0);
}
// 运行结果:输出 hello
// 因为 exit 会刷新缓冲区,将 "hello" 写入标准输出。

// 示例2:使用 _exit
int main() {
    printf("hello");
    _exit(0);
}
// 运行结果:可能没有任何输出
// 因为 _exit 直接终止进程,缓冲区中的 "hello" 未被刷新。

二、进程等待

1. 为什么需要进程等待?

当一个子进程先于父进程终止时,如果父进程不采取任何措施,子进程就会进入 **"僵尸进程"**​ 状态。

  • 僵尸进程的危害 :僵尸进程保留了其在内核中的进程描述符等少量资源,如果父进程一直不回收,会导致资源泄漏(内存泄漏)。更严重的是,僵尸进程"刀枪不入",连 kill -9也无法杀死。

  • 获取子进程信息:父进程需要通过等待来获取子进程的退出状态,判断其是正常结束还是异常退出,以及正常的退出码是多少。

因此,进程等待 ​ 是父进程的 责任,其主要目的有两个:

  1. 回收子进程资源,防止僵尸进程的产生。

  2. 获取子进程的退出信息

2. 进程等待的方法

主要有两个函数:waitwaitpid

wait函数

复制代码
#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);
  • 作用:阻塞等待任意一个子进程退出。

  • 参数status是一个输出型参数,用于获取子进程的退出状态。如果不关心状态,可设置为 NULL

  • 返回值:成功则返回被等待子进程的PID,失败返回-1。

waitpid函数

复制代码
#include <sys/types.h>
#include <sys/wait.h>
pid_t waitpid(pid_t pid, int *status, int options);
  • 作用:功能更强大,可以等待指定的子进程,并支持非阻塞模式。

  • 参数

    • pid

      • pid = -1:等待任意一个子进程,与 wait等效。

      • pid > 0:等待进程ID等于 pid的子进程。

    • status:同 wait

    • options

      • 0:默认选项,表示 阻塞等待

      • WNOHANG:表示 非阻塞等待 。如果指定的子进程没有结束,则 waitpid立即返回0,不等待。

  • 返回值

    • 成功时返回收集到的子进程的PID。

    • 如果设置了 WNOHANG且子进程未退出,则返回 0

    • 调用失败返回 -1

3. 如何解析 status 参数?

status参数不能简单地当作一个整数来看待,而应该将其视为一个 位图。它的低16位包含了退出信息(在32位系统上)。

我们通常使用宏来安全地解析这些信息:

  • WIFEXITED(status) :如果这个宏为真(非零),表示子进程是 正常终止​ 的。

  • WEXITSTATUS(status) :如果 WIFEXITED为真,此宏用于提取子进程的 退出码 (即 exitreturn的参数)。

  • WIFSIGNALED(status) :如果这个宏为真,表示子进程是被 信号终止​ 的(异常退出)。

  • WTERMSIG(status) :如果 WIFSIGNALED为真,此宏用于获取导致子进程终止的 信号编号

示例代码

复制代码
int main(void) {
    pid_t pid;
    if ((pid = fork()) == -1) {
        perror("fork");
        exit(1);
    }
    if (pid == 0) { // 子进程
        sleep(20);
        exit(10);   // 子进程正常退出,退出码为10
    } else {        // 父进程
        int st;
        int ret = wait(&st);
        if (ret > 0) {
            if (WIFEXITED(st)) { // 正常退出
                printf("Child exit code: %d\n", WEXITSTATUS(st)); // 输出 10
            } else if (WIFSIGNALED(st)) { // 被信号杀死
                printf("Child killed by signal: %d\n", WTERMSIG(st));
            }
        }
    }
    return 0;
}

三、进程程序替换

fork创建的子进程默认执行的是父进程相同的代码。如果我们希望子进程去执行一个 全新的、不同的程序 (例如,在Shell中输入 ls命令),就需要用到 进程程序替换

1. 替换原理

进程程序替换的核心函数是 exec系列函数。当进程调用 exec函数时,该进程的用户空间代码和数据 完全被新程序替换 ,并从新程序的 main函数开始执行。

  • 关键点exec函数并 不创建新的进程。调用前后,进程的PID保持不变。它只是用磁盘上的一个新程序,替换了当前进程的代码段、数据段等。
2. exec 函数族

有6个以 exec开头的函数,它们功能相同,但参数传递方式不同。

复制代码
#include <unistd.h>
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[]);

命名规律

  • l (list) :参数采用 列表 ​ 形式,逐个传入,以 NULL结尾。例如:execl("/bin/ls", "ls", "-l", NULL)

  • v (vector) :参数放入一个 字符串数组 ​ 中传入,数组最后一个元素必须是 NULL。例如:

    复制代码
    char *const argv[] = {"ls", "-l", NULL};
    execv("/bin/ls", argv);
  • p (path) :带有 p的函数会自动在 环境变量 PATH ​ 指定的目录中搜索可执行文件,无需写全路径。例如:execlp("ls", "ls", "-l", NULL)

  • e (environment) :带有 e的函数需要用户自己 组装并传入环境变量 ​ 数组 envp[],不使用当前进程的环境变量。

重要特性exec函数只有在调用失败时才有返回值(-1)。如果调用成功,则执行新程序,原程序的后续代码不会再执行。

3. 函数关系

实际上,只有 execve是真正的 系统调用 ,其他五个函数都是库函数,它们最终都会封装 execve来实现功能。其关系如下图所示:

复制代码
+----------+    +----------+    +----------+
|  execl   |    |  execv   |    | execlp   |  ... (Library Functions)
+----------+    +----------+    +----------+
      |               |               |
      v               v               v
+-------------------------------------------------+
|                 execve                          |  (System Call)
+-------------------------------------------------+

总结与实践:微型Shell

将进程创建(fork)、进程等待(waitpid)和进程替换(exec)结合起来,就能理解Shell的工作原理。一个简单的Shell流程如下:

  1. 获取命令行:显示提示符,读取用户输入的命令。

  2. 解析命令行:将命令字符串解析为命令名和参数列表。

  3. 创建子进程 :使用 fork

  4. 子进程程序替换 :在子进程中使用 execvp执行命令。

  5. 父进程等待子进程退出 :使用 waitpid等待子进程结束,防止其变成僵尸进程。

这个过程完美体现了 **"调用/返回"**​ 的对称性:

  • 在函数中,call调用函数,函数 return返回。

  • 在进程间,fork创建子进程,子进程 exec执行新程序,新程序 exit退出,父进程 wait回收。

通过编写一个微型的Shell(代码已在你提供的文档中),可以极大地加深对进程控制的理解。


相关推荐
大树8815 小时前
金刚石散热越强,管路越先见顶
大数据·运维·服务器·人工智能·ai
摇滚侠15 小时前
Linux CentOS7 rpm 安装 MySQL 5.7
linux·运维·mysql
霸道流氓气质15 小时前
领域驱动设计(DDD)在 Spring Boot 微服务中的实践指南
运维·spring boot·微服务
bush416 小时前
嵌入式linux学习记录十四、术语
linux·嵌入式
载数而行52016 小时前
Linux 11 动态监控指令top
linux
小宇宙Zz16 小时前
Maven依赖冲突
java·服务器·maven
Inhand陈工17 小时前
基于台达PLC与映翰通IG502的智慧水产养殖精准投喂与远程运维解决方案
运维·人工智能·物联网·阿里云·信息与通信
酣大智17 小时前
ARP代理--工作原理
运维·网络·arp·arp代理
不会C语言的男孩17 小时前
Linux 系统编程 · 第 8 章:进程基础
linux·c语言
shushangyun_17 小时前
2026年快消品B2B系统推荐:支持终端门店订货、促销政策自动化的工具?
java·运维·网络·数据库·人工智能·spring·自动化