一、进程等待:父进程的 “责任与担当”

1.1 进程等待必要性:不做 “甩手掌柜”,规避系统风险

当子进程执行完任务终止后,内核并不会立即释放其所有资源(如 PCB、退出状态等),而是将其标记为 “僵尸进程(Zombie)”,等待父进程 “认领”。如果父进程对此不管不顾,僵尸进程就会一直占用系统资源,久而久之导致内存泄漏;更严重的是,僵尸进程无法被 kill -9 强制删除,如同 “幽灵” 般难以清除。

除此之外,父进程还需要通过等待机制获取子进程的执行结果:子进程是正常完成任务,还是中途异常终止?执行结果是否符合预期?这些信息都需要通过进程等待来回收。

1.1.1 僵尸进程的 “危害演示”

为了让大家直观感受僵尸进程,我们通过代码模拟一个父进程不等待子进程的场景(zombie_demo.c):

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        // 子进程执行完立即退出
        printf("子进程(PID:%d)执行完毕,退出\n", getpid());
        exit(0);
    }
    else
    {
        // 父进程无限循环,不等待子进程
        printf("父进程(PID:%d)不等待子进程,持续运行...\n", getpid());
        while (1)
        {
            sleep(1);
        }
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc zombie_demo.c -o zombie_demo
./zombie_demo

此时打开另一个终端,使用 ps 命令查看僵尸进程:

代码语言:javascript

AI代码解释

ps aux | grep defunct

执行结果如下:

代码语言:javascript

AI代码解释

root      45200  0.0  0.0      0     0 pts/0    Z+   16:20   0:00 [zombie_demo] <defunct>

其中<defunct>标记表示该进程是僵尸进程。只要父进程不退出,这个僵尸进程就会一直存在,占用 PID 和系统资源。若大量僵尸进程累积,会导致系统可用 PID 耗尽,无法创建新进程。

1.1.2 进程等待的三大核心作用

  1. 回收子进程资源:清除僵尸进程,释放其占用的 PCB、PID 等系统资源,避免内存泄漏;
  2. 获取子进程退出状态:得知子进程是正常退出(返回退出码)还是异常终止(被信号杀死);
  3. 实现父子进程同步:父进程可以通过等待控制执行节奏,确保子进程完成任务后再继续运行。
1.2 进程等待的方法:wait 与 waitpid 的 “双雄记”

Linux 提供了 wait waitpid 两个核心函数来实现进程等待,前者是简单的阻塞等待,后者功能更强大,支持指定等待对象、非阻塞等待等高级特性。

1.2.1 wait 函数:简单直接的 “阻塞等待”

wait 函数是进程等待的基础接口,其头文件和函数原型如下:

代码语言:javascript

AI代码解释

#include <sys/types.h>
#include <sys/wait.h>

pid_t wait(int *status);
函数参数与返回值

  • 参数 status:输出型参数,用于存储子进程的退出状态。若不关心子进程退出状态,可设为 NULL;
  • 返回值:成功返回被等待子进程的 PID;失败返回 - 1(如没有子进程可等待)。
核心特性:阻塞等待

当父进程调用 wait 后,会立即进入阻塞状态,暂停执行,直到有子进程终止。此时内核会唤醒父进程,让其回收子进程资源并获取退出状态。

实战:wait 函数基本用法(wait_basic.c)

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        // 子进程模拟执行任务(睡眠3秒)
        printf("子进程(PID:%d)开始执行任务...\n", getpid());
        sleep(3);
        printf("子进程执行完毕,退出码:10\n");
        exit(10); // 正常退出,退出码10
    }
    else
    {
        int status;
        pid_t ret = wait(&status); // 阻塞等待子进程退出
        if (ret == -1)
        {
            perror("wait failed");
            exit(1);
        }
        printf("父进程(PID:%d)等待成功,回收子进程PID:%d\n", getpid(), ret);
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc wait_basic.c -o wait_basic
./wait_basic

执行结果:

代码语言:javascript

AI代码解释

子进程(PID:45205)开始执行任务...
子进程执行完毕,退出码:10
父进程(PID:45204)等待成功,回收子进程PID:45205

从结果可以看到,父进程调用 wait 后阻塞,直到子进程执行完 3 秒任务并退出,才继续执行后续代码,成功回收子进程资源。

1.2.2 waitpid 函数:功能强大的 “灵活等待”

waitpid 函数是 wait 函数的增强版,支持指定等待的子进程、设置等待方式(阻塞 / 非阻塞),其函数原型如下:

代码语言:javascript

AI代码解释

pid_t waitpid(pid_t pid, int *status, int options);
函数参数详解

  1. pid 参数:指定等待的子进程 PID,支持三种取值:
    • pid = -1:等待任意一个子进程(与 wait 功能等效);
    • pid > 0:等待 PID 等于该值的特定子进程;
    • pid = 0:等待与父进程同属一个进程组的所有子进程。
  2. status 参数:与 wait 函数的 status 参数一致,用于存储子进程退出状态,为输出型参数。
  3. options 参数:设置等待方式,常用取值:
    • 0:默认值,阻塞等待(与 wait 行为一致);
    • WNOHANG:非阻塞等待。若指定的子进程未退出,waitpid 立即返回 0,不阻塞父进程;若子进程已退出,返回子进程 PID;若出错,返回 - 1。
返回值说明

  • 成功回收子进程:返回被回收子进程的 PID;
  • 非阻塞等待时子进程未退出:返回 0;
  • 出错(如无指定子进程):返回 - 1,errno 会被设置为对应错误码。
实战 1:指定子进程的阻塞等待(waitpid_specify.c)

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

int main(void)
{
    // 创建两个子进程
    pid_t pid1 = fork();
    pid_t pid2 = fork();

    if (pid1 == -1 || pid2 == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid1 == 0)
    {
        // 第一个子进程(PID:pid1),睡眠2秒后退出
        printf("子进程1(PID:%d)执行任务,睡眠2秒...\n", getpid());
        sleep(2);
        exit(2);
    }
    else if (pid2 == 0)
    {
        // 第二个子进程(PID:pid2),睡眠4秒后退出
        printf("子进程2(PID:%d)执行任务,睡眠4秒...\n", getpid());
        sleep(4);
        exit(4);
    }
    else
    {
        int status1, status2;
        // 先等待子进程1(指定PID为pid1)
        pid_t ret1 = waitpid(pid1, &status1, 0);
        printf("父进程回收子进程1,PID:%d,退出码:%d\n", ret1, WEXITSTATUS(status1));

        // 再等待子进程2(指定PID为pid2)
        pid_t ret2 = waitpid(pid2, &status2, 0);
        printf("父进程回收子进程2,PID:%d,退出码:%d\n", ret2, WEXITSTATUS(status2));
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc waitpid_specify.c -o waitpid_specify
./waitpid_specify

执行结果:

代码语言:javascript

AI代码解释

子进程1(PID:45210)执行任务,睡眠2秒...
子进程2(PID:45211)执行任务,睡眠4秒...
父进程回收子进程1,PID:45210,退出码:2
父进程回收子进程2,PID:45211,退出码:4

可以看到,父进程按照指定的 PID 顺序等待子进程,先回收睡眠 2 秒的子进程 1,再回收睡眠 4 秒的子进程 2,实现了精准的子进程回收。

实战 2:非阻塞等待(waitpid_nonblock.c)

非阻塞等待的核心优势是:父进程在等待子进程的同时,可以执行其他任务,提高 CPU 利用率。例如,父进程在等待子进程处理任务时,可同时处理日志、响应其他请求等。

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>

// 父进程在等待期间执行的临时任务
void do_other_task()
{
    static int count = 0;
    printf("父进程执行临时任务,已执行%d次\n", ++count);
    sleep(1); // 模拟任务耗时
}

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        // 子进程模拟耗时任务(睡眠5秒)
        printf("子进程(PID:%d)开始执行耗时任务,预计5秒...\n", getpid());
        sleep(5);
        printf("子进程执行完毕,退出码:5\n");
        exit(5);
    }
    else
    {
        int status;
        pid_t ret;
        // 非阻塞等待:循环检查子进程是否退出
        while (1)
        {
            ret = waitpid(pid, &status, WNOHANG); // 非阻塞模式
            if (ret == 0)
            {
                // 子进程未退出,父进程执行其他任务
                do_other_task();
                continue;
            }
            else if (ret == pid)
            {
                // 子进程已退出,回收成功
                printf("父进程回收子进程PID:%d,退出码:%d\n", ret, WEXITSTATUS(status));
                break;
            }
            else
            {
                // 等待出错
                perror("waitpid failed");
                exit(1);
            }
        }
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc waitpid_nonblock.c -o waitpid_nonblock
./waitpid_nonblock

执行结果:

代码语言:javascript

AI代码解释

子进程(PID:45215)开始执行耗时任务,预计5秒...
父进程执行临时任务,已执行1次
父进程执行临时任务,已执行2次
父进程执行临时任务,已执行3次
父进程执行临时任务,已执行4次
父进程执行临时任务,已执行5次
子进程执行完毕,退出码:5
父进程回收子进程PID:45215,退出码:5

从结果可以看到,父进程在等待子进程的 5 秒内,并未阻塞,而是循环执行临时任务,直到子进程退出后才停止,充分利用了 CPU 资源。

1.3 解析子进程退出状态:status 参数的 “位图密码”

wait 和 waitpid 的 status 参数并非普通整数,而是一个 16 位的位图(低 16 位有效),用于存储子进程的退出状态信息,其结构如下:

位范围(低 16 位)

含义

说明

0-6 位

终止信号编号

若子进程被信号杀死,该字段存储信号编号;若正常退出,该字段为 0

7 位

core dump 标志

若为 1,表示子进程终止时产生了 core 文件(用于调试)

8-15 位

退出码

子进程正常退出时,该字段存储退出码(如 exit (n) 中的 n)

为了方便解析 status 参数,Linux 提供了一组宏定义,无需手动操作位图:

  • WIFEXITED(status):判断子进程是否正常退出。若为真,说明子进程通过 return、exit 或_exit 退出;
  • WEXITSTATUS(status):若 WIFEXITED 为真,提取子进程的退出码;
  • WIFSIGNALED(status):判断子进程是否被信号杀死。若为真,说明子进程因收到致命信号而终止;
  • WTERMSIG(status):若 WIFSIGNALED 为真,提取终止子进程的信号编号;
  • WCOREDUMP(status):判断子进程终止时是否产生了 core 文件。
实战:解析子进程退出状态(status_parse.c)

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <sys/wait.h>
#include <signal.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        // 子进程模拟两种退出场景:注释掉其中一种进行测试
        // 场景1:正常退出,退出码3
        // printf("子进程正常退出,退出码:3\n");
        // exit(3);

        // 场景2:触发除零错误,被SIGFPE信号(编号8)杀死
        printf("子进程触发除零错误,即将被信号终止...\n");
        int a = 10 / 0;
        exit(0); // 不会执行
    }
    else
    {
        int status;
        pid_t ret = waitpid(pid, &status, 0);
        if (ret == -1)
        {
            perror("waitpid failed");
            exit(1);
        }

        // 解析退出状态
        if (WIFEXITED(status))
        {
            // 正常退出
            printf("子进程(PID:%d)正常退出,退出码:%d\n", ret, WEXITSTATUS(status));
        }
        else if (WIFSIGNALED(status))
        {
            // 被信号杀死
            printf("子进程(PID:%d)被信号终止,信号编号:%d,信号名称:%s\n",
                   ret, WTERMSIG(status), strsignal(WTERMSIG(status)));
            // 检查是否产生core文件
            if (WCOREDUMP(status))
            {
                printf("子进程终止时产生了core文件\n");
            }
        }
    }
    return 0;
}

编译执行(测试场景 2):

代码语言:javascript

AI代码解释

gcc status_parse.c -o status_parse
./status_parse

执行结果:

代码语言:javascript

AI代码解释

子进程触发除零错误,即将被信号终止...
子进程(PID:45220)被信号终止,信号编号:8,信号名称:Floating point exception
子进程终止时产生了core文件

若测试场景 1(正常退出),执行结果如下:

代码语言:javascript

AI代码解释

子进程正常退出,退出码:3
子进程(PID:45221)正常退出,退出码:3

通过这些宏定义,我们可以轻松解析子进程的退出状态,判断其是正常完成任务还是异常终止,为后续处理提供依据。

1.4 阻塞等待与非阻塞等待的适用场景

  • 阻塞等待:适用于父进程无需执行其他任务,仅需等待子进程完成的场景(如简单的命令执行、单一任务处理)。优点是逻辑简单,无需循环检查;缺点是父进程会暂停执行,CPU 利用率较低。
  • 非阻塞等待:适用于父进程需要并发处理多个任务的场景(如服务器同时处理多个客户端请求、后台程序同时执行多个子任务)。优点是父进程可充分利用 CPU 资源,执行其他任务;缺点是需要循环检查子进程状态,逻辑稍复杂。

二、进程程序替换:子进程的 “改头换面”

通过 fork 创建的子进程,会继承父进程的代码段、数据段、堆和栈,本质上执行的是与父进程相同的程序(只是可能执行不同的代码分支)。但在实际开发中,我们常常需要子进程执行一个全新的程序(如 shell 中执行 ls、ps 命令),这就需要通过进程程序替换来实现。

2.1 程序替换原理:“换核不换壳”

进程程序替换的核心原理是:用磁盘上的一个全新程序(代码和数据),覆盖当前进程的用户空间代码段、数据段、堆和栈,然后从新程序的启动例程开始执行

需要注意的是:

  1. 程序替换不会创建新进程,进程的 PID、PCB 等内核数据结构保持不变,只是进程的用户空间内容被完全替换;
  2. 若替换成功,新程序会从 main 函数开始执行,原进程的代码段、数据段等被彻底覆盖,替换函数之后的代码不会执行;
  3. 若替换失败,函数会返回 - 1,原进程的代码和数据保持不变。
程序替换的底层流程

  1. 父进程 fork 创建子进程,子进程继承父进程的用户空间内容;
  2. 子进程调用 exec 系列函数,请求替换程序;
  3. 内核根据 exec 函数指定的路径或文件名,找到磁盘上的可执行文件(ELF 格式);
  4. 内核加载可执行文件的代码段、数据段到子进程的用户空间,覆盖原有的内容;
  5. 内核设置子进程的程序计数器(PC),指向新程序的启动地址(通常是 main 函数的入口);
  6. 子进程开始执行新程序,原有的代码和数据不再被访问。

生动类比:进程程序替换就像 “演员换剧本”

一个进程就像一个演员,fork 创建子进程相当于 “克隆” 了一个演员,两者初始时拿到的是相同的剧本(父进程的代码);而程序替换相当于给克隆的演员换了一本全新的剧本(新程序的代码),演员会按照新剧本从头开始表演,原来的剧本就被丢弃了,但演员本身(进程 PID、身份)没有变化。

2.2 exec 函数族:程序替换的 “六大金刚”

Linux 提供了 6 个以 exec 开头的函数(统称 exec 函数族),用于实现进程程序替换。它们的核心功能一致,只是参数格式和使用场景略有不同。

2.2.1 函数原型与头文件

代码语言:javascript

AI代码解释

#include <unistd.h>

// 1. execl:参数列表形式,需指定程序完整路径
int execl(const char *path, const char *arg, ...);

// 2. execlp:参数列表形式,支持通过PATH环境变量查找程序
int execlp(const char *file, const char *arg, ...);

// 3. execle:参数列表形式,支持自定义环境变量
int execle(const char *path, const char *arg, ..., char *const envp[]);

// 4. execv:参数数组形式,需指定程序完整路径
int execv(const char *path, char *const argv[]);

// 5. execvp:参数数组形式,支持通过PATH环境变量查找程序
int execvp(const char *file, char *const argv[]);

// 6. execve:参数数组形式,支持自定义环境变量(系统调用,其他函数的底层实现)
int execve(const char *path, char *const argv[], char *const envp[]);
2.2.2 函数命名规律:轻松记准六大函数

exec 函数族的命名有明确规律,掌握后可快速区分用法:

  • l(list):参数采用 “列表形式”,需逐个指定命令参数,最后以 NULL 结尾(标记参数结束);
  • v(vector):参数采用 “数组形式”,将所有命令参数存入一个字符串数组,数组最后一个元素必须是 NULL;
  • p(path):支持通过系统的 PATH 环境变量查找程序,无需指定完整路径(如 execlp ("ls", "ls", "-l", NULL) 可直接找到 ls 命令);
  • e(env):支持自定义环境变量,需传入一个环境变量数组(如 {"PATH=/bin", "TERM=console", NULL}),不使用系统默认环境变量。

2.2.3 函数返回值说明

  • 若程序替换成功:函数不会返回(新程序直接开始执行,覆盖原进程代码);
  • 若程序替换失败:返回 - 1(此时需检查错误原因,如程序路径错误、权限不足等)。
2.2.4 六大函数用法实战

为了让大家清晰掌握每个函数的用法,我来为大家分别演示 6 个 exec 函数的使用。

1. execl 函数:列表形式 + 完整路径

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        printf("子进程(PID:%d)使用execl执行ls -l\n", getpid());
        // 参数1:程序完整路径(通过which ls可查询)
        // 参数2~n:命令参数,第一个参数是命令名,最后以NULL结尾
        execl("/bin/ls", "ls", "-l", NULL);
        // 若execl返回,说明替换失败
        perror("execl failed");
        exit(1);
    }
    else
    {
        wait(NULL); // 父进程等待子进程完成
        printf("父进程:子进程执行完毕\n");
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc exec_execl.c -o exec_execl
./exec_execl

执行结果:

代码语言:javascript

AI代码解释

子进程(PID:45225)使用execl执行ls -l
总用量 40
-rwxr-xr-x 1 root root 8800 6月  10 17:30 exec_execl
-rw-r--r-- 1 root root  542 6月  10 17:29 exec_execl.c
...(其他文件列表)
父进程:子进程执行完毕
2. execlp 函数:列表形式 + PATH 查找

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        printf("子进程(PID:%d)使用execlp执行ls -l\n", getpid());
        // 参数1:程序名(无需完整路径,通过PATH环境变量查找)
        execlp("ls", "ls", "-l", NULL);
        perror("execlp failed");
        exit(1);
    }
    else
    {
        wait(NULL);
        printf("父进程:子进程执行完毕\n");
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc exec_execlp.c -o exec_execlp
./exec_execlp

执行结果与 execl 一致,区别在于 execlp 无需指定 ls 的完整路径,内核会自动在 PATH 环境变量包含的目录(如 /bin、/usr/bin)中查找。

3. execle 函数:列表形式 + 自定义环境变量

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        printf("子进程(PID:%d)使用execle执行ls -l(自定义环境变量)\n", getpid());
        // 自定义环境变量数组,最后以NULL结尾
        char *const envp[] = {"PATH=/bin", "TERM=console", NULL};
        // 最后一个参数传入自定义环境变量数组
        execle("/bin/ls", "ls", "-l", NULL, envp);
        perror("execle failed");
        exit(1);
    }
    else
    {
        wait(NULL);
        printf("父进程:子进程执行完毕\n");
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc exec_execle.c -o exec_execle
./exec_execle

execle 会使用自定义的环境变量执行程序,若自定义的 PATH 中不包含 ls 的路径,会替换失败。

4. execv 函数:数组形式 + 完整路径

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        printf("子进程(PID:%d)使用execv执行ls -l\n", getpid());
        // 命令参数数组,最后以NULL结尾
        char *const argv[] = {"ls", "-l", NULL};
        // 参数1:程序完整路径,参数2:参数数组
        execv("/bin/ls", argv);
        perror("execv failed");
        exit(1);
    }
    else
    {
        wait(NULL);
        printf("父进程:子进程执行完毕\n");
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc exec_execv.c -o exec_execv
./exec_execv

execv execl 的区别在于参数传递方式:execl 逐个传入参数,execv 将参数存入数组传入,适用于参数数量较多的场景。

5. execvp 函数:数组形式 + PATH 查找

代码语言:javascript

AI代码解释

#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>

int main(void)
{
    pid_t pid = fork();
    if (pid == -1)
    {
        perror("fork failed");
        exit(1);
    }
    else if (pid == 0)
    {
        printf("子进程(PID:%d)使用execvp执行ls -l\n", getpid());
        char *const argv[] = {"ls", "-l", NULL};
        // 参数1:程序名(通过PATH查找),参数2:参数数组
        execvp("ls", argv);
        perror("execvp failed");
        exit(1);
    }
    else
    {
        wait(NULL);
        printf("父进程:子进程执行完毕\n");
    }
    return 0;
}

编译执行:

代码语言:javascript

AI代码解释

gcc exec_execvp.c -o exec_execvp
./exec_execvp

execvp 是 shell 执行命令的核心函数,shell 通过 fork 创建子进程后,调用 execvp 执行用户输入的命令(如 ls、pwd 等),无需用户指定程序路径。
 

Logo

有“AI”的1024 = 2048,欢迎大家加入2048 AI社区

更多推荐