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

在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(代码已在你提供的文档中),可以极大地加深对进程控制的理解。


相关推荐
水天需0102 小时前
HISTFILE 介绍
linux
CreasyChan3 小时前
VirtualBox 安装 CentOS 7.2
linux·运维·centos
AAA.建材批发刘哥3 小时前
04--C++ 类和对象下篇
linux·c++·经验分享·青少年编程
J_liaty3 小时前
Nginx核心功能解析与实战指南
运维·nginx·负载均衡
杰克崔3 小时前
glibc社区提问
linux·运维·服务器·车载系统
wqdian_com3 小时前
中文域名的准确展示能否堵住网络钓鱼攻击“后门”?
服务器·安全·php
乾元3 小时前
网络切片的自动化配置与 SLA 保证——5G / 专网场景中,从“逻辑隔离”到“可验证承诺”的工程实现
运维·开发语言·网络·人工智能·网络协议·重构
山上三树3 小时前
MMU与页表
linux·嵌入式硬件
Source.Liu3 小时前
【网络】VLAN(虚拟局域网)技术详解
运维·网络
CHrisFC3 小时前
中小型第三方环境检测实验室的数字化破局之选——江苏硕晟LIMS
大数据·运维·人工智能