posix_spawnp 在 Debian 9 上挂起直到子进程终止

posix_spawnp 在 Debian 9 上挂起直到子进程终止

我们最近发现了一个有趣的案例,其中 posix_spawnp挂起直到它生成的子进程终止Debian 9。这在 Ubuntu(18.04) 或 CentOS(7.3) 等其他发行版上无法重现。您可以使用最后的代码片段来重现它。只需编译并运行./test_posix_spawnp sleep 30,假设您将可执行文件命名为 test_posix_spawnp。我们传入sleep 30只是为了让子进程运行一小会儿。您会看到PID of child: xxx并没有立即打印为指示器。

下面的示例代码是模拟我们的真实代码,其关键是关闭子进程中除 stdin/stdout/stderr 和为日志记录而打开的文件描述符之外的所有文件描述符,并将 stdout/stderr 重定向到日志文件。无论是在真实情况还是在这个模拟情况下,子进程似乎已经生成,并且已经开始运行正在传递的可执行文件。

我们的问题:

以前有人遇到过这个问题吗?这听起来像是一个 libc(2.24) 错误吗?如果没有,我们如何修复我们的代码?如果是这样,我们该怎么办?

PS 不确定这是否重要,但我们观察到,当它可重现时或在 Debian 上时,在执行 posix_spawnp 期间会创建一个额外的管道,父级具有读取端,子级具有写入端。

#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <spawn.h>
#include <stdio.h>
#include <unistd.h>
#include <stdlib.h>
#include <string.h>
#include <wait.h>
#include <errno.h>

#define errExit(msg)    do { perror(msg); \
                            exit(EXIT_FAILURE); } while (0)

#define errExitEN(en, msg) \
                       do { errno = en; perror(msg); \
                            exit(EXIT_FAILURE); } while (0)

char **environ;

int
main(int argc, char *argv[])
{
  pid_t child_pid;
  int s, status;
  sigset_t mask;
  posix_spawnattr_t attr;
  posix_spawnattr_t *attrp;
  posix_spawn_file_actions_t file_actions;
  posix_spawn_file_actions_t *file_actionsp;

  attrp = NULL;
  file_actionsp = NULL;
  long open_max = sysconf(_SC_OPEN_MAX);
  printf("sysconf says: max open file descriptor %ld\n", open_max);
  if (open_max > 32768) {
    open_max = 32768;
    printf("bump max open file desriptor to %ld\n", open_max);
  }
  int flags = O_WRONLY | O_CREAT | O_APPEND;
  mode_t mode = S_IRUSR | S_IWUSR | S_IRGRP | S_IRGRP;
  int log_fd = open("test_posix_spawnp.log", flags, mode);

  printf("opened output file \"test_posix_spawnp.log\", fd=%d\n", log_fd);

  /* Close all fds except log_fd to which stdout and stderr are redirected */

  s = posix_spawn_file_actions_init(&file_actions);
  if (s != 0)
    errExitEN(s, "posix_spawn_file_actions_init");

  s = posix_spawn_file_actions_adddup2(&file_actions, log_fd, STDOUT_FILENO);
  if (s != 0)
    errExitEN(s, "posix_spawn_file_actions_adddup2");

  s = posix_spawn_file_actions_adddup2(&file_actions, log_fd, STDERR_FILENO);
  if (s != 0)
    errExitEN(s, "posix_spawn_file_actions_adddup2");

  for (int i = 3; i < open_max; ++i) {
    if (i == log_fd) continue;
    s = posix_spawn_file_actions_addclose(&file_actions, i);
    if (s != 0)
      errExitEN(s, "posix_spawn_file_actions_addclose");
  }

  file_actionsp = &file_actions;

  s = posix_spawnp(&child_pid, argv[optind], file_actionsp, attrp,
                   &argv[optind], environ);
  if (s != 0)
    errExitEN(s, "posix_spawn");

  printf("PID of child: %ld\n", (long) child_pid);

  /* Clean up after ourselves */

  if (file_actionsp != NULL) {
    s = posix_spawn_file_actions_destroy(file_actionsp);
    if (s != 0)
      errExitEN(s, "posix_spawn_file_actions_destroy");
  }

  exit(EXIT_SUCCESS);
}

答案1

我查看了 glibc 2.24,它是 Debian 9 附带的。

posix_spawnp(和 posix_spawn)是作为用户模式 ​​C 代码而不是系统调用实现的。它执行以下操作:

  1. 用旗帜制作一个管道O_CLOEXEC
  2. 使用标志调用克隆CLONE_VFORK。 vfork 限制了子进程和父进程之间的通信——这就是管道发挥作用的地方。
  3. 父级关闭管道的写入端并尝试从读取端读取。
  4. 子进程关闭管道的读取端并执行所有文件操作。
  5. 孩子调用 execvp。如果成功,管道应该关闭。如果失败,子进程将向管道写入错误代码。
  6. 父级的读取返回。如果子进程中的 execvp 成功,则读取应该失败因为管道的写端应该已经关闭,父级将该变量设置ec为 0。如果读取成功,ec则 是子级发送给它的错误代码。
  7. 父级中的 posix_spawnp 返回ec

我把这些词用斜体表示,因为有一个错误。

当 posix_spawnp 执行所有这些posix_spawn_file_actions_addclose操作时,glibc 代码足够聪明,可以在看到影响该文件描述符的文件操作时对管道的写入端执行重复操作。

int p = args->pipe[1];
...
/* Dup the pipe fd onto an unoccupied one to avoid any file
   operation to clobber it.  */
if ((action->action.close_action.fd == p)
    || (action->action.open_action.fd == p)
    || (action->action.dup2_action.fd == p))
  {
    if ((ret = __dup (p)) < 0)
      goto fail;
    p = ret;
  }

问题是,重复不复制该O_CLOEXEC标志,因此 fd 会泄漏到子进程已执行的进程,并且在该进程退出之前不会关闭。在此之前,父级中的读取不会返回。

该错误已修复这次提交。现在,子级通过使用共享变量而不是管道将其成功或失败传达给父级。

如果您坚持使用这个版本的 glibc,除了不告诉 posix_spawnp 关闭管道的写入端(示例代码中可能是 logfd+2)之外,您无能为力。

相关内容