Contents

关于僵尸进程

1 进程状态

在使用topps查看进程信息时,有一列字段用来标识进程当前的状态。以ps为例:

./1.png

进程状态一般用单个大写字母来表示,具体每种状态表示什么含义,为什么ps显示出来的状态有多个字母,这个查阅官方文档(https://man7.org/linux/man-pages/man1/ps.1.html)中的PROCESS STATE CODE部分即可。

这些命令本质上是从进程的/proc/PID/status目录下读取的状态信息,因此也可参考 https://man7.org/linux/man-pages/man5/proc.5.html 了解所有状态的演化发展情况。

几种常见进程状态含义如下:

  • R:进程正在运行或处于可运行状态,只要有时间片了,就会调度到 CPU 上运行
  • S:可中断的 sleep 状态,进程调用了 sleep 或在等待网络 IO 事件
  • D:不可中断的 sleep 状态,一般是进程在等待磁盘 IO(这种一般也 kill 不掉,除非阻塞解除)
  • T:收到了 STOP 信号,处于暂停状态(在命令行中按下Ctrl+z就会让进程进入这个状态)
  • t:进程被 ptrace 调试导致的暂停(GDB 底层用的 ptrace)
  • Z:僵尸进程,进程的生命周期已经结束了,但内核中还保留了一些关于它的信息,没有被彻底回收

2 僵尸进程如何产生

进程状态为 Z 的进程,就是僵尸进程。

当一个进程终止(正常或异常)后,该进程使用的系统资源会被回收,只在内核中保留了很少的信息,主要记录了一些退出状态和退出原因。这个时候进程状态就会切换为 Z,需要父进程调用 wait() 来让系统彻底回收资源。

这么设计是因为在一些场景中,父进程需要感知子进程退出的原因,调用 wait() 的同时也能拿到子进程退出时的信息,这就是为什么在 bash 中可以通过echo $?拿到上一条命令的退出码。

但是在一些特殊情况下,父进程没能正确回收子进程的资源(例如,执行到什么逻辑的时候卡住了),此时子进程会一直维持 Z 状态,这时就能通过系统命令捕获到僵尸进程了。

虽然僵尸进程不会被调度,大部分系统资源也已经释放,但是它会占用一个进程 PID,如果系统中有大量的僵尸进程产生,会耗尽 PID 资源,导致新进程无法派生。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

int main()
{
    int pid = fork();
    if (pid != 0) {
        printf("father process is stucking...\n");
        sleep(60);
    } else {
        printf("child process pid: %d\n", getpid());
        return 0;
    }
    return 0;
}

可以看到,父进程在 sleep,而子进程已经退出了,此时状态为 Z,成为了僵尸进程。

./2.png

./3.png

但是过一会,再使用ps查看进程状态时,发现僵尸进程已经消失了。这是因为当父进程退出后,僵尸子进程会挂到systemd进程下,systemd会周期调用wait()来回收子进程,最终清除了刚才的僵尸进程。

https://unix.stackexchange.com/questions/155012/how-does-linux-handles-zombie-process

3 如何处理僵尸进程

3.1 直接同步调用 wait()

最直接的办法就是在父进程的逻辑中调用 wait() 来回收终止的子进程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
    int pid = fork();
    if (pid != 0) {
        printf("father process is stucking...\n");
        int child_pid = wait(NULL);  // 这里传入非 NULL 可以获取更多子进程信息,这里不讨论
        printf("child process(%d) is finished, reap it\n", child_pid);
        sleep(60);
    } else {
        printf("child process pid: %d\n", getpid());
        return 0;
    }
    return 0;
}

./4.png

./5.png

这个时候就没有僵尸进程了。

直接使用 wait() 调用有如下几个缺点:

  • 该调用是阻塞的,如果子进程一直不退出,父进程调用后就会卡住,无法执行其它逻辑
  • 如果有多个子进程,该调用无法指定回收并获取具体的某个子进程,每次可操作的都是最先终止的

于是可以使用 watipid() 来进行更好的控制。它拥有更多的控制参数,可以让父进程只阻塞在特定的子进程上,也可以设置为不阻塞,通过轮询的的方式来回收子进程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>

int main()
{
    int pid = fork();
    if (pid != 0) { // father
        int ret = 0;
        while (ret == 0) {
            printf("father process do something else...\n");
            sleep(1);
            ret = waitpid(pid, NULL, WNOHANG);
        }
        printf("child process(%d) is finished, reap it\n", ret);
    } else {
        printf("child process pid: %d\n", getpid());
        sleep(5);
        return 0;
    }
    return 0;
}
./6.png

3.2 使用信号机制异步处理

当有子进程终止时,内核会向它的父进程发送一个SIGCHLD信号,默认该信号是被忽略的,我们可以给该信号注册一个 handler,在 handler 的逻辑中回收子进程。

当信号处理函数被调用时,接下来的SIGCHLD会被默认屏蔽,如果在处理信号的时候,又有子进程退出了,产生的信号就捕获不到了。因此,在 handler 中要考虑尽可能回收所有子进程。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/wait.h>
#include <signal.h>

void handle_child_exit(int sig)
{
    int ret = 0;
    while ((ret = waitpid(-1, NULL, WNOHANG)) > 0) {
        printf("child process(%d) is finished, reap it\n", ret);
        continue;
    }
    return;
}

int main()
{
    signal(SIGCHLD, handle_child_exit);  // call this before fork()

    int pid = fork();
    if (pid != 0) { // father
        while (1) {
            printf("father process do something else...\n");
            sleep(1);
        }
    } else {
        printf("child process pid: %d\n", getpid());
        sleep(2);
        return 0;
    }
    return 0;
}