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 是通信手段。把它们组合起来,就能搭出像进程池这样的实用模型。
18.7 避坑指南
最后分享几个我踩过的坑:
- 僵尸进程:子进程结束但父进程没 wait,就会变成僵尸。我曾经在服务器上跑了一周,发现进程表里多了几百个僵尸——就是因为某个分支忘了 waitpid。
- 管道死锁:父子进程互相等待对方写数据,结果都卡在 read 上。解决办法是设计好通信协议,或者用非阻塞 I/O。
- 文件描述符泄漏:fork 后子进程继承了父进程的所有 fd,如果不关掉不需要的,会导致资源耗尽。我习惯在 fork 后立即 close 掉不需要的端。
- 信号处理:子进程退出时会发 SIGCHLD 信号。如果父进程没处理,可以用 signal(SIGCHLD, SIG_IGN) 让内核自动回收——但这样你就拿不到退出码了。
嗯,进程管理这块内容不少,但核心就是这几个函数。你只要把 fork、exec、waitpid、pipe 搞透了,再复杂的多进程程序也能拆解明白。下次遇到性能瓶颈,不妨试试用进程池来分担压力——效果往往立竿见影。
公众号:蓝海资料掘金营,微信deep3321