进程终止:exit与_exit的区别、atexit注册终止处理函数、孤儿进程与僵尸进程、wait和waitpid
进程终止这个话题,说简单也简单,说复杂嘛……嗯,坑不少。我早年写一个网络服务程序时,就因为对进程退出处理不当,导致线上出现了大量僵尸进程,把系统资源吃光了。从那以后,我对这块就格外上心。
今天咱们就把进程终止的方方面面掰扯清楚。你想想看,一个进程从生到死,到底经历了什么?
一、exit() 与 _exit():两个退出函数,差在哪?
很多初学者会问:exit 和 _exit 不都是退出吗?有啥区别?
区别大了去了。我直接说结论:exit() 是标准C库函数,_exit() 是系统调用。
核心区别:
- exit() 会先执行清理工作,再调用 _exit()
- _exit() 直接进入内核,啥也不管
具体来说,exit() 做了三件事:
- 调用所有通过 atexit() 注册的终止处理函数
- 刷新并关闭所有标准I/O流(stdin、stdout、stderr等)
- 调用 _exit() 进入内核
而 _exit() 呢?它直接告诉内核:「我不干了,剩下的你看着办」。缓冲区里的数据?不刷了。终止处理函数?不调了。
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
int main() {
printf("Hello, "); // 注意:没有换行符
// 如果用 _exit,这个 "Hello, " 不会输出
// 因为 printf 的缓冲区还没刷新
_exit(0);
// 如果用 exit(0),会先刷新缓冲区,输出 "Hello, "
// 然后才退出
}
我的建议:在普通应用层代码中,用 exit() 就够了。只有在子进程中,特别是 fork() 之后,我习惯用 _exit() —— 避免子进程不小心刷新了父进程的缓冲区,造成数据混乱。
二、atexit():给进程安排后事
atexit() 这玩意儿,说白了就是注册一个「临终遗言」函数。进程正常退出时,会按注册顺序的逆序调用这些函数。
#include <stdio.h>
#include <stdlib.h>
void cleanup1() {
printf("清理工作1:关闭数据库连接\n");
}
void cleanup2() {
printf("清理工作2:释放内存池\n");
}
int main() {
atexit(cleanup1);
atexit(cleanup2);
printf("程序运行中...\n");
return 0; // 等价于 exit(0)
}
// 输出顺序:
// 程序运行中...
// 清理工作2:释放内存池
// 清理工作1:关闭数据库连接
注意几个要点:
- 注册顺序和执行顺序相反(后进先出)
- 一个进程可以注册多个,最多 32 个(不同系统限制不同)
- 如果调用了 _exit() 或被信号杀死,atexit 函数不会执行
我曾经踩过的坑:在 atexit 处理函数里又调用了 exit(),结果导致无限递归。记住,atexit 函数里别干可能导致再次退出的事。
三、孤儿进程与僵尸进程
这两个概念,我当年学的时候总觉得差不多。其实完全两码事。
孤儿进程
父进程先挂了,子进程还在跑。这时候子进程就成了「孤儿」。内核会好心地把孤儿进程过继给 init 进程(PID=1),由 init 负责收尸。
说白了,孤儿进程不可怕,系统会自动处理。
僵尸进程
这才是真正的麻烦。子进程先退出,父进程还没调用 wait() 收尸。这时候子进程的进程描述符还留在内核里,占着 PID 和资源。
用 ps 命令看,状态栏显示的是 Z(Zombie)。
僵尸进程的危害:
- 占用 PID 号(系统 PID 有限)
- 占用进程表项
- 大量僵尸进程会导致无法创建新进程
怎么避免?父进程必须调用 wait() 或 waitpid() 来收尸。或者父进程自己先死,让孤儿变孤儿,由 init 接管。
四、wait() 和 waitpid():给子进程收尸
这两个函数,就是用来「收尸」的。父进程调用它们,获取子进程的退出状态,然后内核才彻底释放子进程的资源。
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
int main() {
pid_t pid = fork();
if (pid == 0) {
// 子进程
printf("子进程运行中,PID=%d\n", getpid());
sleep(2);
exit(42); // 退出码 42
} else {
// 父进程
int status;
pid_t child_pid = wait(&status);
if (WIFEXITED(status)) {
int exit_code = WEXITSTATUS(status);
printf("子进程 %d 正常退出,退出码=%d\n",
child_pid, exit_code);
}
}
return 0;
}
wait() vs waitpid()
| 特性 | wait() | waitpid() |
|---|---|---|
| 等待对象 | 任意一个子进程 | 指定子进程或任意 |
| 阻塞行为 | 一直阻塞 | 可设置非阻塞 |
| 灵活性 | 低 | 高(可指定 PID、可设置选项) |
waitpid() 的第三个参数 options 很实用:
WNOHANG:非阻塞,没有子进程退出就立即返回 0WUNTRACED:也捕获暂停的子进程WCONTINUED:也捕获恢复运行的子进程
// 非阻塞等待示例
int status;
pid_t ret = waitpid(child_pid, &status, WNOHANG);
if (ret == 0) {
printf("子进程还没退出,我先干别的\n");
} else if (ret == child_pid) {
printf("子进程已退出,处理退出状态\n");
}
我的经验:实际项目中,我几乎不用 wait(),只用 waitpid()。因为 waitpid() 可以指定等待哪个子进程,还能设置非阻塞,灵活得多。特别是在事件驱动模型里,非阻塞等待是必须的。
五、知识体系总览
下面这张图,把进程终止的整个脉络串起来了。你对照着看,思路会更清晰。
嗯,进程终止这块内容,说白了就是两件事:怎么退和怎么收尸。exit 和 _exit 管怎么退,atexit 管退之前做啥,孤儿和僵尸是退之后的状态问题,wait 系列函数管收尸。把这几个点串起来,你就全通了。
最后提醒一句:写服务端程序时,一定要在父进程里处理 SIGCHLD 信号,或者在事件循环里定期 waitpid()。否则僵尸进程会像幽灵一样,不知不觉把你的系统资源吃光。我曾经就因为这个,半夜被报警电话叫起来过……
公众号:蓝海资料掘金营,微信deep3321