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 负责收——三个系统调用配合起来,就是完整的进程生命周期管理。
至于孤儿进程和僵尸进程,它们不是「操作」,而是「状态」。理解了 fork 和 wait 的配合,这两个概念自然就清楚了。嗯,今天就先聊到这儿,代码写多了,记得多跑几遍看看效果。