使用SIGTERM信号调用子进程的kill命令会终止父进程,但使用SIGKILL信号则会保持父进程不被终止。

9
这篇文章是 如何防止子进程的SIGINT信号传播到并杀死父进程? 的续集。
从上一个问题中我了解到, SIGINT 并没有从子进程传递到父进程,而是发给整个前台进程组。这意味着我需要编写一个信号处理器来防止我按下 CTRL + C 时父进程退出。
我尝试实现此功能,但存在一个问题。具体来说,我调用 kill 系统调用以终止子进程。如果我传递 SIGKILL,则一切都按预期工作;但是,如果我传递 SIGTERM,则它也会终止父进程,并在 shell 提示符中显示“Terminated: 15”。
即使 SIGKILL 可以奏效,但我想使用 SIGTERM,因为根据我所读到的,它似乎是一个更好的想法,可以给予被信号终止的进程一个清理自己的机会。
以下代码是我想到的一个简化示例:
#include <stdio.h>
#include <signal.h>
#include <stdlib.h>
#include <unistd.h>

pid_t CHILD = 0;
void handle_sigint(int s) {
  (void)s;
  if (CHILD != 0) {
    kill(CHILD, SIGTERM); // <-- SIGKILL works, but SIGTERM kills parent
    CHILD = 0;
  }
}

int main() {
  // Set up signal handling
  char str[2];
  struct sigaction sa = {
    .sa_flags = SA_RESTART,
    .sa_handler = handle_sigint
  };
  sigaction(SIGINT, &sa, NULL);

  for (;;) {
    printf("1) Open SQLite\n"
           "2) Quit\n"
           "-> "
          );
    scanf("%1s", str);
    if (str[0] == '1') {
      CHILD = fork();
      if (CHILD == 0) {
        execlp("sqlite3", "sqlite3", NULL);
        printf("exec failed\n");
      } else {
        wait(NULL);
        printf("Hi\n");
      }
    } else if (str[0] == '2') {
      break;
    } else {
      printf("Invalid!\n");
    }
  }
}

我有一个合理的猜测,为什么会出现这种情况,可能是某个东西拦截了SIGTERM信号,并杀死了整个进程组。而当我使用SIGKILL时,无法拦截该信号,因此我的kill调用按预期工作。但这只是一种猜测。

有人能解释一下为什么会发生这种情况吗?

顺便说一句,我对我的handle_sigint函数并不满意。有没有更标准的方法来终止交互式子进程?


2
这是一个非常好的问题,但我现在太困了,无法进行尝试。同时请注意,信号掩码是如何从父进程传递到子进程的;规则非常复杂,每次都需要查阅。你的“无法拦截”的假设很好,但可能是错误的,因为内核根据请求重新排列信号;进程不参与其中。 - msw
1
如果SQLite没有启动自己的进程组,我会非常惊讶,并且我保证它会改变自己的信号掩码。 - msw
2
@msw:我没有查看源代码,但是通过下面的示例程序,我可以告诉你sqlite3不会阻塞HUPTERMQUITUSR1USR2信号;而且它似乎处理或忽略了INT信号(如果在其他地方通过kill发送,则在终端上显示为^C)。请注意,您始终可以使用例如kill -HUP $(ps -C sqlite3 -o pid=)向所有正在运行的sqlite3进程发送HUP信号;这就是我用于测试的方法。 - Nominal Animal
1个回答

14

你的代码中有太多的错误(由于未在struct sigaction中清除信号掩码),任何人都无法解释你正在遇到的效果。

相反,考虑以下可用的示例代码,例如example.c

#define  _POSIX_C_SOURCE 200809L
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>
#include <string.h>
#include <stdio.h>
#include <errno.h>

/* Child process PID, and atomic functions to get and set it.
 * Do not access the internal_child_pid, except using the set_ and get_ functions.
*/
static pid_t   internal_child_pid = 0;
static inline void  set_child_pid(pid_t p) { __atomic_store_n(&internal_child_pid, p, __ATOMIC_SEQ_CST);    }
static inline pid_t get_child_pid(void)    { return __atomic_load_n(&internal_child_pid, __ATOMIC_SEQ_CST); }

static void forward_handler(int signum, siginfo_t *info, void *context)
{
    const pid_t target = get_child_pid();

    if (target != 0 && info->si_pid != target)
        kill(target, signum);
}

static int forward_signal(const int signum)
{
    struct sigaction act;

    memset(&act, 0, sizeof act);
    sigemptyset(&act.sa_mask);
    act.sa_sigaction = forward_handler;
    act.sa_flags = SA_SIGINFO | SA_RESTART;

    if (sigaction(signum, &act, NULL))
        return errno;

    return 0;
}

int main(int argc, char *argv[])
{
    int   status;
    pid_t p, r;

    if (argc < 2 || !strcmp(argv[1], "-h") || !strcmp(argv[1], "--help")) {
        fprintf(stderr, "\n");
        fprintf(stderr, "Usage: %s [ -h | --help ]\n", argv[0]);
        fprintf(stderr, "       %s COMMAND [ ARGS ... ]\n", argv[0]);
        fprintf(stderr, "\n");
        return EXIT_FAILURE;
    }

    /* Install signal forwarders. */
    if (forward_signal(SIGINT) ||
        forward_signal(SIGHUP) ||
        forward_signal(SIGTERM) ||
        forward_signal(SIGQUIT) ||
        forward_signal(SIGUSR1) ||
        forward_signal(SIGUSR2)) {
        fprintf(stderr, "Cannot install signal handlers: %s.\n", strerror(errno));
        return EXIT_FAILURE;
    }

    p = fork();
    if (p == (pid_t)-1) {
        fprintf(stderr, "Cannot fork(): %s.\n", strerror(errno));
        return EXIT_FAILURE;
    }

    if (!p) {
        /* Child process. */

        execvp(argv[1], argv + 1);

        fprintf(stderr, "%s: %s.\n", argv[1], strerror(errno));
        return EXIT_FAILURE;
    }

    /* Parent process. Ensure signals are reflected. */        
    set_child_pid(p);

    /* Wait until the child we created exits. */
    while (1) {
        status = 0;
        r = waitpid(p, &status, 0);

        /* Error? */
        if (r == -1) {
            /* EINTR is not an error. Occurs more often if
               SA_RESTART is not specified in sigaction flags. */
            if (errno == EINTR)
                continue;

            fprintf(stderr, "Error waiting for child to exit: %s.\n", strerror(errno));
            status = EXIT_FAILURE;
            break;
        }

        /* Child p exited? */
        if (r == p) {
            if (WIFEXITED(status)) {
                if (WEXITSTATUS(status))
                    fprintf(stderr, "Command failed [%d]\n", WEXITSTATUS(status));
                else
                    fprintf(stderr, "Command succeeded [0]\n");
            } else
            if (WIFSIGNALED(status))
                fprintf(stderr, "Command exited due to signal %d (%s)\n", WTERMSIG(status), strsignal(WTERMSIG(status)));
            else
                fprintf(stderr, "Command process died from unknown causes!\n");
            break;
        }
    }

    /* This is a poor hack, but works in many (but not all) systems.
       Instead of returning a valid code (EXIT_SUCCESS, EXIT_FAILURE)
       we return the entire status word from the child process. */
    return status;
}

使用例如编译它。

gcc -Wall -O2 example.c -o example

并且可以通过例如以下方式运行。

./example sqlite3

你会注意到,即使你直接运行 sqlite3Ctrl+C 也不会中断它;相反,你只会在屏幕上看到 ^C。这是因为 sqlite3 设置了终端,使得 Ctrl+C 不会引起信号,而只是被视为普通输入。

你可以使用 .quit 命令退出 sqlite3,或在行首按下 Ctrl+D

你会看到原始程序在返回命令行之前会输出一个 Command ... [] 行。因此,父进程不会被信号杀死/损坏/打扰。

你可以使用 ps f 查看终端进程树,并找出父进程和子进程的 PID,然后向任意一个发送信号以观察发生了什么。

请注意,由于无法捕获、阻止或忽略 SIGSTOP 信号,因此反映作业控制信号(例如当你使用 Ctrl+Z 时)可能不那么简单。对于正确的作业控制,父进程需要设置新会话和新进程组,并暂时与终端分离。这也是完全可能的,但有点超出本文的范围,因为它涉及到会话、进程组和终端的详细行为。

让我们解构上面的示例程序。

该示例程序首先安装了一些信号反射器,然后 fork 了一个子进程,该子进程执行命令 sqlite3。(你可以指定任何可执行文件和任何参数字符串给程序。)

internal_child_pid 变量、set_child_pid()get_child_pid() 函数用于原子地管理子进程。__atomic_store_n()__atomic_load_n() 是编译器提供的内建函数;对于 GCC,请参见此处以获取详细信息。它们避免了在只部分分配子进程 PID 时发生信号的问题。在某些常见架构中,这种情况不会发生,但这是一个谨慎的示例,因此使用原子访问来确保只有完整的(旧的或新的)值才能被看到。如果在过渡期间暂时阻止相关信号,我们可以完全避免使用这些。再次强调,我决定使用原子访问更简单,并且在实践中可能会有趣。

forward_handler()函数原子地获取子进程PID,然后验证其是否非零(以确保我们有一个子进程),并且不会转发由子进程发送的信号(仅确保我们不会造成信号风暴,两个进程互相轰炸对方的信号)。siginfo_t结构中的各个字段在man 2 sigaction man页面中列出。

forward_signal()函数为指定的信号signum安装上述处理程序。请注意,我们首先使用memset()将整个结构清除为零。以这种方式清除它可以确保未来兼容性,如果结构中的某些填充被转换为数据字段。

struct sigaction中的.sa_mask字段是一个无序的信号集。在执行信号处理程序的线程中,屏蔽了掩码中设置的信号的传递。(对于上面的示例程序,我们可以安全地说,在运行信号处理程序时这些信号被阻止;只是在多线程程序中,这些信号仅在用于运行处理程序的特定线程中被阻止。)

重要的是要使用sigemptyset(&act.sa_mask)清除信号掩码。仅将结构设置为零是不够的,即使它在许多机器上实践中可能有效。(我不知道,我甚至没有检查过。我更喜欢强健和可靠,而不是懒惰和脆弱!)

使用了SA_SIGINFO标志,因为处理程序使用三参数形式(并使用siginfo_tsi_pid字段)。只有在OP希望使用它时才会存在SA_RESTART标志;这仅意味着如果可能,C库和内核会尝试避免返回errno == EINTR错误,如果一个信号被传递使用当前在系统调用(如wait())中阻塞的线程。您可以删除SA_RESTART标志,并在父进程的循环中的适当位置添加一个调试fprintf(stderr, "Hey!\n");以查看然后会发生什么。

如果没有错误,则sigaction()函数将返回0,否则将返回-1,并设置errno。如果成功分配forward_handler,则forward_signal()函数返回0,否则返回非零的错误号。有些人不喜欢这种返回值(他们更喜欢仅返回错误的-1,而不是errno值本身),但出于某种不合理的原因,我已经喜欢上了这种习惯用法。如果您愿意,请更改它。

现在我们来到了main()函数。

如果你没有输入任何参数或者只输入一个-h--help参数运行程序,它将会输出一个使用摘要信息。再次强调,这样做只是我喜欢的一种方式--getopt()getopt_long()更常用于解析命令行选项。对于这种简单的程序,我只是硬编码实现了参数检查。

在这种情况下,我故意让使用说明非常简短。添加一个关于程序具体功能的段落会更好。这些类型的文本--尤其是代码中的注释(解释代码应该做什么的意图,而不是描述代码实际做了什么)--非常重要。离我第一次写代码拿到报酬已经过去超过20年了,我仍然在学习如何更好地注释--描述--我的代码,所以我认为越早开始这样做就越好。

fork()部分应该很熟悉了。如果它返回-1,则说明fork失败(可能是由于限制或类似的原因),现在最好打印出errno消息。返回值将在子进程中为0,在父进程中为子进程的进程ID。

execlp()函数有两个参数:要执行的二进制文件名称(指定在PATH环境变量中的目录将用于查找这样的二进制文件),以及一个指向该二进制文件参数的指针数组。第一个参数将是新二进制文件中的argv[0],也就是命令本身的名称。

execlp(argv[1], argv + 1);调用实际上很容易解析,如果你将它与上面的描述进行比较。 argv[1]命名要执行的二进制文件。 argv+1基本上等同于(char **)(&argv[1]),即一个从argv[1]开始而不是argv[0]的指针数组。再次强调,我只是喜欢execlp(argv[n], argv + n)的习惯用法,因为它允许我们在不必担心解析命令行或通过Shell执行它(有时是绝对不希望的)的情况下,执行在命令行中指定的另一个命令。

man 7 signal 页面解释了在 fork()exec() 中会发生什么事情。简单来说,在 fork() 中信号处理程序被继承,但在 exec() 中重置为默认值,这正好是我们想要的。

如果我们首先进行 fork ,然后再安装信号处理程序,那么在子进程存在但父进程仍具有默认处理程序(大多数是终止)的情况下,就会出现一个窗口。

相反,我们可以在 fork 前在父进程中使用例如 sigprocmask() 来阻塞这些信号。阻塞信号意味着它被“等待”;直到信号被解除阻塞之前,它将不会被发送。在子进程中,信号可以保持阻塞,因为通过 exec() 信号处理程序会被重置为默认值。在父进程中,我们可以在 fork 前或后安装信号处理程序,最后解除信号的阻塞。这样做的好处在于,我们既不需要原子机制,也不需要检查子进程的 pid 是否为零,因为在任何信号被发送之前,子进程的 pid 就会被设置为其实际值!

while 循环基本上只是一个围绕 waitpid() 调用的循环,直到我们启动的确切子进程退出,或者发生一些有趣的事情(子进程以某种方式消失)。这个循环包含相当仔细的错误检查,以及正确的 EINTR 处理,如果信号处理程序是在没有使用 SA_RESTART 标志的情况下安装的。

如果我们 fork 的子进程退出了,我们将检查它的退出状态和/或死亡原因,并向标准错误打印诊断消息。

最后,程序以可怕的 hack 结束:我们不返回 EXIT_SUCCESSEXIT_FAILURE,而是返回我们在子进程退出时使用 waitpid 获得的整个状态字。我保留了这个,是因为在实践中有时会使用它,当您希望返回与子进程返回的相同或类似的退出状态代码时。所以,这是为了说明问题。如果您曾经发现自己处于需要使程序返回与 fork 和执行的子进程相同的退出状态的情况下,这仍然比设置机制让进程自杀更好,使用相同信号杀死子进程。只需在那里放置一个突出的注释,并在安装说明中添加一条注释,以便那些在可能不需要的体系结构上编译程序的人可以进行修复。


这太棒了...我还在处理所有的内容,但感谢您写下来。我倾向于等到我能够提供赏金,然后再加上它。 - m0meni
2
@AR7:不用了。我写这篇文章的目的是希望你的问题也是其他人会遇到的,并且他们也会发现一个解释性的例子很有用。一下子理解起来可能有些困难,但如果你耐心阅读那堵墙壁般的文字,你也可以让它变得更好,并进行调整。如果你对这个例子或其行为有任何问题或疑问,请在这里添加评论,这样我就会收到通知;我只是偶尔上线。 - Nominal Animal
我打算多次仔细阅读它...再次感谢,一旦我完全理解了它,我一定会告诉你是否有任何问题。 - m0meni
1
@NominalAnimal 这篇文章写得非常好,非常感谢你。 - user5070125

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接