计算机系统篇之异常控制流(6):如何正确地回收子进程

Author: stormQ

Created: Saturday, 22. August 2020 05:00PM

Last Modified: Friday, 18. September 2020 02:48PM



摘要

本文描述了回收子进程的两种方式:堵塞方式和非堵塞方式,并介绍了如何处理信号中断,从而保证正确地回收子进程。

如何堵塞地回收子进程

我们可以通过wait函数(函数原型为__pid_t wait (__WAIT_STATUS __stat_loc))等待任意一个子进程终止。Linux man-pages中描述了wait函数发生错误时,其中一个返回值为EINTR。也就是说,wait函数会被信号中断而返回 -1。接下来,通过一个简单的示例验证这一点。

源码,proc12_main.cpp:

#include <cstdio>
#include <cstdlib>
#include <string>
#include <cstring>

#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <errno.h>

void sigint_hanlder(int)
{
    // do nothing
}

int sleep_for(int secs)
{
    // If sleep interupted by a signal, try again.
    while ((secs = sleep(secs)) > 0 && EINTR == errno)
    {
    }
    return secs;
}

void foo(int exit_status)
{
    if (0 == fork())
    {
        sleep_for(1000);
        const auto self_pid = getpid();
        std::printf("I'm child(pid:%d), exited with %d. Bye...\n"
            self_pid, exit_status);
        std::exit(exit_status);
    }
}

int main()
{
    signal(SIGINT, sigint_hanlder);

    for (int i = 0; i < 5; i++)
    {
        foo(i);
    }

    std::printf("I'm parent. Starting to wait child to exit...\n");

    int child_pid = 0, child_status = 0;
    while ((child_pid = wait(&child_status)) > 0)
    {
        std::printf("child(pid:%d) exited, exit status:%s\n", child_pid, 
            WIFEXITED(child_status) ? 
                std::to_string(WEXITSTATUS(child_status)).c_str() : "unknown");
    }

    std::printf("ECHILD:%d, EINTR:%d, errno:%d\n", ECHILD, EINTR, errno);

    return 0;
}

上述程序捕获了信号SIGINT,并设置信号处理函数。为了简单起见,信号处理函数什么都不做。当接收到信号SIGINT时,程序的预期结果为:所有子进程仍睡眠(因为sleep_for函数处理了被信号中断的情形)、父进程中的wait函数返回 -1 并设置errno的值为EINTR

编译:

$ g++ -o proc12_main proc12_main.cpp -g

运行:

$ ./proc12_main 

在另一个 Shell 窗口中发送SIGINT信号:

$ pkill -SIGINT -f "proc12_main"

运行结果:

I'm parent. Starting to wait child to exit...

上述命令会将SIGINT信号发送到所有进程(包括父进程和所有子进程)。

在发送SIGINT信号后,我们发现父进程仍在运行,未出现所预期的结果:wait函数返回 -1 并设置errno的值为EINTR

咦!难道wait函数不会被信号中断?

接下来,换一种捕获信号的方式。即用sigaction而不是signal函数捕获信号。

源码,proc13_main.cpp:

将 proc12_main.cpp 中的语句signal(SIGINT, sigint_hanlder);替换为如下内容,其余不变。

struct sigaction sa;
std::memset(&sa, 0sizeof(sa));
sa.sa_handler = sigint_hanlder;
sigaction(SIGINT, &sa, NULL);

编译:

$ g++ -o proc13_main proc13_main.cpp -g

运行:

$ ./proc13_main

在另一个 Shell 窗口中发送SIGINT信号:

$ pkill -SIGINT -f "proc13_main"

运行结果:

I'm parent. Starting to wait child to exit...
ECHILD:10, EINTR:4, errno:4

从输出结果中可以看出,父进程出现了所预期的结果:wait函数返回 -1 并设置errno的值为EINTR。因此,可以说明:存在wait函数被信号中断的情形。

进一步研究,sigaction函数能否做到——wait函数不被信号中断?

源码,proc14_main.cpp:

在 proc13_main.cpp 中语句sa.sa_handler = sigint_hanlder;的后面增加如下内容,其余不变。

sa.sa_flags = SA_RESTART;

编译:

$ g++ -o proc14_main proc14_main.cpp -g

运行:

$ ./proc14_main 

在另一个 Shell 窗口中发送SIGINT信号:

$ pkill -SIGINT -f "proc14_main"

运行结果:

I'm parent. Starting to wait child to exit...

在发送SIGINT信号后,我们发现父进程仍在运行。即达到了与 proc12_main.cpp 同样的效果。

通过上面的 3 个示例,我们可以得出一个结论:存在wait函数被信号中断的情形。

因此,如果要使用wait函数等待子进程终止,我们应该总是处理wait函数被信号中断的情形,以避免捕获信号方式可能引起的副作用,从而保证程序的正确性。

实现一个包裹函数Wait,用于处理wait函数被信号中断的情形:

int Wait(int *status)
{
  int pid = 0;
  do
  {
    pid = wait(status);
  } while (-1 == pid && EINTR == errno);

  if (nullptr != status && WIFEXITED(*status))
  {
    *status = WEXITSTATUS(*status);
  }
  return pid;
}

如何非堵塞地回收子进程

我们可以通过 Linux 信号以非堵塞的方式回收子进程。这样做可行的原因是,只要有一个子进程终止或停止,内核就会发送一个SIGCHLD信号给父进程。

由于同一类型的待处理信号至多只有一个,即信号不会排队等待。也就是说,如果父进程收到了一个SIGCHLD信号,那么表明至少有一个子进程已终止或已停止。因此,在每次处理信号时(即在每次SIGCHLD信号处理程序被调用时)都应该尽可能多地回收子进程

下面给出 CS-APP 中SIGCHLD信号处理程序的实现:

void handler2(int sig)
{
    int olderrno = errno;

    while (waitpid(-1NULL0) > 0) {
        Sio_puts("Hanlder reaped child\n");
    }
    if (errno != ECHILD)
        Sio_error("waitpid error");
    Sleep(1);
    errno = olderrno;
}

上述代码通过将wait函数放到一个循环中,从而达到只要收到了一个SIGCHLD信号就尽可能多地回收子进程的目的。

上述代码中,保存和恢复errno的做法非常值得我们借鉴。为什么要保存和恢复errno?这是因为,许多 Linux 异步信号安全的函数都会在出错返回时设置errno。在处理程序中调用这样的函数(比如:wait函数)可能会干扰主程序中其他依赖于errno的部分。注意,只有在信号处理程序要返回时才有此必要。如果信号处理程序调用_exit终止该进程,那么就不需要这样做了。

不过,上述代码中存在这样一个问题:可能发生一直堵塞原有逻辑控制流的情况
以下两种情形会出现该问题:
1)子进程停止时(即子进程收到了一个SIGTSTP / SIGTTIN / SIGTTOU信号,并且该信号处理程序是默认行为时),内核会发送一个SIGCHLD信号给父进程。父进程接收SIGCHLD信号会导致中断原有逻辑控制流以执行信号处理程序。由于信号处理程序中的waitpid(-1, NULL, 0)语句在子进程停止时不会返回,从而导致堵塞原有逻辑控制流的情况发生;
2)信号处理程序成功回收了一个已终止的子进程后,仍有子进程未终止时。在这种情况下,下一次调用waitpid(-1, NULL, 0)语句会堵塞信号处理程序。也就是说,信号处理程序在所有子进程都终止之前都不会返回原有逻辑控制流,从而导致堵塞原有逻辑控制流的情况发生。

这里,在handler2的基础上,我们将信号处理程序的行为修改为:没有已终止的子进程时就退出信号处理程序,从而不会一直堵塞原有逻辑控制流。另外,信号处理程序在执行时可以被其他不同的信号中断,但不会被相同的信号中断。为了保证正确性,我们采用一种保守做法(暂未找到必须这样做的情形,比如:在某些版本的类 Unix 系统,不这样做的话就会影响正确性),即使用包裹函数WaitWithoutSuspend(处理了waitpid函数被信号中断的情形)来等待任意一个子进程退出。

不会一直堵塞原有逻辑控制流的SIGCHLD信号处理程序实现:

int WaitWithoutSuspend(int *status)
{
  int pid = 0;
  do
  {
    pid = waitpid(-1, status, WNOHANG);
  } while (-1 == pid && EINTR == errno);

  if (nullptr != status && WIFEXITED(*status))
  {
    *status = WEXITSTATUS(*status);
  }
  return pid;
}

void sigchld_hanlder(int sig)
{
  if (SIGCHLD != sig)
  {
    return;
  }

  const auto old_errno = errno;
  while (Process::WaitWithoutSuspend(nullptr) > 0)
  {
  }
  errno = old_errno;
}

上述代码中的sigchld_hanlder函数真正做到了非堵塞地回收子进程。换言之,如果有已终止的子进程,内核就中断原有逻辑控制流进入信号处理程序进行回收,没有可回收的子进程时就将程序控制权返回给原有逻辑控制流继续执行。


下一篇:计算机系统篇之异常控制流(7):利用 fork 和 execve 实现一个简易的 shell 程序

上一篇:计算机系统篇之异常控制流(4):如何正确地让调用线程休眠一段时间

首页