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

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

206

通过在 prctl() 系统调用中指定选项 PR_SET_PDEATHSIG ,子进程可以要求内核在父进程死亡时发送 SIGHUP(或其他信号),例如:

prctl(PR_SET_PDEATHSIG, SIGHUP);

详情请参见 man 2 prctl

注:此功能仅适用于 Linux。


10
这个解决方案很差,因为父对象可能已经被删除了。这是一种竞态条件。正确的解决方案请参考:http://stackoverflow.com/a/17589555/412080。 - Maxim Egorushkin
28
称一个答案为“差劲”并不太好,即使它没有解决竞态条件的问题。请参阅我的答案,了解如何以无竞争条件的方式使用prctl()。顺便说一下,Maxim提供的答案是错误的。 - maxschlepzig
6
这只是一个错误的答案。它会在调用fork的线程死亡时向子进程发送信号,而不是当父进程死亡时。 - Lothar
2
@Lothar 很高兴能够看到一些证据。 man prctl 表示:将调用进程的父进程死亡信号设置为arg2(在1..maxsig范围内的信号值或0以清除)。这是调用进程在其父进程死亡时将收到的信号。该值在fork(2)的子进程中被清除,并且在执行set-user-ID或set-group-ID二进制文件时(自Linux 2.4.36 / 2.6.23开始)也会被清除。 - qrdl
1
@maxschlepzig 感谢提供新链接。似乎之前的链接已失效。顺便说一下,多年过去了,仍然没有API可以在父级设置选项。真遗憾。 - rox
显示剩余8条评论

74

我也在解决相同的问题,由于我的程序必须在OS X上运行,所以Linux-only的解决方案对我没有用。

我得出了与此页面上的其他人相同的结论--没有一种符合POSIX标准的方法可以在父进程死亡时通知子进程。因此,我采用了次优解--让子进程进行轮询。

当父进程(无论何种原因)死亡时,子进程的父进程变为进程1。如果子进程简单地定期轮询,它可以检查其父进程是否为1。如果是,则子进程应该退出。

虽然这并不完美,但它能够正常工作,并且比此页面上其他人提出的TCP套接字/锁文件轮询解决方案更容易实现。


8
很棒的解决方案。不断调用getppid(),直到它返回1,然后退出。这很好,我现在也使用它。不过一个非轮询的解决方案会更好。谢谢Schof。 - neoneye
12
仅提供信息,在Solaris操作系统中,如果你在一个zone中,调用gettpid()函数并不会返回1,而是会返回该zone调度程序(进程zsched)的进程ID。 - Patrick Schlüter
4
如果有人想知道,在安卓系统中,当父进程死亡时,pid似乎会变成0(系统进程pid),而不是1。请注意,这里的pid指的是进程ID。 - Rui Marques
4
为了实现更健壮和平台无关的方法,在执行fork()之前,先使用getpid()函数获取当前进程ID,如果在子进程中调用getppid()返回的父进程ID与父进程的不同,则退出子进程。 - Sebastien
3
如果您无法控制子进程,则此方法无法实现。例如,我正在开发一个命令来包装find(1),如果包装器因某种原因死亡,我希望确保可以杀死find进程。 - Lucretiel
显示剩余8条评论

40
在Linux下,您可以在子进程中安装一个父进程死亡信号,例如:

在Linux中,您可以为子进程安装父进程死亡信号,例如:

#include <sys/prctl.h> // prctl(), PR_SET_PDEATHSIG
#include <signal.h> // signals
#include <unistd.h> // fork()
#include <stdio.h>  // perror()

// ...

pid_t ppid_before_fork = getpid();
pid_t pid = fork();
if (pid == -1) { perror(0); exit(1); }
if (pid) {
    ; // continue parent execution
} else {
    int r = prctl(PR_SET_PDEATHSIG, SIGTERM);
    if (r == -1) { perror(0); exit(1); }
    // test in case the original parent exited just
    // before the prctl() call
    if (getppid() != ppid_before_fork)
        exit(1);
    // continue child execution ...

请注意,在分叉之前存储父进程id,并在子进程中使用prctl()测试它,可以消除调用子进程的进程退出和prctl()之间的竞态条件。

还要注意,在其自己新创建的子进程中,子进程的父死亡信号被清除。它不会因为execve()而受到影响。

如果我们确定负责接管所有孤儿进程的系统进程具有PID 1,那么该测试可以简化:

pid_t pid = fork();
if (pid == -1) { perror(0); exit(1); }
if (pid) {
    ; // continue parent execution
} else {
    int r = prctl(PR_SET_PDEATHSIG, SIGTERM);
    if (r == -1) { perror(0); exit(1); }
    // test in case the original parent exited just
    // before the prctl() call
    if (getppid() == 1)
        exit(1);
    // continue child execution ...
依赖于系统进程的处理过程必须是init并且具有PID 1,这种方法不可移植。然而,POSIX.1-2008指定

调用进程的所有现有子进程和僵尸进程的父进程ID应设置为实现定义的系统进程的进程ID。换句话说,这些进程将由特殊的系统进程继承。

传统上,接收所有孤儿进程的系统进程是PID 1,即init - 它是所有进程的祖先。

在像LinuxFreeBSD这样的现代系统中,另一个进程可能会具有该角色。例如,在Linux上,进程可以调用prctl(PR_SET_CHILD_SUBREAPER, 1)来将自己确定为继承其任何后代的所有孤儿的系统进程(参见Fedora 25上的示例)。


1
@JohannesSchaub-litb,它不必是PID 1 - POSIX指定:调用进程的所有现有子进程和僵尸进程的父进程ID应设置为实现定义的系统进程的进程ID。也就是说,这些进程将被一个特殊的系统进程继承。例如,在Fedora 25系统的Gnome终端上运行时,特殊的系统进程具有PID!= 1:https://gist.github.com/gsauthof/8c8406748e536887c45ec14b2e476cbc - maxschlepzig
1
@JohannesSchaub-litb,你不能总是假设一个进程的祖先将是 init(8) 进程... 你唯一能假设的是当一个父进程死亡时,它的父进程 ID 将会改变。这实际上只会在一个进程的生命周期中发生一次... 就是当该进程的父进程死亡时。唯一的主要例外是 init(8) 的子进程,但你受到保护,因为 init(8) 永远不会 exit(2)(在这种情况下内核会崩溃)。 - Luis Colorado
2
不幸的是,如果一个子进程从一个线程中分叉出来,然后该线程退出,那么子进程将收到 SIGTERM 信号。 - rox
@y_159 是的,我在我的回答中涵盖了execve。Bash 在这里并不特别。 - maxschlepzig
1
@y_159 是的,应该可以。 - maxschlepzig
显示剩余7条评论

33
我曾经通过在“子进程”中运行“原始”代码,而在“父进程”中运行“派生”的代码(也就是在fork()之后反转了通常的测试意义)。然后在“派生”的代码中捕获SIGCHLD信号……在你的情况下可能不可行,但当它起作用时很可爱。

2
在父进程中执行工作的巨大问题是你正在改变父进程。对于必须“永久”运行的服务器来说,这不是一个选项。 - Alexis Wilke

32

如果您无法修改子进程,可以尝试以下操作:

int pipes[2];
pipe(pipes)
if (fork() == 0) {
    close(pipes[1]); /* Close the writer end in the child*/
    dup2(pipes[0], STDIN_FILENO); /* Use reader end as stdin (fixed per  maxschlepzig */
    exec("sh -c 'set -o monitor; child_process & read dummy; kill %1'")
}

close(pipes[0]); /* Close the reader end in the parent */

使用启用作业控制的 shell 进程运行子进程。子进程在后台生成。 shell 等待换行符(或 EOF),然后杀死子进程。

当父进程死亡时,无论原因是什么,它都会关闭管道的一端。子 shell 将从读取操作中获取 EOF 并继续杀死后台子进程。


3
不错,但是在这十行代码中使用了五个系统调用和一个sh进程,让我对这段代码的性能有些怀疑。 - Oleiade
1
+1. 你可以使用read -u标志从特定的文件描述符中读取,避免使用dup2和接管stdin。我还在子进程中添加了一个setpgid(0, 0)来防止在终端中按下^C时退出。 - Greg Hewgill
1
dup2() 调用的参数顺序错误。如果您想使用 pipes[0] 作为标准输入,您需要写成 dup2(pipes[0], 0) 而不是 dup2(0, pipes[0])。它是 dup2(oldfd, newfd),其中该调用关闭了先前打开的 newfd - maxschlepzig
@Oleiade,我同意,特别是因为生成的sh只是另一个fork来执行真正的子进程... - maxschlepzig
在调用 dup2() 后,您还应该关闭 pipes[0] - Jonathan Leffler

14

为了完整性,如果在 macOS 上,可以使用 kqueue:

void noteProcDeath(
    CFFileDescriptorRef fdref, 
    CFOptionFlags callBackTypes, 
    void* info) 
{
    // LOG_DEBUG(@"noteProcDeath... ");

    struct kevent kev;
    int fd = CFFileDescriptorGetNativeDescriptor(fdref);
    kevent(fd, NULL, 0, &kev, 1, NULL);
    // take action on death of process here
    unsigned int dead_pid = (unsigned int)kev.ident;

    CFFileDescriptorInvalidate(fdref);
    CFRelease(fdref); // the CFFileDescriptorRef is no longer of any use in this example

    int our_pid = getpid();
    // when our parent dies we die as well.. 
    LOG_INFO(@"exit! parent process (pid %u) died. no need for us (pid %i) to stick around", dead_pid, our_pid);
    exit(EXIT_SUCCESS);
}


void suicide_if_we_become_a_zombie(int parent_pid) {
    // int parent_pid = getppid();
    // int our_pid = getpid();
    // LOG_ERROR(@"suicide_if_we_become_a_zombie(). parent process (pid %u) that we monitor. our pid %i", parent_pid, our_pid);

    int fd = kqueue();
    struct kevent kev;
    EV_SET(&kev, parent_pid, EVFILT_PROC, EV_ADD|EV_ENABLE, NOTE_EXIT, 0, NULL);
    kevent(fd, &kev, 1, NULL, 0, NULL);
    CFFileDescriptorRef fdref = CFFileDescriptorCreate(kCFAllocatorDefault, fd, true, noteProcDeath, NULL);
    CFFileDescriptorEnableCallBacks(fdref, kCFFileDescriptorReadCallBack);
    CFRunLoopSourceRef source = CFFileDescriptorCreateRunLoopSource(kCFAllocatorDefault, fdref, 0);
    CFRunLoopAddSource(CFRunLoopGetMain(), source, kCFRunLoopDefaultMode);
    CFRelease(source);
}

你可以使用稍微更好的API来完成这个任务,使用DISPATCH_SOURCE_PROC和PROC_EXIT的调度源。 - russbishop
由于某种原因,这会导致我的 Mac 出现紧急情况。使用此代码运行进程有大约 50% 的几率会导致它冻结,导致风扇以我从未听说过的速度旋转(超级快),然后 Mac 就会关闭。请非常小心地处理此代码。 - Qix - MONICA WAS MISTREATED
@russbishop - 我尝试了您使用调度源的建议,但对我没有起作用。 这是我尝试过的代码的要点:https://gist.github.com/jdv85/5a67ae81247f21433044b0ffea404693事件处理程序块不运行。 使用 kqueue 正如 @neoneye 的答案中所述可以正常工作。 - Jonas Due Vesterheden
@JonasDueVesterheden 如果您检查libdispatch源代码,它在底层使用kqueue,因此应该是相同的。您似乎没有在任何地方保留源,因此当函数退出时,它将被释放和取消。如果您在处理程序块中捕获源,则可以解决该问题。 - russbishop
1
CFRelease(source); 之后不是应该加上 CFRunLoopRun(); 吗?类似于 https://developer.apple.com/documentation/corefoundation/cffiledescriptor-ru3#2556086,它使用了 CFRunLoopRunInMode(kCFRunLoopDefaultMode, 20.0, false);此外,我没有观察到来自 https://dev59.com/JnVC5IYBdhLWcg3wdw-5#1h8moYgBc1ULPQZF2DDz 的崩溃行为。 - timotheecour
显示剩余2条评论

14

受到这里另一个答案的启发,我想出了以下全POSIX解决方案。总体思路是在父进程和子进程之间创建一个中间进程,其唯一目的是:注意到父进程死亡,并显式地杀死子进程。

当子进程中的代码无法修改时,这种解决方案非常有用。

int p[2];
pipe(p);
pid_t child = fork();
if (child == 0) {
    close(p[1]); // close write end of pipe
    setpgid(0, 0); // prevent ^C in parent from stopping this process
    child = fork();
    if (child == 0) {
        close(p[0]); // close read end of pipe (don't need it here)
        exec(...child process here...);
        exit(1);
    }
    read(p[0], 1); // returns when parent exits for any reason
    kill(child, 9);
    exit(1);
}

使用这种方法有两个小注意事项:

  • 如果您有意杀死中间进程,则父进程死亡时子进程不会被杀死。
  • 如果子进程在父进程之前退出,则中间进程将尝试杀死原始子进程pid,但此时该pid可能已经指向另一个进程。(在中间进程中增加更多代码可以解决这个问题。)

值得一提的是,我实际使用的代码是Python。为了完整起见,以下是代码:

def run(*args):
    (r, w) = os.pipe()
    child = os.fork()
    if child == 0:
        os.close(w)
        os.setpgid(0, 0)
        child = os.fork()
        if child == 0:
            os.close(r)
            os.execl(args[0], *args)
            os._exit(1)
        os.read(r, 1)
        os.kill(child, 9)
        os._exit(1)
    os.close(r)

请注意,一段时间以前,在IRIX下,我使用了一个父/子方案,其中我在两者之间建立了一个管道,从管道中读取数据会在任何一个进程死亡时生成SIGHUP信号。这是我用来杀死fork()出的子进程的方法,而无需使用中间进程。 - Alexis Wilke
3
我认为你的第二个警告是错误的。子进程的pid是其父进程的资源,只有在父进程(中间进程)等待它(或终止并让init等待它)之后才能被释放/重复使用。 - R.. GitHub STOP HELPING ICE

12

子进程是否有与父进程的管道连接?如果是,当写入时您将收到SIGPIPE信号,或者读取时会得到EOF - 这些条件可以被检测到。


1
我发现这在 OS X 上并不可靠。 - Schof
注意事项:systemd默认禁用了服务管理中的SIGPIPE,但您仍然可以检查管道关闭情况。请参见https://www.freedesktop.org/software/systemd/man/systemd.exec.html下的IgnoreSIGPIPE。 - jdizzle

10

我不认为只使用标准的POSIX调用可以保证这一点。就像现实生活一样,一旦一个子进程被启动,它就有了自己的生命。

父进程是可能会捕获大部分可能的终止事件,并尝试在那时杀死子进程,但总会有一些无法捕获。

例如,没有进程可以捕获SIGKILL。当内核处理此信号时,它将完全不通知指定的进程而将其杀死。

扩展这个比喻 - 唯一的其他标准方法是当子进程发现自己没有父进程时自杀。

还有一种Linux专有方式可以使用prctl(2)来实现 - 参见其他答案。


9
这个解决方案对我很有用:
  • 将stdin pipe传递给子进程,您不必向流中写入任何数据。
  • 子进程无限期地从stdin读取,直到EOF。EOF表示父进程已经退出。
  • 这是一种最可靠且便携的检测父进程退出的方法。即使父进程崩溃,操作系统也会关闭管道。

这是针对一种只有在父进程存在时才有意义的工作类型进程的情况。


@SebastianJylanki 我不记得我是否尝试过,但可能会成功,因为基本类型(POSIX流)在各个操作系统中都是相当标准的。 - joonas.fi

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