如何使子进程在父进程退出后终止?

249
假设我有一个进程会生成一个子进程,现在无论何种原因导致父进程退出(正常或异常退出、被kill掉、^C、断言失败或任何其他原因),我都希望子进程能够随之退出。如何正确实现这个功能?
stackoverflow上的一些类似问题:
stackoverflow上一些关于Windows系统的类似问题:
24个回答

6

一些帖子中已经提到了管道和kqueue。实际上,您还可以通过调用socketpair()创建一对连接的Unix域套接字。套接字类型应为SOCK_STREAM

假设您有两个套接字文件描述符fd1、fd2。现在fork()来创建子进程,子进程将继承这些fd。在父进程中,您关闭fd2,在子进程中您关闭fd1。现在每个进程都可以poll()自己端口上剩余的打开fd以获取POLLIN事件。只要每一方在正常生命周期内没有显式close()它的fd,您就可以相当肯定地认为POLLHUP标志应该指示另一方的终止(无论是干净还是不干净)。当收到此事件通知时,子进程可以决定如何处理(例如死亡)。

#include <unistd.h>
#include <stdlib.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <poll.h>
#include <stdio.h>

int main(int argc, char ** argv)
{
    int sv[2];        /* sv[0] for parent, sv[1] for child */
    socketpair(AF_UNIX, SOCK_STREAM, 0, sv);

    pid_t pid = fork();

    if ( pid > 0 ) {  /* parent */
        close(sv[1]);
        fprintf(stderr, "parent: pid = %d\n", getpid());
        sleep(100);
        exit(0);

    } else {          /* child */
        close(sv[0]);
        fprintf(stderr, "child: pid = %d\n", getpid());

        struct pollfd mon;
        mon.fd = sv[1];
        mon.events = POLLIN;

        poll(&mon, 1, -1);
        if ( mon.revents & POLLHUP )
            fprintf(stderr, "child: parent hung up\n");
        exit(0);
    }
}

您可以尝试编译上面的概念验证代码,并在终端中运行,例如:./a.out &。您大约有100秒钟的时间来尝试使用各种信号杀死父进程ID,否则它将只是退出。无论哪种情况,您都应该看到消息“child: parent hung up”。
与使用SIGPIPE处理程序的方法相比,此方法不需要尝试write()调用。
此方法还是对称的,即进程可以使用同一通道监视彼此的存在。
此解决方案仅调用POSIX函数。我在Linux和FreeBSD中尝试了这个。我认为它应该适用于其他Unix系统,但我没有真正测试过。
另请参见:
  • Linux man页的unix(7),FreeBSD的unix(4)poll(2)socketpair(2)socket(7)

非常酷,但我真的想知道这是否存在可靠性问题。你在生产环境中测试过吗?使用不同的应用程序进行测试了吗? - Aktau
@Aktau,我在Linux程序中使用了Python版本的这个技巧。我需要它是因为子进程的工作逻辑是“在父进程退出后尽最大努力清理并退出”。然而,我真的不确定其他平台是否适用。C代码片段在Linux和FreeBSD上运行正常,但这就是我所知道的...此外,有些情况需要小心,比如父进程再次fork或者父进程在真正退出之前放弃fd(从而创建竞争条件的时间窗口)。 - Cong Ma
@Aktau - 这将是完全可靠的。 - Omnifarious

5

安装一个陷阱处理程序来捕获SIGINT信号,如果子进程仍然存活,则杀死它,尽管其他帖子正确指出它无法捕获SIGKILL。

以独占方式打开.lock文件,并使子进程轮询尝试打开它 - 如果打开成功,则子进程应该退出。


或者,子进程可以在阻塞模式下在单独的线程中打开锁定文件,这样可能是一个相当不错和干净的解决方案。虽然可能存在一些可移植性限制。 - Jean

5

正如其他人所指出的那样,依赖父进程 ID 在父进程退出时变为 1 是不可移植的。与其等待特定的父进程进程 ID,不如等待 ID 发生变化:

pit_t pid = getpid();
switch (fork())
{
    case -1:
    {
        abort(); /* or whatever... */
    }
    default:
    {
        /* parent */
        exit(0);
    }
    case 0:
    {
        /* child */
        /* ... */
    }
}

/* Wait for parent to exit */
while (getppid() != pid)
    ;

如果你不想以全速轮询,可以根据需要添加微睡眠。

这个选项对我来说似乎比使用管道或依赖信号更简单。


1
很遗憾,那个解决方案不够健壮。如果父进程在获取初始值之前死亡,那该怎么办?子进程将永远无法退出。 - dgatwood
@dgatwood,你是什么意思?!?第一个 getpid() 是在父进程调用 fork() 之前完成的。如果父进程在此之前死亡,则子进程不存在。可能发生的情况是子进程在一段时间内超过父进程。 - Alexis Wilke
在这个有点牵强的例子中,它能够工作;但在真实世界的代码中,fork 几乎总是会被 exec 跟随,并且新的进程必须通过请求其 PPID 重新开始。在这两个检查之间的时间里,如果父进程消失了,子进程将毫不知情。此外,您不太可能对父进程和子进程代码都有控制权(否则您只能将 PPID 作为参数传递)。因此,作为常规解决方案,这种方法并不是很有效。而且现实情况是,如果一个类 UNIX 的操作系统没有将 init 设为 1,那么会有很多东西崩溃,我无法想象会有人这样做。 - dgatwood
1
在执行子进程的 exec 时,将父进程的 pid 作为命令行参数传递。 - Nish
5
全速轮询是疯狂的。 - maxschlepzig
如果没有极好的理由,轮询全速运行甚至采用“微睡眠”都是不明智的,也不应该作为默认解决方案给出答案。 - Remember Monica

4
另一种Linux特有的方法是将父进程创建在新的PID命名空间中。它将成为该命名空间中的PID 1,当它退出时,它的所有子进程都将立即被用SIGKILL信号杀死。
不幸的是,要创建新的PID命名空间,您必须拥有CAP_SYS_ADMIN权限。但是,这种方法非常有效,并且除了父进程的初始启动之外,不需要对父进程或子进程进行任何实际更改。
请参阅clone(2)pid_namespaces(7)unshare(2)

1
我需要以另一种方式进行编辑。可以使用prctl使进程作为其所有子孙后代的init进程,包括曾孙等... - Omnifarious
希望你是指PR_SET_CHILD_SUBREAPER,并将其添加到答案中。 - Remember Monica
我又把它删掉了,我不认为PR_SET_CHILD_SUBREAPER是另一种方式,至少它没有被记录下来这样做。 - Remember Monica
@RememberMonica - 这并不是这样的。PR_SET_CHILD_SUBREAPER 是为了确保一个特定的进程可以捕获所有子/孙子/曾孙等的退出状态。当然,pid 命名空间也会实现这一点,但那只是一个副作用。 - Omnifarious

3

我认为一种快速而简单的方法是在子进程和父进程之间创建一个管道。当父进程退出时,子进程将接收到一个SIGPIPE信号。


1
当管道关闭时,不会发送SIGPIPE信号,只有在子进程尝试向其写入时才会发送。 - Alcaro

1

从UNIX v7开始,进程系统通过检查进程的父ID来检测进程孤立状态。历史上,init(8)系统进程是一个特殊的进程,原因只有一个:它不能死亡。这是因为内核算法处理分配新的父进程ID时依赖于此事实。当进程执行其exit(2)调用(通过进程系统调用或外部任务发送信号等方式)时,内核将所有子进程的ID重新分配为init进程的ID作为它们的父进程ID。这导致了最简单的测试和最可移植的方法,以知道进程是否已经孤立。只需检查getppid(2)系统调用的结果,如果它是init(2)进程的进程ID,则该进程在系统调用之前变成了孤立状态。

这种方法出现了两个问题,可能会导致问题:

首先,我们有可能将init进程更改为任何用户进程,那么如何确保init进程始终是所有孤立进程的父进程呢?在exit系统调用代码中,有一个明确的检查来查看执行该调用的进程是否为init进程(pid等于1的进程),如果是这种情况,内核会发生崩溃(它不再能够维护进程层次结构),因此不允许init进程进行exit(2)调用。
其次,在上面公开的基本测试中存在竞争条件。历史上假定init进程的ID为1,但这并不是POSIX方法所保证的,POSIX方法规定(如其他响应中所述)只有系统进程ID才保留了这个目的。几乎没有任何POSIX实现这样做,您可以假设在原始的Unix派生系统中,使用getppid(2)系统调用的结果为1就足以认为该进程是孤立的。另一种检查方法是在fork之后进行getppid(2)调用,并将该值与新调用的结果进行比较。这在所有情况下都不起作用,因为这两个调用不是原子的,而且父进程可能会在fork(2)之后和第一个getppid(2)系统调用之前死亡。当其父进程进行exit(2)调用时,进程的父ID只会更改一次,因此这应该足以检查getppid(2)结果是否在调用之间更改以查看父进程是否已退出。对于init进程的实际子进程,此测试无效,因为它们始终是init(8)的子进程,但您可以安全地假设这些进程也没有父进程(除非您在系统中替换了init进程)。

1

如果有其他人也遇到类似的问题,当我从C++中的分叉子进程中生成JVM实例时,唯一能够让JVM实例在父进程完成后正确终止的方法是执行以下步骤。希望如果这不是最佳方法,有人可以在评论中提供反馈。

1)在通过execv启动Java应用程序之前,在分叉的子进程上调用prctl(PR_SET_PDEATHSIG,SIGHUP),并且

2)向Java应用程序添加一个关闭挂钩,该挂钩轮询直到其父PID等于1,然后进行硬Runtime.getRuntime().halt(0)。轮询是通过启动运行ps命令的单独shell来完成的(参见:如何在Linux上使用Java或JRuby查找我的PID?)。

编辑130118:

看起来那不是一个稳健的解决方案。我仍然有点难以理解正在发生的细微差别,但是当在screen / SSH会话中运行这些应用程序时,有时仍会出现孤立的JVM进程。

我在Java应用程序中不再轮询PPID,而是让关闭挂钩执行清理,然后硬停止如上所述。然后,当需要终止所有内容时,我确保在C++父应用程序中调用waitpid来处理生成的子进程。这似乎是一种更强大的解决方案,因为子进程确保它终止,而父进程使用现有引用确保其子进程终止。与先前的解决方案相比,该解决方案使父进程在适当时终止,并使子进程在终止之前尝试确定它们是否已被孤立。


1
“PID等于1”的等待是无效的。新的父进程可能是其他PID。您应该检查它是否从原始父进程(fork()之前的getpid())更改为新的父进程(子进程中的getppid()不等于在fork()之前调用的getpid())。 - Alexis Wilke

1

POSIX下,exit()_exit()_Exit()函数被定义为:

  • 如果进程是控制进程,则将SIGHUP信号发送到调用进程所属的控制终端前台进程组中的每个进程。

因此,如果您安排父进程成为其进程组的控制进程,则当父进程退出时,子进程应该会收到SIGHUP信号。我不确定当父进程崩溃时是否会发生这种情况,但我认为会。对于非崩溃情况,它应该可以正常工作。

请注意,您可能需要阅读相当多的细节说明-包括基本定义(定义)部分以及exit()setsid()setpgrp()的系统服务信息-才能获得完整的图片。(我也是!)


3
哦。这份文档描述含糊且自相矛盾,但看起来父进程必须成为会话的主进程,而不仅仅是进程组。会话的主进程通常是登录进程,而让我的进程接管作为新会话的主进程则超出了我当前的能力范围。 - Schof
2
如果退出进程是登录 shell,则 SIGHUP 仅会被发送到子进程。http://www.opengroup.org/onlinepubs/009695399/functions/exit.html“进程的终止不会直接终止其子进程。如下所述,发送 SIGHUP 信号间接地在某些情况下终止子进程。” - Rob K
1
@Rob:正确 - 我给出的引用也是这样说的:只有在某些情况下,子进程才会收到SIGHUP信号。而且仅仅说它只是一个登录shell发送SIGHUP是过于简化了,尽管这是最常见的情况。如果一个具有多个子进程的进程将自己设置为自己和其子进程的控制进程,则当主进程死亡时,SIGHUP信号将(方便地)发送到其子进程。然而,进程很少费那么大的劲 - 所以我更多的是挑刺而不是提出真正重要的异议。 - Jonathan Leffler
2
我玩了几个小时,但无法使其正常工作。如果我有一个带有一些需要在父进程退出时全部终止的子进程的守护程序,则它将很好地处理该情况。 - Rob K

1
如果您向PID 0发送信号,例如
kill(0, 2); /* SIGINT */

该信号被发送到整个进程组,从而有效地终止了子进程。

您可以使用类似以下的内容轻松测试它:

(cat && kill 0) | python

如果您按下^D,您将看到文本"Terminated",这表明Python解释器确实已被终止,而不仅仅是因为stdin被关闭而退出。

1
(echo -e“ print(2 + 2)\ n”&kill 0)| sh -c“ python-”快乐地打印4,而不是终止。 - Kamil Szot
@KamilSzot您的示例只是包含了一种竞态条件,与这个问题无关。 - Remember Monica
@RememberMonica 为什么在这种情况下单词“终止”没有显示出来?为了澄清,在 Windows bash 中,我只能看到(echo -e "print(2+2)\n" && kill 0) | sh -c "python -"对应的4。而在 Ubuntu 的 WSL 中,无论是单个&还是&&都没有显示出来。 - Kamil Szot

0

我通过滥用终端控制和会话,成功实现了一个便携式、非轮询的解决方案,其中包括3个进程。

技巧如下:

  • 启动进程A
  • 进程A创建一个管道P(并从未从中读取)
  • 进程A分叉为进程B
  • 进程B创建一个新会话
  • 进程B为该新会话分配虚拟终端
  • 进程B安装SIGCHLD处理程序以在子进程退出时死亡
  • 进程B设置一个SIGPIPE处理程序
  • 进程B分叉为进程C
  • 进程C执行其所需操作(例如exec()未修改的二进制文件或运行任何逻辑)
  • 进程B写入管道P(并以此方式阻塞)
  • 进程A等待进程B,并在其死亡时退出

这样做的好处是:

  • 如果进程A死亡:进程B会收到SIGPIPE信号并死亡
  • 如果进程B死亡:进程A的wait()函数返回并死亡,进程C会收到SIGHUP信号(因为当带有终端的会话的会话领导者死亡时,前台进程组中的所有进程都会收到SIGHUP信号)
  • 如果进程C死亡:进程B会收到SIGCHLD信号并死亡,从而导致进程A死亡

缺点:

  • 进程C无法处理SIGHUP信号
  • 进程C将在不同的会话中运行
  • 进程C无法使用会话/进程组API,因为这会破坏脆弱的设置
  • 为每个此类操作创建终端并不是最好的想法

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