1

前序

上次的排查,我们发现在容器里golang进程作为1号进程的时候不具备等待孤儿进程退出状态的能力,但是bash就可以,带着这个问题,我们进一步研究。

寻找思路

我们再次看下维基百科对于僵尸进程的定义。

僵尸进程定义

对于里面的内容,我们不逐字逐句分析,其中有一句话

子进程死后,系统会发送SIGCHLD信号给父进程,父进程对其默认处理是忽略。如果想响应这个消息,父进程通常在信号事件处理程序中,使用wait系统调用来响应子进程的终止。

我们找到两个关键点,SIGCHLD信号和wait系统调用。基于这两个关键点我们展开下面的讨论。

结合前面的文章,我们可以初步判断,对于托管的孤儿进程,执行golang二进制文件的进程并没有调用wait等待子进程的退出状态。至于是这种子进程结束的时候父进程没有收到SIGCHLD信号,我们可以先保持疑问。

go cmd源码

我们直接找到golang exec.go中的 Run方法

func (c *Cmd) Run() error {
    if err := c.Start(); err != nil {
        return err
    }
    return c.Wait()
}

func (p *Process) wait() (ps *ProcessState, err error) {
    if p.Pid == -1 {
        return nil, syscall.EINVAL
    }

    // If we can block until Wait4 will succeed immediately, do so.
    ready, err := p.blockUntilWaitable()
    if err != nil {
        return nil, err
    }
    if ready {
        // Mark the process done now, before the call to Wait4,
        // so that Process.signal will not send a signal.
        p.setDone()
        // Acquire a write lock on sigMu to wait for any
        // active call to the signal method to complete.
        p.sigMu.Lock()
        p.sigMu.Unlock()
    }

    var (
        status syscall.WaitStatus
        rusage syscall.Rusage
        pid1   int
        e      error
    )
    for {
        pid1, e = syscall.Wait4(p.Pid, &status, 0, &rusage)
        if e != syscall.EINTR {
            break
        }
    }
    if e != nil {
        return nil, NewSyscallError("wait", e)
    }
    if pid1 != 0 {
        p.setDone()
    }
    ps = &ProcessState{
        pid:    pid1,
        status: status,
        rusage: &rusage,
    }
    return ps, nil
}

start是以非阻塞的方式直接启动子进程,然后wait里面其实就包含了系统调用wait4。我们不难得出结论,以这种方式创建并启动的子进程,其实无需专门处理SIGCHLD信号。

我们阅读golang signal的官方文档

golang signal

我们可以看到下面这段话

Notify disables the default behavior for a given set of asynchronous signals and instead delivers them over one or more registered channels. Specifically, it applies to the signals SIGHUP, SIGINT, SIGQUIT, SIGABRT, and SIGTERM. It also applies to the job control signals SIGTSTP, SIGTTIN, and SIGTTOU, in which case the system default behavior does not occur. It also applies to some signals that otherwise cause no action: SIGUSR1, SIGUSR2, SIGPIPE, SIGALRM, SIGCHLD, SIGCONT, SIGURG, SIGXCPU, SIGXFSZ, SIGVTALRM, SIGWINCH, SIGIO, SIGPWR, SIGSYS, SIGINFO, SIGTHR, SIGWAITING, SIGLWP, SIGFREEZE, SIGTHAW, SIGLOST, SIGXRES, SIGJVM1, SIGJVM2, and any real time signals used on the system. Note that not all of these signals are available on all systems.

有一些系统信号golang进程是默认不处理的,SIGCHLD包含其中。
也就是说,如果我们想让golang进程对SIGCHLD信号做出响应,需要自己实现。

验证信号接收

如果我们想基于处理SIGCHLD信号解决僵尸进程的问题,我们首先要验证一下前面提到的问题,对于这种托管过来的孤儿进程的退出,父进程是否能收到SIGCHLD信号,话不多说,上代码。

我们在callmain中加入信号的处理

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    cmd := exec.CommandContext(ctx, "/home/rain/shell/main")
    go func() {
        c := make(chan os.Signal)
        signal.Notify(c)
        fmt.Println(time.Now(), " ready to get singnal")
        for {
            s := <-c
            fmt.Println(time.Now()," get a signal:", s.String())
        }
    }()
    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
    }
    time.Sleep(5 * time.Hour)
}

老样子执行

docker run -v C:\work\go\code\first\patest:/home/rain --entrypoint /home/rain/shell/callmain centos:7

通过终端内容我们可以看到收到了两个SIGCHLD信号,根据时间不难判断,一个是子进程main函数的结束信号,另一个是托管的sleep的结束信号。

image.png

对于sleep的结束信号,golang没有处理,所以可以看到,sleep进程成为了僵尸进程

image.png

好了,通过上面的实验,证明即便是后面托管过来的孤儿进程,它退出的时候,父进程也是有SIGCHLD信号收到的。

bash的处理方式

下载了bash4.2的源码

源码地址

这一块我没有特别深入研究详细的逻辑,所以暂时不做详细的论述,看几段示例代码。

#define UNQUEUE_SIGCHLD(os) \
    do { \
      queue_sigchld--; \
      if (queue_sigchld == 0 && os != sigchld) \
        waitchld (-1, 0); \
    } while (0)
sigchld_handler (sig)
     int sig;
{
  int n, oerrno;

  oerrno = errno;
  REINSTALL_SIGCHLD_HANDLER;
  sigchld++;
  n = 0;
  if (queue_sigchld == 0)
    n = waitchld (-1, 0);
  errno = oerrno;
  SIGRETURN (n);
}

job.c里面有很多关于这个sigchld的处理,我并没有一一分析,不过我们至少可以知道,bash对sigchld有处理逻辑而不是像golang默认忽略。

在golang中处理sigchld信号

我们尝试在golang中加入对sigchld的处理逻辑
前面我们通过对cmd源码查看,发现golang最终是调用的wait4这个系统调用,我们找到了相关文档

wait4文档
image.png

因为我们接收到sigchld信号,但是并不知道具体是哪个子进程,所以我们pid参数设置为-1(等待所有子进程),其他选项我们就选择阻塞的方式(阻塞情况下如果当前有子进程存活,就一直处于阻塞中,但是如果没有子进程存在,就会直接返回错误和-1)
我们继续修改callmain的代码

ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    cmd := exec.CommandContext(ctx, "/home/rain/shell/main")
    go func() {
        c := make(chan os.Signal, 1)
        signal.Notify(c, syscall.SIGCHLD)
        fmt.Println(time.Now(), " ready to get sigchld singnal")
        for {
            s := <-c
            fmt.Println(time.Now(), " get a signal:", s.String())

            var (
                status syscall.WaitStatus
                rusage syscall.Rusage
                pid1   int
                e      error
            )
            pid1, e = syscall.Wait4(-1, &status, 0, &rusage)
            fmt.Println(time.Now(), " child pid", pid1)
            fmt.Println(time.Now(), " err", e)

        }
    }()

    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
    }
    time.Sleep(5 * time.Hour)

编译好重新执行

docker run -v C:\work\go\code\first\patest:/home/rain --entrypoint /home/rain/shell/callmain centos:7

根据时间发现其实是main函数进程结束的时候收到了第一个SIGCHLD信号,但是因为去wait的时候,main因为是属于callmain自己启动了已经默认wait了,这时候第一时间没有获取main的退出状态。但是因为还有sleep进程存在,所以wait一直阻塞,等待sleep结束,wait执行获取到了sleep的退出状态。
等再次获取到的信号,其实才是sleep的信号。虽然这个信号和wait的结果没有一一对应,但是其实结果符合我们预期。

image.png

image.png

其中有一个小插曲,一开始执行的时候,打印出err <nil>的时候就没有日志了打印了,我看了一下signal.Notify的参数介绍,其中对第一个参数chan的说明

Package signal will not block sending to c: the caller must ensure
that c has sufficient buffer space to keep up with the expected
signal rate. For a channel used for notification of just one signal value,
a buffer of size 1 is sufficient.

也就是说,信号不会阻塞等待可以发送到chan,当我们设置为无缓冲chan的时候,当执行后面逻辑,没有尝试从chan里面获取一个元素,这时候来的信号就无法发送到chan,又不会阻塞等待,所以应该是被丢弃了。因此,可以注意到,我将chan缓冲大小设置为1。

好了,说明基于SIGCHLD信号去wait子进程的方案是有可行性的,我们可以稍微优化一下逻辑。收到SIGCHLD就循环wait一直到收到没有子进程的报错。
如下:

func main() {
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()
    cmd := exec.CommandContext(ctx, "/home/rain/shell/main")
    go func() {
        c := make(chan os.Signal, 1)
        signal.Notify(c, syscall.SIGCHLD)
        fmt.Println(time.Now(), " ready to get sigchld singnal")
        for {
            s := <-c
            fmt.Println(time.Now(), " get a signal:", s.String())

            var (
                status syscall.WaitStatus
                rusage syscall.Rusage
                pid1   int
                e      error
            )
            for {
                pid1, e = syscall.Wait4(-1, &status, 0, &rusage)
                fmt.Println(time.Now(), " child pid", pid1)
                fmt.Println(time.Now(), " err", e)
                if pid1 == -1 && strings.Contains(e.Error(), "no child processes") {
                    break
                }
            }

        }
    }()
    err := cmd.Run()
    if err != nil {
        fmt.Println(err)
    }
    time.Sleep(5 * time.Hour)
}

我们模拟的场景A、B、C类进程都有一个,后面会让A多产生一些B、C进程进行测试。


润雨冰雪
36 声望4 粉丝