Linux

关注公众号 jb51net

关闭
首页 > 网站技巧 > 服务器 > Linux > linux进程终止与等待

linux之进程终止与等待,一文讲透

作者:吹什么轩

还在为进程退出、僵尸进程、wait和waitpid头疼吗?本文用最直白的方式讲透进程终止的本质、三种退出场景、exit与_exit的区别,以及如何用wait/waitpid回收子进程、避免僵尸进程,还详细解析了status位图,让你一眼看懂退出码和信号

一、进程终止的本质

进程终止的本质是释放系统资源,就是释放进程申请的相关内核数据结构和对应的数据和代码。

注意这句话的两个层面:

所以"进程死了"不是"这个程序不跑了"这么简单,而是它占的一切资源被系统收回。

二、进程退出的三种场景

场景说明例子
代码跑完,结果正确正常结束,退出码一般是 0ls 执行成功
代码跑完,结果不正确正常结束,但退出码非 0程序逻辑判断"找不到文件",exit(1)
代码异常终止没跑完就被打断段错误、Ctrl+C、kill -9

关键区别:前两种是进程自己决定退出,第三种是被外力杀死。这两种情况在父进程 wait 时能区分(看 status 位图)。

三、进程的退出方法

3.1 正常终止(可以用echo $?查看退出码)

  1. 从 main 返回(最常见);
  2. 调用 exit;
  3. 调用 _exit。

3.2 异常退出

四、退出码(exit code)

退出码(退出状态)可以告诉我们最后一次执行的命令的状态。程序返回退出代码 0 表示执行成功;1 或 0 以外的任何代码都被视为不成功。

查看方式:

./a.out
echo $?        # 上一条第命令的退出码

Linux Shell 中的主要退出码(课件):

退出码含义
0命令执行无误,理想状态
1可以解释为"不被允许的操作"。例如没有 sudo 权限却执行 yum;再例如除以 0,命令 let a=1/0
130(128+2)被 SIGINT / ^C 终止
143(128+15)被 SIGTERM 终止

规律:信号终止的退出码 = 128 + 信号编号。

另外可以用 strerror 把错误码翻译成人类可读的描述:

#include <string.h>
#include <stdio.h>
printf("%s\n", strerror(1));   // 输出 "Operation not permitted" 之类

4.1_exit函数

#include <unistd.h>
void _exit(int status);

参数 status 定义了进程的终止状态,父进程通过 wait 来获取该值。

重点细节:

虽然 status 是 int,但仅有低 8 位可以被父进程所用。所以 _exit(-1) 时,在终端执行 echo $? 发现返回值是 255(-1 的补码低 8 位 = 0xFF = 255)。

4.2exit函数

#include <unistd.h>
void exit(int status);

exit 最后也会调用 _exit,但在调用 _exit 之前,还做了其他工作:

  1. 执行用户通过 atexit 或 on_exit 定义的清理函数;
  2. 关闭所有打开的流,所有的缓存数据均被写入;
  3. 调用 _exit。

4.3exitvs_exit:一个能看见输出,一个看不见

这是最经典的区别演示。

int main()
{
    printf("hello");      // 注意:没有 \n
    exit(0);
}

运行结果:

hello[root@localhost linux]#

int main()
{
    printf("hello");      // 同样没有 \n
    _exit(0);
}

运行结果:

[root@localhost linux]#

什么也没打印出来!

原因链:

  1. printf("hello") 没有 \n,且 stdout 是行缓冲(终端下),所以"hello" 只待在用户态缓冲区里,还没真正 write 到内核;
  2. exit 会刷新所有流(把缓冲区写出去),所以你看到了 hello;
  3. _exit 不刷新缓冲区,直接进入内核销毁进程,缓冲区里的数据随进程一起蒸发了,所以你什么也看不到。

这也回头解释了第 2 篇那个坑:printf 不带 \n 就 fork,缓冲区被复制,父子各刷一次,于是"打印了两遍"。理解缓冲区,很多怪现象就不怪了。

4.4return退出

return 是一种更常见的退出方法。执行 return n 等同于执行 exit(n),因为调用 main 的运行时函数会把 main 的返回值当作 exit 的参数。

所以这三种写法等价:

int main() { return 0; }            // 从 main 返回
int main() { exit(0); }             // 显式调用
int main() { _exit(0); }            // 极端:不刷缓冲区

五、进程等待:为什么必须等

5.1 四条必要性(课件原话)

不管不顾会造成僵尸进程,进而内存泄漏。 子进程退出,父进程如果不管,就可能造成僵尸问题。

僵尸"刀枪不入"。 进程一旦变成僵尸状态,kill -9 也无能为力,因为你没有办法杀死一个已经死去的进程。

父进程需要知道子进程干得怎么样。 父进程派给子进程的任务完成得如何?结果对不对?是否正常退出?

回收资源需要主动动作。 父进程通过进程等待的方式,回收子进程资源,获取子进程退出信息。

一句话:wait 是杀死僵尸的唯一正道。

5.2wait

#include <sys/types.h>
#include <sys/wait.h>
pid_t wait(int *status);

返回值:成功返回被等待进程的 pid,失败返回 -1。

参数:status 是输出型参数,获取子进程退出状态;不关心可以传 NULL。

特性:

5.3waitpid(更强大)

pid_t waitpid(pid_t pid, int *status, int options);

返回值:

参数 pid:

值含义
-1等待任意一个子进程(与 wait 等效)
>0等待指定 pid 的子进程

参数 status(输出型):

宏作用
WIFEXITED(status)若为正常终止子进程返回的状态,则为真(判断是否正常退出)
WEXITSTATUS(status)若 WIFEXITED 非零,提取子进程退出码

参数 options:

5.4 三种时机下的行为

情况wait / waitpid 的行为
子进程已经退出立即返回,释放资源,拿到退出信息
子进程存在且正在运行可能阻塞(阻塞模式下)
不存在该子进程立即出错返回

第三种很关键:没有子进程时 wait 会立刻返回 -1(errno = ECHILD),不会傻等。

六、status 不是整数,是位图

status 不能简单地当作整形来看待,可以当作位图来看待(只研究低 16 比特位)。

低 16 位的布局:

 15          8 7        0
 ┌────────────┬─────────┐
 │  退出状态码  │ 终止信号 │
 │ (exit code) │ (signal)│
 └────────────┴─────────┘
        ↑            ↑
  (st >> 8) & 0xFF   st & 0x7F

所以:

6.1 手动解析 status

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main(void)
{
    pid_t pid;
    if ((pid = fork()) == -1)
        perror("fork"), exit(1);
    if (pid == 0) {
        sleep(20);
        exit(10);                    // 子进程 20 秒后以 10 退出
    } else {
        int st;
        int ret = wait(&st);
        if (ret > 0 && (st & 0x7F) == 0) {      // 正常退出
            printf("child exit code: %d\n", (st >> 8) & 0xFF);
        } else if (ret > 0) {                    // 异常退出
            printf("sig code : %d\n", st & 0x7F);
        }
    }
    return 0;
}

测试结果(课件):

# 等 20 秒自然退出
child exit code: 10
# 在另一个终端 kill -9 掉子进程
sig code : 9

这就是"父进程怎么区分'任务成功'和'被外面杀了'"的标准答案。

七、阻塞等待 vs 非阻塞等待

7.1 阻塞等待(options = 0)

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
    pid_t pid;
    pid = fork();
    if (pid < 0) {
        printf("%s fork error\n", __FUNCTION__);
        return 1;
    } else if (pid == 0) {                 // child
        printf("child is run, pid is : %d\n", getpid());
        sleep(5);
        exit(257);
    } else {                                // parent
        int status = 0;
        pid_t ret = waitpid(-1, &status, 0);   // 阻塞式等待,等待 5 秒
        printf("this is test for wait\n");
        if (WIFEXITED(status) && ret == pid) {
            printf("wait child 5s success, child return code is :%d.\n",
                   WEXITSTATUS(status));
        } else {
            printf("wait child failed, return.\n");
            return 1;
        }
    }
    return 0;
}

运行结果:

child is run, pid is : 45110
this is test for wait
wait child 5s success, child return code is :1.

注意两点:

  1. this is test for wait 是5 秒之后才打印的——父进程 真的被卡住了;
  2. 子进程 exit(257),但打印出来是 1。因为退出码只用低 8 位:257 & 0xFF = 1。这又一次印证了第 4.1 节那条规则。

7.2 非阻塞等待(WNOHANG)

父进程不傻等,而是"一边等一边干别的":

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <vector>
typedef void (*handler_t)();        // 函数指针类型
std::vector<handler_t> handlers;    // 函数指针数组
void fun_one() { printf("这是一个临时任务1\n"); }
void fun_two() { printf("这是一个临时任务2\n"); }
void Load() {
    handlers.push_back(fun_one);
    handlers.push_back(fun_two);
}
// 父进程在等待期间可以干的"其他事"
void handler() {
    if (handlers.empty()) Load();
    for (auto iter : handlers) iter();
}
int main() {
    pid_t pid = fork();
    if (pid < 0) {
        printf("%s fork error\n", __FUNCTION__);
        return 1;
    } else if (pid == 0) {              // child
        printf("child is run, pid is : %d\n", getpid());
        sleep(5);
        exit(1);
    } else {                             // parent
        int status = 0;
        pid_t ret = 0;
        do {
            ret = waitpid(-1, &status, WNOHANG);   // 非阻塞式等待
            if (ret == 0) {
                printf("child is running\n");
            }
            handler();                     // 干点别的
        } while (ret == 0);
        if (WIFEXITED(status) && ret == pid) {
            printf("wait child 5s success, child return code is :%d.\n",
                   WEXITSTATUS(status));
        } else {
            printf("wait child failed, return.\n");
            return 1;
        }
    }
    return 0;
}

运行效果:

child is run, pid is : 45111
child is running
这是一个临时任务1
这是一个临时任务2
child is running
...(反复若干次)
wait child 5s success, child return code is :1.

这种"轮询 + 顺便干活"的模式,就是所有单线程事件循环(包括 Nginx、Redis 的雏形)的思维方式。

注意 WNOHANG 模式下 CPU 会被吃满——真实工程里要配合 sleep、select、epoll 来等,而不是死循环轮询。

八、把僵尸问题彻底解决

回到第 3 篇。当一个父进程不断 fork,正确的收尾长这样:

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
int main()
{
    pid_t id = fork();
    if (id == 0) {
        // 子进程干活
        sleep(2);
        exit(42);
    }
    // 父进程必须回收,否则子进程变僵尸
    int status = 0;
    pid_t ret = waitpid(id, &status, 0);
    if (ret == id && WIFEXITED(status)) {
        printf("子进程正常结束,退出码 = %d\n", WEXITSTATUS(status));
    }
    sleep(1);   // 给演示留点时间
    return 0;
}

三种"避免僵尸"的策略:

策略做法适用
主动 wait(推荐)父进程 wait/waitpid 回收大多数场景
忽略 SIGCHLDsignal(SIGCHLD, SIG_IGN),子进程退出自动回收不关心退出状态
让父进程先退出子进程变孤儿,由 init 收尸(double fork)守护进程

waitpid 只回收指定 pid 或任意一个子进程;有多个子进程时要循环回收,否则剩下的还是会变僵尸。

九、易错点

误解正确理解
status 就是退出码status 是位图,退出码在高 8 位,信号在低 7 位
exit(257) 父进程能拿到 257只能拿到 1(低 8 位)
_exit 和 exit 一样_exit 不刷缓冲区、不跑 atexit
wait 会等所有子进程只等任意一个,要全收得循环
没有子进程时 wait 会一直等立即返回 -1(ECHILD),不会阻塞
kill -9 能杀僵尸不能,僵尸已死,只能由父进程 wait 回收

总结

以上为个人经验,希望能给大家一个参考,也希望大家多多支持脚本之家。

您可能感兴趣的文章:
阅读全文