18、进程管理:fork/exec、waitpid、管道pipe通信、进程池模型

进程管理,说白了就是操作系统里最核心的那套「生孩子、养孩子、让孩子干活」的机制。我刚开始学的时候也觉得抽象,但后来在写一个网络代理程序时,被单进程的性能瓶颈逼得没办法,才真正把这一套啃了下来。今天咱们就聊聊 fork、exec、waitpid、管道,以及怎么用它们搭一个进程池。

18.1 fork():复制一个自己

fork 是 Unix 系统里创建进程的唯一方式(除了特殊情况)。调用一次,返回两次——这句话我当年听了三遍才理解。

#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;
}

fork 之后,子进程会获得父进程数据段、堆、栈的副本。注意是副本,不是共享。我在项目中遇到过一个问题:父进程打开了一个文件描述符,子进程 fork 后直接往里面写,结果两个进程的文件偏移互相干扰——因为文件描述符虽然复制了,但内核里的文件表项是共享的。

⚠️ 注意:fork 之后,父子进程的执行顺序是不确定的。不要假设谁先跑,要用 waitpid 或信号来同步。

18.2 exec():换一个程序跑

fork 只是复制,exec 才是真正的「变身」。它会把当前进程的代码段、数据段全部替换成新程序,但 PID 不变。

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

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        // 子进程:变成 ls 程序
        execlp("ls", "ls", "-l", NULL);
        // 如果 exec 失败,才会执行到这里
        perror("exec失败");
        return 1;
    } else if (pid > 0) {
        wait(NULL);  // 等子进程结束
        printf("子进程执行完毕\n");
    }
    return 0;
}

exec 系列函数有好几个:execl、execv、execle、execve……说白了就是参数传递方式不同。我个人习惯用 execvp,因为它会在 PATH 里自动搜索可执行文件,省心。

💡 小技巧:fork + exec 是 Unix 下启动新进程的标准模式。你每次在终端敲命令,shell 就是这么干的——先 fork 自己,然后子进程 exec 你敲的那个程序。

18.3 waitpid():等孩子回家

父进程创建子进程后,总得知道孩子干完活了没有。waitpid 就是干这个的。

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

int main() {
    pid_t pid = fork();

    if (pid == 0) {
        sleep(2);
        printf("子进程结束\n");
        return 42;  // 退出码
    }

    int status;
    pid_t ret = waitpid(pid, &status, 0);

    if (WIFEXITED(status)) {
        int exit_code = WEXITSTATUS(status);
        printf("子进程 %d 正常退出,退出码=%d\n", ret, exit_code);
    }
    return 0;
}

waitpid 比 wait 更灵活。第三个参数可以传 WNOHANG,实现非阻塞等待——这在进程池里特别有用。我曾经写过一个监控程序,用 WNOHANG 轮询所有子进程的状态,避免了阻塞在某个进程上。

🔑 关键点:子进程结束后会变成僵尸进程,直到父进程调用 wait/waitpid 回收。如果父进程一直不回收,内核会保留子进程的 PCB,造成资源泄漏。所以一定要记得 wait。

18.4 管道 pipe():进程间的通信线

管道是 Unix 下最古老的 IPC 方式。它本质上是内核里的一块缓冲区,有两个文件描述符:一个读端,一个写端。

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

int main() {
    int fd[2];
    pipe(fd);  // fd[0] 读端,fd[1] 写端

    pid_t pid = fork();

    if (pid == 0) {
        // 子进程:关闭读端,只写
        close(fd[0]);
        char *msg = "Hello 父进程!";
        write(fd[1], msg, strlen(msg) + 1);
        close(fd[1]);
    } else {
        // 父进程:关闭写端,只读
        close(fd[1]);
        char buf[128];
        read(fd[0], buf, sizeof(buf));
        printf("收到子进程消息: %s\n", buf);
        close(fd[0]);
        wait(NULL);
    }
    return 0;
}

这里有个坑:管道是半双工的,数据只能单向流动。如果父子进程都要读写,就得建两个管道。我在项目中遇到过一个问题:忘记关闭不需要的端,导致 read 一直阻塞——因为写端没关,内核认为还有进程可能写数据。

⚠️ 注意:管道默认是阻塞的。如果读端读空缓冲区,read 会阻塞直到有数据。如果写端写满缓冲区(通常 64KB),write 也会阻塞。可以用 fcntl 设置 O_NONBLOCK 变成非阻塞模式。

18.5 进程池模型:批量管理子进程

单个子进程好管,但如果你要处理 100 个并发任务呢?总不能 fork 100 次吧?进程池就是解决这个问题的——预先创建一批子进程,然后派发任务。

下面是一个简单的进程池框架:

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

#define POOL_SIZE 4

void worker(int id, int read_fd) {
    // 每个子进程从管道读任务
    int task;
    while (read(read_fd, &task, sizeof(task)) > 0) {
        printf("Worker %d 执行任务 %d\n", id, task);
        sleep(1);  // 模拟工作
    }
    exit(0);
}

int main() {
    int pipes[POOL_SIZE][2];
    pid_t pids[POOL_SIZE];

    // 创建子进程和管道
    for (int i = 0; i < POOL_SIZE; i++) {
        pipe(pipes[i]);
        pids[i] = fork();

        if (pids[i] == 0) {
            close(pipes[i][1]);  // 子进程关闭写端
            worker(i, pipes[i][0]);
        } else {
            close(pipes[i][0]);  // 父进程关闭读端
        }
    }

    // 派发 10 个任务
    for (int task = 0; task < 10; task++) {
        int worker_id = task % POOL_SIZE;
        write(pipes[worker_id][1], &task, sizeof(task));
    }

    // 关闭所有写端,通知子进程结束
    for (int i = 0; i < POOL_SIZE; i++) {
        close(pipes[i][1]);
    }

    // 回收所有子进程
    for (int i = 0; i < POOL_SIZE; i++) {
        waitpid(pids[i], NULL, 0);
    }

    printf("所有任务完成\n");
    return 0;
}

这个模型的核心思路是:父进程通过管道给子进程派发任务,子进程处理完后再通过管道(或共享内存)返回结果。每个子进程对应一个管道,避免了多线程的锁竞争问题。

💡 经验之谈:进程池的大小不是越大越好。我一般根据 CPU 核心数来定,比如 4 核就开 4 个进程。太多反而会因为上下文切换降低效率。另外,子进程之间最好用不同的管道,别共用——否则你还要处理并发读写的同步问题。

18.6 知识体系总览

下面这张图把本章的核心逻辑串起来了:

进程管理核心知识体系 进程管理 fork() 创建进程 exec() 替换程序 waitpid() 回收 pipe() 管道通信 进程池模型 预创建 + 任务派发 管道通信 + 回收 fork + exec + waitpid + pipe → 构建进程池

从图上可以看得很清楚:fork 是基础,exec 是变体,waitpid 是收尾,pipe 是通信手段。把它们组合起来,就能搭出像进程池这样的实用模型。

18.7 避坑指南

最后分享几个我踩过的坑:

  • 僵尸进程:子进程结束但父进程没 wait,就会变成僵尸。我曾经在服务器上跑了一周,发现进程表里多了几百个僵尸——就是因为某个分支忘了 waitpid。
  • 管道死锁:父子进程互相等待对方写数据,结果都卡在 read 上。解决办法是设计好通信协议,或者用非阻塞 I/O。
  • 文件描述符泄漏:fork 后子进程继承了父进程的所有 fd,如果不关掉不需要的,会导致资源耗尽。我习惯在 fork 后立即 close 掉不需要的端。
  • 信号处理:子进程退出时会发 SIGCHLD 信号。如果父进程没处理,可以用 signal(SIGCHLD, SIG_IGN) 让内核自动回收——但这样你就拿不到退出码了。

嗯,进程管理这块内容不少,但核心就是这几个函数。你只要把 fork、exec、waitpid、pipe 搞透了,再复杂的多进程程序也能拆解明白。下次遇到性能瓶颈,不妨试试用进程池来分担压力——效果往往立竿见影。


公众号:蓝海资料掘金营,微信deep3321