3. 进程管理:fork()、exec族、wait()与僵尸进程

进程管理这块,说实话是系统编程里最容易出「灵异事件」的地方。我早年做后台服务时,就遇到过服务器莫名其妙多出一堆「死不掉」的进程——后来才知道,那是僵尸进程在作祟。今天咱们就把 fork、exec、wait 这些老伙计彻底聊透。

3.1 fork() 系统调用——分身术

fork() 是 Unix/Linux 里最神奇的系统调用之一。它一次调用,两次返回。你想想看,一个函数居然能返回两个不同的值?

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

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程
        printf("我是子进程,PID=%d\n", getpid());
    } else if (pid > 0) {
        // 父进程
        printf("我是父进程,子进程PID=%d\n", pid);
    } else {
        perror("fork失败");
    }
    return 0;
}

运行结果可能是这样的:

我是父进程,子进程PID=12345
我是子进程,PID=12345

注意,两个进程的执行顺序是不确定的。谁先跑,完全看调度器的心情。我在项目中遇到过新手同事以为子进程一定后执行,结果写出来的代码在单核机器上跑得好好的,换到多核就崩了。

核心要点:

  • fork 之后,子进程是父进程的「完整副本」——包括文件描述符、信号处理、内存数据
  • 但子进程有自己的 PID、PPID,以及独立的地址空间
  • 父子进程共享文件描述符的偏移量(这点经常被忽略)

我的习惯:fork 之后立刻判断 pid 值,然后分叉处理逻辑。千万别在 fork 之前打开太多文件,否则子进程会继承一堆用不到的 fd,容易泄漏。

3.2 exec 族函数——变身术

fork 只是复制,exec 才是真正的「变身」。它用一个新的程序替换当前进程的代码段、数据段、堆和栈。说白了,就是「换芯」。

exec 族有 6 个成员:

函数名 特点 常用场景
execl 参数列表形式,以 NULL 结尾 参数固定的场景
execv 参数数组形式 参数动态构建
execle 可传入环境变量 需要自定义环境
execve 系统调用底层实现 最底层接口
execlp 从 PATH 中搜索程序 执行系统命令
execvp 从 PATH 搜索 + 参数数组 最常用
#include <unistd.h>
#include <stdio.h>

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程:变身成 ls 命令
        execlp("ls", "ls", "-l", "/home", NULL);
        // 如果 exec 失败,才会执行到这里
        perror("exec 失败");
        _exit(1);
    } else if (pid > 0) {
        // 父进程继续做自己的事
        printf("父进程等待子进程执行...\n");
    }
    return 0;
}

我曾经踩过的坑:exec 成功后,原来的代码不会再执行。所以 exec 后面不要放任何「正常逻辑」的代码——那些代码只有 exec 失败时才会跑到。我见过有人把清理代码放在 exec 后面,结果 exec 成功了,清理代码永远跑不到,资源全泄漏了。

3.3 wait() 与 waitpid()——收尸人

子进程退出时,会向父进程发送 SIGCHLD 信号。如果父进程不处理,子进程就会变成僵尸进程。嗯,这里要注意——僵尸进程已经死了,但它的进程描述符还留在内核里,占着 PID 呢。

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

int main() {
    pid_t pid = fork();
    
    if (pid == 0) {
        // 子进程
        printf("子进程工作...\n");
        sleep(2);
        _exit(42);  // 退出码 42
    } else {
        // 父进程
        int status;
        pid_t child_pid = wait(&status);
        
        if (WIFEXITED(status)) {
            printf("子进程 %d 正常退出,退出码=%d\n", 
                   child_pid, WEXITSTATUS(status));
        }
    }
    return 0;
}

waitpid() 比 wait() 更灵活:

// 等待特定子进程
waitpid(child_pid, &status, 0);

// 非阻塞等待
waitpid(-1, &status, WNOHANG);

// 等待任意子进程(同 wait)
waitpid(-1, &status, 0);

关键区别:

  • wait() 阻塞等待任意一个子进程退出
  • waitpid() 可以指定等待哪个子进程,也可以设置非阻塞
  • 都返回子进程的 PID,失败返回 -1

3.4 孤儿进程与僵尸进程

这两个概念,我当年面试时被问过不下十次。说白了就是:

  • 孤儿进程:父进程先挂了,子进程被 init 进程(PID=1)收养
  • 僵尸进程:子进程先挂了,但父进程没调用 wait 收尸

为什么会这样?

子进程退出时,内核会保留它的进程描述符,直到父进程用 wait 读取退出状态。如果父进程一直不读,这个「尸体」就一直在。僵尸进程不占 CPU 和内存,但它占 PID——而 PID 是有限的。

我曾经遇到的生产事故:有个后台服务 fork 了大量子进程处理请求,但父进程没处理好 SIGCHLD 信号。运行一周后,系统 PID 耗尽,新进程 fork 不出来,服务直接瘫痪。后来加了个信号处理器,用 waitpid(-1, NULL, WNOHANG) 循环收尸,问题解决。

3.5 实战:完整的进程管理框架

下面是我个人习惯用的一个进程管理模板,兼顾了 fork、exec 和 wait:

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

// 信号处理器:非阻塞收尸
void sigchld_handler(int sig) {
    while (waitpid(-1, NULL, WNOHANG) > 0) {
        // 循环收尸,直到没有僵尸
    }
}

int main() {
    // 注册 SIGCHLD 处理器
    struct sigaction sa;
    sa.sa_handler = sigchld_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART | SA_NOCLDSTOP;
    sigaction(SIGCHLD, &sa, NULL);
    
    for (int i = 0; i < 5; i++) {
        pid_t pid = fork();
        
        if (pid == 0) {
            // 子进程:执行外部程序
            execlp("./worker", "worker", NULL);
            perror("exec 失败");
            _exit(1);
        } else if (pid > 0) {
            printf("启动了工作进程 %d\n", pid);
        } else {
            perror("fork 失败");
        }
    }
    
    // 父进程继续做其他事
    while (1) {
        sleep(1);
    }
    return 0;
}

我的建议:生产环境中,永远不要依赖 wait() 的阻塞行为。用信号处理器 + 非阻塞 waitpid 才是正道。另外,SA_NOCLDSTOP 这个标志别忘了加——它告诉内核,子进程暂停/继续时不要发 SIGCHLD,只有真正退出时才发。

3.6 知识体系总览

下面这张图,是我梳理的进程管理核心脉络:

进程管理核心知识体系 进程管理 fork() 系统调用 创建子进程,复制地址空间 exec 族函数 替换进程映像,执行新程序 wait() / waitpid() 回收子进程,获取退出状态 一次调用两次返回 共享文件描述符 独立地址空间 6种变体 失败才返回 PATH搜索 阻塞 vs 非阻塞 退出状态解析 SIGCHLD信号 特殊进程状态 孤儿进程(父先死) vs 僵尸进程(子先死,父未收尸)

这张图把进程管理的三大核心操作串起来了。你想想看,fork 负责生,exec 负责变,wait 负责收——三个系统调用配合起来,就是完整的进程生命周期管理。

至于孤儿进程和僵尸进程,它们不是「操作」,而是「状态」。理解了 fork 和 wait 的配合,这两个概念自然就清楚了。嗯,今天就先聊到这儿,代码写多了,记得多跑几遍看看效果。

可可精品资料库