PHP 中的进程重生和信号处理


Process respawn and signal handling in PHP

>Specifics

我在 PHP 中遇到了一个问题,当重生的进程不处理信号时,而在重生之前,处理工作正常。我将代码缩小到最基本的代码:

declare(ticks=1);
register_shutdown_function(function() {
    if ($noRethrow = ob_get_contents()) {
        ob_end_clean();
        exit;
    }
    system('/usr/bin/nohup /usr/bin/php '.__FILE__. ' 1>/dev/null 2>/dev/null &');
});
function handler($signal)
{
    switch ($signal) {
        case SIGTERM:
            file_put_contents(__FILE__.'.log', sprintf('Terminated [ppid=%s] [pid=%s]'.PHP_EOL, posix_getppid(), posix_getpid()), FILE_APPEND);
            ob_start();
            echo($signal);
            exit;
        case SIGCONT:
            file_put_contents(__FILE__.'.log', sprintf('Restarted [ppid=%s] [pid=%s]'.PHP_EOL, posix_getppid(), posix_getpid()), FILE_APPEND);
            exit;
    }
}
pcntl_signal(SIGTERM, 'handler');
pcntl_signal(SIGCONT, 'handler');
while(1) {
    if (time() % 5 == 0) {
        file_put_contents(__FILE__.'.log', sprintf('Idle [ppid=%s] [pid=%s]'.PHP_EOL, posix_getppid(), posix_getpid()), FILE_APPEND);
    }
    sleep(1);
}

如您所见,它执行以下操作:

  • 注册关闭函数,其中使用 nohup 重新生成进程(因此,当父进程死亡时忽略SIGHUP
  • 通过pcntl_signal()注册处理程序,用于SIGTERMSIGCONT。第一个将只记录进程已终止的消息,而第二个将导致进程重新生成。它是通过ob_*函数实现的,所以要传递一个标志,应该在关闭函数中做什么 - 退出或重生。
  • 将脚本"活动"的一些信息记录到日志文件中。

发生了什么事情

所以,我从以下方面开始脚本:

/usr/bin/nohup /usr/bin/php script.php 1>/dev/null 2>/dev/null &

然后,在日志文件中,有如下条目:

Idle [ppid=7171] [pid=8849]
Idle [ppid=7171] [pid=8849]

比方说,那么我做kill 8849

Terminated [ppid=7171] [pid=8849]

因此,它是成功处理SIGTERM(并且脚本确实退出)。现在,如果我改为kill -18 8849,那么我看到(18 是 SIGCONT 的数值):

Idle [ppid=7171] [pid=8849]
Restarted [ppid=7171] [pid=8849]
Idle [ppid=1] [pid=8875]
Idle [ppid=1] [pid=8875]

因此:首先,SIGCONT也得到了正确处理,并且从下一个"空闲"消息来看,新生成的脚本实例运行良好。

更新#1:我在考虑ppid=1(因此,init全局进程)和孤立进程信号处理的东西,但事实并非如此。这是日志部分,它表明孤立(ppid=1)进程不是原因:当worker通过控制app启动时,它也使用system()命令调用它 - 就像worker重生自身一样。但是,在控制应用程序调用worker后,它具有ppid=1并正确响应信号,而如果worker重生,则新副本不会响应它们,除了SIGKILL。因此,只有当工人重生时,问题才会出现。

更新#2:我试图分析strace发生了什么。现在,这里有两个块。

  1. 当工人尚未重生时 - 跟踪输出。看看4行和5,这是我发送SIGCONT的时候,从而kill -18到一个进程。然后它触发所有链:写入文件,system()调用并退出当前进程。
  2. 当工人已经自行重生时 - 跟踪输出。在这里,看看第 8 行和第 9 行 - 它们在收到SIGCONT后出现.首先:看起来进程仍然以某种方式接收信号,其次,它忽略了信号。未执行任何操作,但系统已通知进程已发送SIGCONT。为什么进程忽略它 - 是一个问题(因为,如果为 SIGCONT安装用户处理程序失败,那么它应该结束执行,而进程没有结束)。至于SIGKILL,那么已经重生的工人的输出是这样的:

    nanosleep({1, 0},  <unfinished ...>
    +++ killed by SIGKILL +++
    

这表明,该信号已被接收并做了它应该做的事情。

问题所在

当这个过程重生时,它既不对SIGTERM做出反应,也不对SIGCONT做出反应。但是,仍然可以用SIGKILL结束它(因此,kill -9 PID确实结束了该过程)。例如,对于上面的进程,kill 8875kill -18 8875将不执行任何操作(进程将忽略信号并继续记录消息)。

但是,我不会说注册信号完全失败 - 因为它至少重新定义了SIGTERM(这通常会导致终止,而在这种情况下它被忽略)。我也怀疑ppid = 1指向了什么错误的事情,但我现在不能确定。

另外,我尝试了任何其他类型的信号(实际上,信号代码是什么并不重要,结果总是相同的)

问题

这种行为的原因可能是什么?我正在重生一个过程的方式是正确的吗?如果没有,还有哪些其他选项可以允许新生成的进程正确使用用户定义的信号处理程序?

解决方案:最终,strace帮助理解了这个问题。具体如下:

nanosleep({1, 0}, {0, 294396497})       = ? ERESTART_RESTARTBLOCK (Interrupted by signal)
restart_syscall(<... resuming interrupted call ...>) = 0

因此,它显示信号已接收,但被忽略。为了完全回答这个问题,我需要弄清楚,为什么要处理添加的信号来忽略列表,但用pcntl_sigprocmask()强行解锁它们正在做这件事:

pcntl_sigprocmask(SIG_UNBLOCK, [SIGTERM, SIGCONT]);

然后一切顺利,重生进程按预期接收/处理信号。例如,我尝试仅添加用于解锁的SIGCONT - 然后它被正确处理,而SIGTERM被阻止,这表明这正是无法调度信号的原因。

解决方法:由于某种原因,当进程在安装了信号处理程序的情况下生成自身时,新实例会屏蔽这些信号以忽略。揭露它们有力地解决了这个问题,但为什么信号在新实例中被掩盖 - 这是目前的一个悬而未决的问题。

这是因为您通过执行 system(foo) 生成一个子进程,然后继续处理当前进程。因此,进程成为孤立进程,其父进程变为 PID 1 (init)。

您可以使用pstree命令查看更改。

以前:

init─┬─cron
(...)
     └─screen─┬─zsh───pstree
              ├─3*[zsh]
              ├─zsh───php
              └─zsh───vim

后:

init─┬─cron
(...)
     └─php

维基百科声明:

孤立进程

与僵尸进程相反,因为它指的是父进程在其子进程之前终止的情况,在这种情况下,这些子进程被称为"孤立进程"。

与子进程

终止(通过 SIGCHLD 信号)时发生的异步子进程到父进程通知不同,子进程在其父进程完成时不会立即收到通知。相反,系统只是将子进程数据中的"parent-pid"字段重新定义为系统中所有其他进程的"祖先"的进程,其 pid 通常具有值 1(一),其名称传统上为"init"。因此,据说"init'采用'系统上的每个孤儿进程"。

对于您的情况,我建议两个选项:

  • 使用两个脚本:一个用于管理子脚本,第二个用于"辅助角色",以实际执行工作,
  • 或者,使用一个脚本,该脚本将同时包含:外部部分将管理,内部部分,从外部分叉,将完成工作。