进程终止:exit与_exit的区别、atexit注册终止处理函数、孤儿进程与僵尸进程、wait和waitpid

进程终止这个话题,说简单也简单,说复杂嘛……嗯,坑不少。我早年写一个网络服务程序时,就因为对进程退出处理不当,导致线上出现了大量僵尸进程,把系统资源吃光了。从那以后,我对这块就格外上心。

今天咱们就把进程终止的方方面面掰扯清楚。你想想看,一个进程从生到死,到底经历了什么?

一、exit() 与 _exit():两个退出函数,差在哪?

很多初学者会问:exit 和 _exit 不都是退出吗?有啥区别?

区别大了去了。我直接说结论:exit() 是标准C库函数,_exit() 是系统调用

核心区别:

  • exit() 会先执行清理工作,再调用 _exit()
  • _exit() 直接进入内核,啥也不管

具体来说,exit() 做了三件事:

  1. 调用所有通过 atexit() 注册的终止处理函数
  2. 刷新并关闭所有标准I/O流(stdin、stdout、stderr等)
  3. 调用 _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:非阻塞,没有子进程退出就立即返回 0
  • WUNTRACED:也捕获暂停的子进程
  • 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() — 标准C库函数 _exit() — 系统调用 return 0 — main函数返回 收到致命信号 异常崩溃 相关核心概念 atexit() 注册处理函数 孤儿进程 僵尸进程 wait() / waitpid() 收尸

嗯,进程终止这块内容,说白了就是两件事:怎么退怎么收尸。exit 和 _exit 管怎么退,atexit 管退之前做啥,孤儿和僵尸是退之后的状态问题,wait 系列函数管收尸。把这几个点串起来,你就全通了。

最后提醒一句:写服务端程序时,一定要在父进程里处理 SIGCHLD 信号,或者在事件循环里定期 waitpid()。否则僵尸进程会像幽灵一样,不知不觉把你的系统资源吃光。我曾经就因为这个,半夜被报警电话叫起来过……


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