等待3(waitpid的别名)在不应该返回-1并设定errno为ECHILD时却返回了。

31

上下文是这个Redis问题。我们有一个wait3()调用,等待AOF重写子进程在磁盘上创建新的AOF版本。当子进程完成后,父进程通过wait3()被通知以将旧的AOF替换为新的AOF。

但是,在上述问题的背景下,用户向我们报告了一个错误。我修改了Redis 3.0的实现,以便在wait3()返回-1而不是因此意外情况崩溃时清晰地记录日志。所以显然会发生以下情况:

  1. 当有挂起的子进程需要等待时,调用wait3()
  2. SIGCHLD应该设置为SIG_DFL,但Redis中根本没有设置这个信号的代码,因此这是默认行为。
  3. 从第二次AOF重写(第二个子进程创建)开始,wait3()开始返回-1。
  4. 我认为在当前的代码中不可能在没有挂起子进程的情况下调用wait3(),因为当创建AOF子进程时,我们将server.aof_child_pid设置为pid的值,而只有在成功的wait3()调用后才会重置它。

因此,wait3()没有理由以-1和ECHILD失败,但实际上确实失败了,所以可能出现某些意外情况导致僵尸子进程未创建。

假设1:Linux在某些奇怪的条件下是否有可能丢弃僵尸子进程,例如因为内存压力?看起来不合理,因为僵尸进程只是附加了元数据,但谁知道呢。

请注意,我们使用WNOHANG调用wait3()。并且考虑到SIGCHLD默认设置为SIG_DFL,唯一导致失败并返回-1和ECHLD的条件应该是没有可用的僵尸进程来报告信息。

假设2:另一件可能发生的事情,但如果发生,就没有解释,那就是在第一个子进程死亡后,将SIGCHLD处理程序设置为SIG_IGN,导致wait3()返回-1和ECHLD

假设3:有没有一种方法可以从外部删除僵尸子进程?也许该用户有某种脚本会在后台中删除僵尸进程,因此然后信息对wait3()不再可用?我的理解是,如果父进程不等待它(使用waitpid或处理信号),并且如果SIGCHLD不被忽略,那么永远不应该能够删除僵尸进程,但是也许有一些Linux特定的方式。

假设4:实际上,在Redis代码中存在某些错误,使我们第一次成功地wait3()了子进程而没有正确重置状态,而后来我们一遍又一遍地调用wait3()/* Check if a background saving or AOF rewrite in progress terminated. */ if (server.rdb_child_pid != -1 || server.aof_child_pid != -1) { int statloc; pid_t pid; if ((pid = wait3(&statloc,WNOHANG,NULL)) != 0) { int exitcode = WEXITSTATUS(statloc); int bysignal = 0; if (WIFSIGNALED(statloc)) bysignal = WTERMSIG(statloc); if (pid == -1) { redisLog(LOG_WARNING,"wait3() returned an error: %s. " "rdb_child_pid = %d, aof_child_pid = %d", strerror(errno), (int) server.rdb_child_pid, (int) server.aof_child_pid); } else if (pid == server.rdb_child_pid) { backgroundSaveDoneHandler(exitcode,bysignal); } else if (pid == server.aof_child_pid) { backgroundRewriteDoneHandler(exitcode,bysignal); } else { redisLog(REDIS_WARNING, "Warning, detected child with unmatched pid: %ld", (long)pid); } updateDictResizePolicy(); } } else {

backgroundRewriteDoneHandler 的选定部分:

<code>void backgroundRewriteDoneHandler(int exitcode, int bysignal) {
    if (!bysignal && exitcode == 0) {
        int newfd, oldfd;
        char tmpfile[256];
        long long now = ustime();
        mstime_t latency;

        redisLog(REDIS_NOTICE,
            "Background AOF rewrite terminated with success");

        ... more code to handle the rewrite, never calls return ...

    } else if (!bysignal && exitcode != 0) {
        server.aof_lastbgrewrite_status = REDIS_ERR;

        redisLog(REDIS_WARNING,
            "Background AOF rewrite terminated with error");
    } else {
        server.aof_lastbgrewrite_status = REDIS_ERR;

        redisLog(REDIS_WARNING,
            "Background AOF rewrite terminated by signal %d", bysignal);
    }

cleanup:
    aofClosePipes();
    aofRewriteBufferReset();
    aofRemoveTempFile(server.aof_child_pid);
    server.aof_child_pid = -1;
    server.aof_rewrite_time_last = time(NULL)-server.aof_rewrite_time_start;
    server.aof_rewrite_time_start = -1;
    /* Schedule a new rewrite if we are waiting for it to switch the AOF ON. */
    if (server.aof_state == REDIS_AOF_WAIT_REWRITE)
        server.aof_rewrite_scheduled = 1;
}
</code>

正如您所看到的,所有的代码路径都必须执行cleanup代码以将server.aof_child_pid重置为-1。

Redis在问题期间记录的错误

21353:C 29 Nov 04:00:29.957 * AOF rewrite: 8 MB of memory used by copy-on-write

27848:M 29 Nov 04:00:30.133 ^@ wait3() returned an error: No child processes. rdb_child_pid = -1, aof_child_pid = 21353

如您所见,aof_child_pid不是-1。


1
对我来说,这听起来像是你在测试得太快,太早了,孩子只是还没有结束而已。 - alk
也许您可以详细说明一下如何确保这一点:“当我们有待等待的子进程时,调用wait3()函数”,因为显然并非如此。我必须承认,我不了解Redis代码,但是除了使用对“wait*()”的调用之外,您会使用哪些其他机制来同步进程的生存时间呢?我想您可能面临着竞争条件。 - alk
1
为了拥有更具可移植性的代码(并且可能会减少您正在观察的这些问题),您需要将 signal() 替换为 sigaction() - alk
1
@antirez 旧的 Unix 信号在第一次处理信号后会将信号处理程序重置为默认值 (SIG_DFL)。因此,假设2是可能发生的。只需用 sigaction() 替换 signal() 调用(它不会重置为 SIG_DFL),以查看是否属实。 - P.P
1
Redis在sentinelCollectTerminatedScripts()中还有另一个wait3()调用,我们能否确定这不会在这种情况下占用由rdb_child_pid / server.aof_child_pid标识的进程? - nos
显示剩余15条评论
1个回答

6

TLDR: 目前你正在依赖于未指定的 signal(2) 行为;请小心使用 sigaction 替代。

首先,SIGCHLD 很奇怪。从 sigaction手册页面 中可以看到:

POSIX.1-1990 禁止将 SIGCHLD 的动作设置为 SIG_IGN。POSIX.1-2001 允许这种可能性,以便忽略 SIGCHLD 可以用于防止创建僵尸进程(请参见 wait(2))。尽管如此,忽略 SIGCHLD 的历史 BSD 和 System V 行为不同,因此确保终止的子进程不会成为僵尸进程的唯一完全可移植的方法是捕获 SIGCHLD 信号并执行 wait(2) 或类似操作。

这里是wait(2)的手册页中的一部分:

POSIX.1-2001规定,如果将SIGCHLD的处理方式设置为SIG_IGN或者为SIGCHLD设置了SA_NOCLDWAIT标志(详见sigaction(2)),那么终止的子进程不会变成僵尸进程,调用wait()waitpid()将会被阻塞直到所有子进程都终止,然后返回ECHILD错误。注意,即使SIGCHLD的默认处理方式是"忽略"(ignore),显式地将其处理方式设置为SIG_IGN也会导致对僵尸进程子进程的不同处理。Linux 2.6符合这一规范。然而,Linux 2.4(以及更早版本)不符合:如果在忽略SIGCHLD时进行wait()waitpid()调用,那么该调用的行为就像没有忽略SIGCHLD一样,即该调用会被阻塞直到下一个子进程终止,然后返回该子进程的进程ID和状态。
请注意,如果信号处理的行为类似于设置了SIG_IGN,那么(在Linux 2.6+下)您将看到您正在看到的行为-即wait()将返回-1ECHLD,因为子进程将自动被回收。
其次,使用pthreads进行信号处理(我认为您在这里使用了它)是出了名的困难。它应该工作的方式(我相信您知道)是将进程定向信号发送到具有未屏蔽信号的任意线程中。但是,虽然线程有自己的信号掩码,但存在一个进程范围的操作处理程序。
将这两件事结合起来,我认为你遇到了我以前遇到过的问题。我曾经在使用signal()处理SIGCHLD时遇到问题(因为在pthread之前已被弃用),这些问题通过移动到sigaction并仔细设置每个线程的信号掩码得到解决。当时我的结论是,C库正在模拟(使用sigaction)我用signal()告诉它要做什么,但由于pthreads而出现了问题。
请注意,您目前依赖于未指定的行为。从signal(2)手册页面中可以看到:
引用:

多线程进程中signal()的影响是未指定的。

以下是我建议您执行的操作:
  1. 转到 sigaction()pthread_sigmask()。明确设置您关心的所有信号的处理方式(即使您认为这是当前的默认值),即使将它们设置为 SIG_IGNSIG_DFL。我在执行此操作时阻止信号(可能过于谨慎,但我从某个地方复制了示例)。

这是我的大致做法:

sigset_t set;
struct sigaction sa;

/* block all signals */
sigfillset (&set);
pthread_sigmask (SIG_BLOCK, &set, NULL);

/* Set up the structure to specify the new action. */
memset (&sa, 0, sizeof (struct sigaction));
sa.sa_handler = handlesignal;        /* signal handler for INT, TERM, HUP, USR1, USR2 */
sigemptyset (&sa.sa_mask);
sa.sa_flags = 0;
sigaction (SIGINT, &sa, NULL);
sigaction (SIGTERM, &sa, NULL);
sigaction (SIGHUP, &sa, NULL);
sigaction (SIGUSR1, &sa, NULL);
sigaction (SIGUSR2, &sa, NULL);

sa.sa_handler = SIG_IGN;
sigemptyset (&sa.sa_mask);
sa.sa_flags = 0;
sigaction (SIGPIPE, &sa, NULL);     /* I don't care about SIGPIPE */

sa.sa_handler = SIG_DFL;
sigemptyset (&sa.sa_mask);
sa.sa_flags = 0;
sigaction (SIGCHLD, &sa, NULL);     /* I want SIGCHLD to be handled by SIG_DFL */

pthread_sigmask (SIG_UNBLOCK, &set, NULL);
  1. 在可能的情况下,在任何pthread操作之前设置所有信号处理程序和掩码等。在可能的情况下,不要更改信号处理程序和掩码(您可能需要在fork()调用之前和之后执行此操作)。

  2. 如果您需要为SIGCHLD设置信号处理程序(而不是依靠SIG_DFL),请尽可能让其由任何线程接收,并使用自管道方法或类似方法通知主程序。

  3. 如果必须有处理/不处理某些信号的线程,请尽量仅在相关线程中使用pthread_sigmask而非sig*调用。

  4. 以防万一您遇到下一个问题,确保在fork()之后,在子进程中重新设置信号处理方式(从头开始),而不是依赖于从父进程继承的任何处理方式。如果有一件比混合了pthread和信号更糟糕的事情,那就是混合了pthread和信号还加上了fork()

请注意,我无法完全解释为什么更改(1)有效,但它已经修复了一个非常相似的问题,并且之前依赖于某些“未指定”的东西。它最接近你的“假设2”,但我认为它实际上是旧信号函数的不完整仿真(具体来说是模拟以前signal()的竞争行为,这就是为什么首先用sigaction()替换它的原因-但这只是一个猜测)。
顺便说一句,我建议您使用wait4()或(如果您没有使用rusagewaitpid()而不是wait3(),这样您可以指定要等待的特定PID。如果您有其他生成子进程的东西(我有一个库这样做),您可能会等待错误的东西。话虽如此,我不认为这就是这里发生的事情。

1
不相关,我认为。主题贴以及源代码的grep都表明,在代码中从未设置SIGCHLD的处理方式。它也没有被屏蔽(尽管这在这里无关紧要)。进一步的症状提示SIG_IGN无法继承,因为wait3至少正常工作了一次。 - pilcrow
我仍然建议使用 sigaction 正确地设置它。它第一次工作并不让我感到惊讶;对我来说,它的工作是不可靠的,并且模拟信号的某些语义要求在处理程序中重新设置信号处理。如果没有效果,那么这仍然是设置信号处理的正确方法。 - abligh
1
明智的建议,但在这里不适用。它根本没有“设置”——没有调用signal(SIGCHLD, ...)sigaction(SIGCHLD,...),我们可以推断出该进程启动时SIGCHLD设置为SIG_DFL。 - pilcrow
没错,但仍然存在SIGCHLD处理的默认状态;这可能是*'由signal()处理'*。在我的情况下,情况更加复杂(与libxl链接,如果您想要血腥细节,则需要进行自己的fork和信号处理),但我只能说上述咒语解决了问题。明确设置信号处理并不是一个坏主意(特别是考虑到SIGCHLD的默认值有些模糊),我认为这值得OP尝试。这可能与我建议的内容无关,在这种情况下,OP仅获得更清洁的信号设置。 - abligh
2
我们可能要谈论聊天领域,但即使在那些旧系统中 signal() 的行为类似于 SA_RESETHAND,SIGCHLD的默认配置也是明确定义的。这个答案是一个很好的技术阐释,但它并不是所问问题的答案。 - pilcrow

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