Orphan, Zombie and Docker

孤儿进程的产生

孤儿进程: 父进程退出,而它的一个或多个子进程还在运行,那么那些子进程将成为孤儿进程。通常,孤儿进程将被进程号为1的进程(进程号为 1 的是 init 进程)所收养,并由该进程调用 wait 对孤儿进程收尸。

#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
int main()
{
    pid_t pid;
    pid = fork();

    if (pid == 0) {
        printf("I'm child process, pid:%d  ppid:%d\n", getpid(), getppid());

        sleep(5);
        printf("I'm child process, pid:%d  ppid:%d\n", getpid(), getppid());
    } else {
        printf("I'm father process, pid:%d  ppid:%d\n", getpid(), getppid());

        sleep(1);
        printf("father process is  exited.\n");
    }
    return 0;
}

运行结果如下所示:

I'm father process, pid:25354  ppid:13981
I'm child process, pid:25355  ppid:25354
father process is  exited.

I'm child process, pid:25355  ppid:1

一般来说,孤儿进程并没有什么危害,因为当孤儿进程结束的时候,init 进程会调用 wait 来处理。

但是当到了 Docker 环境下是不是还是这样呢? 本文第三节再详细说明。

僵尸进程的产生

僵尸进程: 子进程退出,而父进程并没有调用 wait 或 waitpid 获取子进程的状态信息,那么子进程的进程描述符仍然保存在系统中,直到它的父进程退出。

僵尸进程因为一直占据进程描述符等信息,如果它的父进程是长期运行不退出的话,僵尸进程就会一直占用系统资源。

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>

int main()
{
    pid_t pid;
    pid = fork();
  
    if (pid == 0) {
        printf("I am child process - %d. I am exiting.\n", getpid());
        exit(0);
    }
    printf("I am father process- %d. I'm sleeping....\n", getpid());
    /* 等待子进程先退出 */
    sleep(10);

    printf("father process - %d is exiting.\n", getpid());
    return 0;
}

这段代码中,子进程立刻退出变成 zombie,父进程等待 10 秒,所以在这 10 秒钟内,我们能观察到这个 zombie 的存在。

打开一个 terminal,运行上面的代码,

$ ./demo-zombie                           
I am father process- 11714. I'm sleeping....    
I am child process - 11715. I am exiting.  
father process - 11714 is exiting.

同时,在另一个 terminal 里面我们用 ps 命令输出进程树结构关系:

$ps xf -opid,ppid,stat,args

13980     1 Ss   tmux new-session -s ssh_tmux
13981 13980 Ss    \_ -bash
11714 13981 S+    |   \_ ./demo-zombie
11715 11714 Z+    |       \_ [demo-zombie] <defunct>
 8712 13980 Ss    \_ -bash
11855  8712 R+        \_ ps xf -opid,ppid,stat,args

10 秒钟过后,父进程也退出了,zombie 于是被 init 进程收养。

因为 init 进程会用 wait 为 zombie 收尸,于是 10 秒钟过后,系统中将没有这个僵尸进程了。

Docker 中的孤儿进程

上面所说的僵尸进程和孤儿进程都是非 docker 环境中,那么在 docker 里有 init 进程吗 ? 孤儿进程会被谁收养呢?

无论在不在 docker 中,我们先来看一下 linux 如何选择谁来做孤儿进程的 parent。

孤儿进程被谁接管?

看一下 Linux 内核中关于接收孤儿进程的代码

/*
 * When we die, we re-parent all our children, and try to:
 * 1. give them to another thread in our thread group, if such a member exists
 * 2. give it to the first ancestor process which prctl'd itself as a
 *    child_subreaper for its children (like a service manager)
 * 3. give it to the init process (PID 1) in our pid namespace
 */
static struct task_struct *find_new_reaper(struct task_struct *father,
                       struct task_struct *child_reaper)
{}

函数的注释部分说的非常清楚:

  1. 首先尝试找到相同线程组里其他可用的线程。
  2. 如果用 prctl 设置了 child_subreaper,那么找到这个最近的 child_subreaper。
  3. 如果上面两步都失败,则选择该 namespace 下的 init 进程(pid 为 1) 收养这个孤儿进程。

PR_SET_CHILD_SUBREAPER

prctl 顾名思义就是 process control 的缩写。其中关于 PR_SET_CHILD_SUBREAPER 部分的说明如下

PR_SET_CHILD_SUBREAPER (since Linux 3.4)

              A subreaper fulfills the role of init(1) for its descendant
              processes.  When a process becomes orphaned (i.e., its
              immediate parent terminates) then that process will be
              reparented to the nearest still living ancestor subreaper.
              Subsequently, calls to getppid() in the orphaned process will
              now return the PID of the subreaper process, and when the
              orphan terminates, it is the subreaper process that will
              receive a SIGCHLD signal and will be able to wait(2) on the
              process to discover its termination status.

              The setting of the "child subreaper" attribute is not
              inherited by children created by fork(2) and clone(2).  The
              setting is preserved across execve(2).

              Establishing a subreaper process is useful in session
              management frameworks where a hierarchical group of processes
              is managed by a subreaper process that needs to be informed
              when one of the processes—for example, a double-forked daemon—
              terminates (perhaps so that it can restart that process).
              Some init(1) frameworks (e.g., systemd(1)) employ a subreaper
              process for similar reasons.

当一个进程调用 prctl 设置了这个参数以后,它的子进程都被标记为拥有一个 subreaper。当某个子进程成为了孤儿进程,那么会沿着进程树向祖先找一个最近的是 child_subreaper 并且运行着的进程,这个进程将会接管这个孤儿进程。

Docker 中的 pid = 1 进程

由上面的分析我们知道了,如果产生了孤儿进程,系统首先尝试找它的祖先进程树种找到一个合适的父进程,实在找不到才分配 pid = 1 的进程给它。

这么做通常没有问题,因为一般来收系统的 pid 1 进程是 init 进程,init 会对子进程调用 wait(),因此不会产生僵尸进程。

那么在 docker 还是这样吗? 我们先启动一个 docker 运行 sleep 1000 秒,然后再进入到容器中查看进程树。

$ docker run -d --name ubuntu ubuntu:latest sleep 1000
11eff3a64d76760366a6056e1eaaacada07e840c6ee896c47f4d57be62c92d5f


$ docker exec -it 11eff3a6 /bin/bash

root@11eff3a64d76:/# ps xf -opid,ppid,args
  PID  PPID COMMAND
    8     0 /bin/bash
   22     8  \_ ps xf -opid,ppid,args
    1     0 sleep 1000

可以看到,容器内根本没有 init 进程,PID 为 1 的是 sleep 进程,很显然,这个简单的 sleep 进程是不会调用 wait 去给子进程收尸的。

Docker 中孤儿进程会变成僵尸吗?

如果我们故意在容器内产生一个孤儿进程,是不是它的父进程就会被设置为 sleep 进程,又因为 sleep 没有 wait,所以最后产生僵尸进程呢?

我们再来做一个实验,

  1. 首先,用 docker 启动一个 sleep 程序,它的 PID 为 1.
  2. 然后,把第一节中产生孤儿进程的程序挂载到 docker
$docker run -d -rm --name ubuntu -v `pwd`:/root/ ubuntu:latest sleep 1000

$docker exec -it ubuntu /bin/bash
$./orphan

非常幸运的是,我的两台机器上都安装 Docker,其中一个版本较新,另一个版本比较旧,运行同样的程序,看到了两种截然不同的结果。

旧的版本上,产生的孤儿进程 PPID = 1,是 sleep 进程。所以当孤儿进程结束后,sleep 显然不会给它收尸,所以产生了僵尸进程。

当多次运行这个程序后,可以看到,容器内产生了大量僵尸进程。

root@b175b45416ca:~# ./orphan
I'm father process, pid:49  ppid:8
I'm child process, pid:50  ppid:49

I'm child process, pid:50  ppid:1

root@b175b45416ca:~# ps xf -opid,ppid,stat,args
  PID  PPID STAT COMMAND
    8     0 Ss   /bin/bash
   51     8 R+    \_ ps xf -opid,ppid,stat,args
    1     0 Ss   sleep 1000
   20     1 Z    [orphan] <defunct>
   30     1 Z    [orphan] <defunct>
   33     1 Z    [orphan] <defunct>
   35     1 Z    [orphan] <defunct>
   37     1 Z    [orphan] <defunct>
   39     1 Z    [orphan] <defunct>
   41     1 Z    [orphan] <defunct>
   43     1 Z    [orphan] <defunct>
   45     1 Z    [orphan] <defunct>
   50     1 S    ./orphan

相反在较新的 Docker 中,孤儿进程 PPID = 0,多次运行程序后,容器内没有产生僵尸进程。

root@9c0cd97e5da0:~# ./orphan
I'm father process, pid:30  ppid:20
I'm child process, pid:31  ppid:30

root@9c0cd97e5da0:~# ps xf -opid,ppid,stat,args
  PID  PPID STAT COMMAND
   31     0 S    ./orphan
   20     0 Ss   /bin/bash
   32    20 R+    \_ ps xf -opid,ppid,stat,args
    1     0 Ss   sleep 1000
root@9c0cd97e5da0:~# I'm child process, pid:31  ppid:0

由此可见,新版本的 docker engine 中正确处理了产生僵尸进程的问题。

如何避免僵尸进程

直接 wait

这是最简单的方法,父进程直接调用 wait/waitpid 函数等待子进程退出。这是最简单明了的展示如何使用 wait 给子进程收尸的例子。

#include <sys/types.h>
#include <sys/wait.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
    pid_t pid;
    pid = fork();

    if (pid == 0) {  
        printf("This is the child\n");
        sleep(5);
        exit(3);
    } else {   //父进程
        int stat_val;
        waitpid(pid, &stat_val, 0);  /*阻塞等待子进程*/
        if (WIFEXITED(stat_val)) {
            printf("Child exited with code %d\n", WEXITSTATUS(stat_val));
        } else if (WIFSIGNALED(stat_val)) {
            printf("Child terminated abnormally, signal %d\n", WTERMSIG(stat_val));
        }
    }
    return 0;
}

然而直接调用 wait 是阻塞的,父进程一直在等待直到 wait 返回,它才能做其他事。显然在实际开发中不能用这样的方法。

通过信号机制

子进程退出时会自动向父进程发送 SIGCHILD 信号,父进程先为 SIGCHILD信号注册处理函数。在信号处理函数中调用wait进行处理僵尸进程。

这样父进程就不用等待子进程退出,可以继续做自己的事。

#include <stdio.h>
#include <unistd.h>
#include <errno.h>
#include <stdlib.h>
#include <signal.h>

static void sig_child(int signo);

int main()
{
    pid_t pid;
    //创建捕捉子进程退出信号
    signal(SIGCHLD,sig_child);
    pid = fork();
    if (pid == 0) {
        printf("I am child process,pid id %d.I am exiting.\n", getpid());
        exit(0);
    }
    printf("I am father process.I will sleep two seconds\n");
    //等待子进程先退出
    sleep(5);
    //输出进程信息
    system("ps -o pid,ppid,state,tty,command");
    printf("father process is exiting.\n");
    return 0;
}

static void sig_child(int signo)
{
     pid_t      pid;
     int        stat;
     //处理僵尸进程
     while ((pid = waitpid(-1, &stat, WNOHANG)) >0)
            printf("child %d terminated.\n", pid);
}

fork() 两次

原理是将子进程成为孤儿进程,从而其的父进程变为init进程,通过init进程处理僵尸进程。代码的逻辑结构大致如下:

int main()
{
    pid_t  pid;
    //创建第一个子进程
    pid = fork();
    if (pid < 0) {
        perror("fork error:");
        exit(1);
    } else if (pid == 0) {
       
        pid = fork();

        if (pid >0) {
            printf("first procee is exited.\n");
            exit(0);
        }

        //第二个子进程
        do_something();    
    }

    //父进程处理第一个子进程退出
    if (waitpid(pid, NULL, 0) != pid)
    {
        perror("waitepid error:");
        exit(1);
    }
    return 0;
}

参考资料

新旧 Docker 版本信息

$docker version

新版本

Server: Docker Engine - Community
 Engine:
  Version:          18.09.6
  API version:      1.39 (minimum version 1.12)
  Go version:       go1.10.8
  Git commit:       481bc77
  Built:            Sat May  4 01:59:36 2019
  OS/Arch:          linux/amd64
  Experimental:     false

旧版本

Server:
 Engine:
  Version:          18.06.1-ce
  API version:      1.38 (minimum version 1.12)
  Go version:       go1.10.1
  Git commit:       e68fc7a
  Built:            Thu Jan 24 10:49:48 2019
  OS/Arch:          linux/amd64
  Experimental:     false
comments powered by Disqus