在并行环境中使用fork,exec和管道时出现死锁

时间:2019-05-27 15:14:20

标签: c pipe fork exec deadlock

我正在使用fork和exec生成一个子进程。使用两个管道提供输入并接收该过程的输出。

大多数情况下,它工作正常,但是当我使用openmp之类的东西测试它在并发环境中的性能时,它挂在read系统调用中,有时挂在waitpid中。

当我strace子进程时,我发现它在read系统调用中也被阻止。这很奇怪,因为我仅在提供所有输入并关闭管道的写入端之后才等待父进程中的读取。

我尝试创建MVCE,但是时间很长。我不知道如何缩短时间。为了简单起见,我删除了大多数错误检查代码。

请注意,我的代码中没有全局变量。而且我也不想在多个线程中从相同的文件描述符读取/写入。

我想不出什么问题。因此,希望你们能发现我在做什么错。

有:

#include <string.h>
#include <assert.h>
#include <unistd.h>
#include <limits.h>
#include <sys/wait.h>
#include <stdio.h>
#include <stdlib.h>

size_t
min(size_t first, size_t second)
{
    if(first < second)
    {
        return first;
    }

    return second;
}

struct RDI_Buffer
{
    char* data;
    size_t size;
};

typedef struct RDI_Buffer RDI_Buffer;

RDI_Buffer
rdi_buffer_init()
{
    RDI_Buffer b = {0};
    return b;
}

RDI_Buffer
rdi_buffer_new(size_t size)
{
    RDI_Buffer b;

    b.data = malloc(size);
    b.size = size;
    return b;
}

void
rdi_buffer_free(RDI_Buffer b)
{
    if(!b.data)
    {
        return;
    }

    free(b.data);
}

RDI_Buffer
rdi_buffer_resize(RDI_Buffer b, size_t new_size)
{
    if(!b.data)
    {
        return rdi_buffer_new(new_size);
    }

    char* new_data = realloc(b.data, new_size);

    if(new_data)
    {
        b.size = new_size;
        b.data = new_data;
        return b;
    }

    RDI_Buffer output = rdi_buffer_new(new_size);
    memcpy(output.data, b.data, output.size);
    rdi_buffer_free(b);
    return output;
}

RDI_Buffer
rdi_buffer_null_terminate(RDI_Buffer b)
{
    b = rdi_buffer_resize(b, b.size + 1);
    b.data[b.size - 1] = '\0';
    return b;
}

static RDI_Buffer
rw_from_fd(int w_fd, int r_fd, RDI_Buffer input)
{
    const size_t CHUNK_SIZE = 4096;

    assert(input.size <= CHUNK_SIZE);

    write(w_fd, input.data, input.size);
    close(w_fd);

    RDI_Buffer output = rdi_buffer_new(CHUNK_SIZE);

    read(r_fd, output.data, CHUNK_SIZE);

    close(r_fd);
    return output;
}

int main()
{
#pragma omp parallel for
    for(size_t i = 0; i < 100; i++)
    {
        char* thing =
                "Hello this is a sort of long text so that we can test how "
                "well this works. It should go with cat and be printed.";

        RDI_Buffer input_buffer;
        input_buffer.data = thing;
        input_buffer.size = strlen(thing);

        int main_to_sub[2];
        int sub_to_main[2];

        pipe(main_to_sub);
        pipe(sub_to_main);

        int pid = fork();

        if(pid == 0)
        {
            dup2(main_to_sub[0], STDIN_FILENO);
            dup2(sub_to_main[1], STDOUT_FILENO);

            close(main_to_sub[1]);
            close(main_to_sub[0]);
            close(sub_to_main[1]);
            close(sub_to_main[0]);

            char* argv[] = {"cat", NULL};

            execvp("cat", argv);
            exit(1);
        }

        close(main_to_sub[0]);
        close(sub_to_main[1]);

        RDI_Buffer output =
                rw_from_fd(main_to_sub[1], sub_to_main[0], input_buffer);

        int *status = NULL;
        waitpid(pid, status, 0);

        if(status)
        {
            printf("%d\n", *status);
        }

        output = rdi_buffer_null_terminate(output);

        if(strcmp(output.data, thing) == 0)
        {
            printf("good\n");
        }
        else
        {
            printf("bad\n");
        }

        rdi_buffer_free(output);
    }
}

确保您编译并链接到-fopenmp。像这样:gcc main.c -fopenmp

3 个答案:

答案 0 :(得分:2)

当主电源挂起时,在另一个会话中键入lsof。我想您会看到类似的内容:

....
cat       5323                 steve  txt       REG              252,0    52080    6553613 /bin/cat
cat       5323                 steve  mem       REG              252,0  1868984   17302005 /lib/x86_64-linux-gnu/libc-2.23.so
cat       5323                 steve  mem       REG              252,0   162632   17301981 /lib/x86_64-linux-gnu/ld-2.23.so
cat       5323                 steve  mem       REG              252,0  1668976   12849924 /usr/lib/locale/locale-archive
cat       5323                 steve    0r     FIFO               0,10      0t0      32079 pipe
cat       5323                 steve    1w     FIFO               0,10      0t0      32080 pipe
cat       5323                 steve    2u      CHR              136,0      0t0          3 /dev/pts/0
cat       5323                 steve    3r     FIFO               0,10      0t0      32889 pipe
cat       5323                 steve    4w     FIFO               0,10      0t0      32889 pipe
cat       5323                 steve    6r     FIFO               0,10      0t0      32890 pipe
cat       5323                 steve    7r     FIFO               0,10      0t0      34359 pipe
cat       5323                 steve    8w     FIFO               0,10      0t0      32890 pipe
cat       5323                 steve   10r     FIFO               0,10      0t0      22504 pipe
cat       5323                 steve   15w     FIFO               0,10      0t0      22504 pipe
cat       5323                 steve   16r     FIFO               0,10      0t0      22505 pipe
cat       5323                 steve   31w     FIFO               0,10      0t0      22505 pipe
cat       5323                 steve   35r     FIFO               0,10      0t0      17257 pipe
cat       5323                 steve   47r     FIFO               0,10      0t0      31304 pipe
cat       5323                 steve   49r     FIFO               0,10      0t0      30264 pipe

这提出了一个问题,所有这些管道来自哪里?您的主循环不再是单个循环,而是一组不同步的并行循环。查看下面的样板:

void *tdispatch(void *p) {
      int to[2], from[2];
      pipe(to);
      pipe(from);
      if (fork() == 0) {
          ...
      } else {
          ...
          pthread_exit(0); 
     }
}
...
for (int i = 0; i < NCPU; i++) {
    pthread_create(..., tdispatch, ...);
}
for (int i = 0; i < NCPU; i++) {
    pthread_join(...);
}

tdispatch的多个实例可以交错pipe(to),pipe(from)和fork()调用;因此,fds正在泄漏到这些分叉的进程中。我之所以说泄漏,是因为分叉的进程不知道它们在那里。

当管道已缓冲数据或至少有一个写文件描述符打开时,管道继续响应read()系统调用。

假设进程5的正常两端是两个管道打开的,分别指向管道10和管道11。进程6有管道#12和管道#13。但是,由于上述泄漏,进程5也具有管道#12的写端,进程6具有管道#10的写端。进程的5和6永远不会退出,因为它们使彼此保持读取管道的打开。

该解决方案几乎是早期人们所说的:线程和fork是一个棘手的组合。为了使它能够工作,您将不得不对管道,叉,初始关闭位进行序列化。

答案 1 :(得分:0)

将评论转换为答案。

文件描述符可能用完了。使用并行性,如果限制为大约256个描述符,则在每次迭代中创建4个文件描述符的循环的100次迭代可能会遇到麻烦。是的,您可以快速关闭其中的一些,但速度足够快吗?不清楚。调度的不确定性很容易解释行为的变化。

  

我理解openmp的方式是,它在n是线程数的时候进入n次循环体(我错了吗?)。因此,在任何时候,我的机器上的n * 2个文件描述符都不应超过24个。

可能是n * 4个文件描述符,但并行性可能会受到限制。我对OpenMP不够熟悉,无法对此发表权威评论。除了应设置的for循环以外,是否还有其他杂注?对于我来说,尚不清楚在使用Clang编译代码时,在Mac上运行所示代码会在Mac上引入并行性-不抱怨#pragma,不像GCC 9.1.0会警告未知的编译指示在我的默认编译选项下。

但是,使用fork和exec以及线程,生活变得棘手。文件描述符可能没有关闭,应该关闭,因为文件描述符是进程级资源,因此线程1可能创建线程2不知道但共享的文件描述符。然后,当线程2分叉时,线程1创建的文件描述符不会关闭,从而阻止cat正确检测EOF,等等。

一种验证方法是使用如下函数:

#include <sys/stat.h>

static void dump_descriptors(int max_fd)
{
    struct stat sb;
    for (int fd = 0; fd <= max_fd; fd++)
        putchar((fstat(fd, &sb) == 0) ? 'o' : '-');
    putchar('\n');
    fflush(stdout);
}

,并在子代码中用合适的数字进行调用(也许是64-使用数字最大为404的情况)。尽管尝试在函数中使用flockfile(stdout)funlockfile(stdout)很诱人,但仅在子进程中调用它是没有意义的,因为子进程是单线程的,因此不会受到任何干扰从该过程中的其他线程。但是,不同的过程可能会干扰彼此的输出是完全可行的。

如果要在父进程线程中使用dump_descriptor(),则在循环之前添加flockfile(stdout);,并在funlockfile(stdout);调用之后添加fflush()。我不确定有多少会干扰这个问题。它通过该函数强制执行单线程,因为一个线程将其锁定时,其他线程都无法写入stdout

但是,当我用稍微修改过的代码版本进行测试时,该代码在“好”和“坏”行之前以及在dump_descriptors()输出之前输出PID,所以我从未见过任何交错的操作。我得到如下输出:

14128: ooooooo----------------------------------------------------------
14128: good
14129: ooooooo----------------------------------------------------------
14129: good
14130: ooooooo----------------------------------------------------------
14130: good
…
14225: ooooooo----------------------------------------------------------
14225: good
14226: ooooooo----------------------------------------------------------
14226: good
14227: ooooooo----------------------------------------------------------
14227: good

强烈表明代码中没有并行性。当没有并行性时,您将看不到麻烦。每次都有4个管道描述符,并且代码会仔细关闭它们。

在您的场景中,考虑将描述符映射重定向到一个文件(或每个孩子一个文件),在该情况下,您实际上可能会遇到严重的并行性。

请注意,将线程与fork()混合在本质上是困难的(就像John Bollinger noted)—通常使用一种或另一种机制,而不是同时使用这两种机制。

答案 2 :(得分:0)

该问题的原因原来是Jonathan Leffler和Mevet在回答中解释的,继承给子进程的打开文件。如果您有此问题,请阅读他们的答案,如果您仍然不理解或不知道该怎么办,请参考我的答案。

我将以我本应立即理解的方式分享我的解释。同时分享我的代码解决方案。

请考虑以下情形: 进程A打开一个管道(有两个文件)。

进程A产生进程B,以与管道进行通信。但是,它还会创建继承管道(两个文件)的进程C。

现在,进程B将在管道上连续调用read(2),这是一个阻塞的系统调用。 (它会等到有人向管道写入内容时

进程A完成写入并关闭管道的末端。通常,这将导致进程B中的read(2)系统调用失败,程序将退出(这就是我们想要的)。

但是,在我们的情况下,由于进程C确实具有管道的开放写端,因此进程B中的read(2)系统调用将不会失败,并且将阻止等待来自进程C中开放写端的写操作。

当进程C刚刚结束时,一切都会好起来的。

在另一种情况下,当B和C彼此保持管道时,就会出现真正的死锁(如Mevet的回答所述)。他们每个人都在等待对方关闭管道的末端。永远不会发生导致死锁的情况。

我的解决方案是在fork(2)

之后关闭所有不需要的打开文件
int pid = fork();

if(pid == 0)
{
    int exceptions[2] = {main_to_sub[0], sub_to_main[1]};
    close_all_descriptors(exceptions);
    dup2(main_to_sub[0], STDIN_FILENO);
    dup2(sub_to_main[1], STDOUT_FILENO);

    close(main_to_sub[0]);
    close(sub_to_main[1]);

    char* argv[] = {"cat", NULL};

    execvp("cat", argv);
    exit(1);
}

这是close_all_descriptors

的实现
#include <fcntl.h>
#include <errno.h>

static int
is_within(int fd, int arr[2])
{
    for(int i = 0; i < 2; i++)
    {
        if(fd == arr[i])
        {
            return 1;
        }
    }

    return 0;
}

static int
fd_is_valid(int fd)
{
    return fcntl(fd, F_GETFD) != -1 || errno != EBADF;
}

static void
close_all_descriptors(int exceptions[2])
{
    // getdtablesize returns the max number of files that can be open. It's 1024 on my system
    const int max_fd = getdtablesize();

    // starting at 3 because I don't want to close stdin/out/err
    // let dup2(2) do that
    for (int fd = 3; fd <= max_fd; fd++)
    {
        if(fd_is_valid(fd) && !is_within(fd, exceptions))
        {
            close(fd);
        }
    }
}