Тупик при использовании fork, exec и pipe в параллельной среде

Я создаю дочерний процесс, используя 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

Вы не проверяете наличие ошибок в системных вызовах. Вам следует. Возможно, у вас закончились файловые дескрипторы. При параллелизме 100 итераций цикла, создающего 4 файловых дескриптора на каждой итерации, могут привести к проблемам, если ограничение составляет около 256 дескрипторов. Да, вы закрываете некоторые из них быстро, но достаточно быстро? Не ясно. И неопределенность планирования легко объясняет различное поведение.

Jonathan Leffler 27.05.2019 17:26

Ну, я действительно упростил программу и удалил проверку ошибок, чтобы уменьшить количество строк и сделать ее более читаемой.

Adham Zahran 27.05.2019 17:38

Насколько я понимаю, openmp заключается в том, что он входит в тело цикла n раз за раз, где n — количество потоков (я ошибаюсь?). Таким образом, в любой момент у меня никогда не должно быть более n * 2 файловых дескрипторов, которых на моей машине должно быть около 24.

Adham Zahran 27.05.2019 17:39

Вероятно, это n*4 файловых дескриптора, но могут быть ограничения на параллелизм. С форками и исполнителями, а также с потоками жизнь становится сложной. Файловые дескрипторы не могут быть закрыты, потому что файловые дескрипторы являются ресурсом уровня процесса, поэтому поток 1 может создавать файловые дескрипторы, о которых поток 2 не знает, но которые он разделяет. И тогда файловые дескрипторы не закрываются, что мешает cat правильно определять EOF и т. д.

Jonathan Leffler 27.05.2019 17:48

«Я не могу думать о том, что может пойти не так»: даже не взглянув на код, моя первая мысль была «многопоточность и fork — вот это хитрая комбинация».

John Bollinger 27.05.2019 17:50

Вся эта передача структур по значению кажется рецептом неприятностей, особенно если учесть, что структуры содержат указатели на динамически выделяемую память. Я не думаю, что это причина вашей проблемы, но это кажется неразумным.

John Bollinger 27.05.2019 18:02

Я запускал размещенный код много раз (в Ubuntu Linux 18.04), и он всегда выполнялся успешно (в результате столбец текста: good

user3629249 27.05.2019 18:14

В будущем, если вы удалите проверку ошибок, чтобы следовать части «минимизации» рекомендаций по созданию MCVE (Минимальный, полный, проверяемый пример), об этом также следует сказать в вопросе. Это законно, пока ваш «настоящий» код усердно проверяет все системные вызовы (которые могут дать сбой — такие системные вызовы, как getpid(), не могут быть ошибочными) на наличие ошибок.

Jonathan Leffler 27.05.2019 18:15

Почему вы игнорируете SIGPIPE в rw_from_fd()? Этого никогда не должно происходить, так как вы контролируете выход кота. У него есть побочные эффекты: первый дочерний элемент (кошка), которого вы запускаете, имеет включенный SIGPIPE; остальные наследуют свой игнорируемый статус от родителя. Я не знаю, имеет ли это значение, но __kmp_install_signals обрабатывает SIGPIPE как особый...

mevets 27.05.2019 21:07

Сколько процессоров у вас есть в наличии?

mevets 27.05.2019 21:08

@mevets игнорирование sigpipe является частью исходного кода. Я должен был удалить его. В исходном коде я игнорирую sigpipes и обрабатываю ошибки локально в функции с помощью errno()

Adham Zahran 28.05.2019 12:43

моя машина имеет 12 процессоров

Adham Zahran 28.05.2019 12:43

@user3629249 user3629249 Вы уверены, что компилируете и связываетесь с -fopenmp?

Adham Zahran 28.05.2019 12:59

Я добавил -fopenmp в параметры компиляции. Это не имело никакого значения. Я компилирую, как вы предложили, с gcc, а не mpicc

user3629249 28.05.2019 16:20

@ Адам, когда я перешел на машину с 16 процессорами, она воспроизводится. Вы не сумасшедший, по крайней мере, в одном отношении. Я нахожу, что дети все ждут записи....

mevets 28.05.2019 23:21
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
15
515
3
Перейти к ответу Данный вопрос помечен как решенный

Ответы 3

Converting comments into an answer.

Возможно, у вас закончились файловые дескрипторы. При параллелизме 100 итераций цикла, создающего 4 файловых дескриптора на каждой итерации, могут привести к проблемам, если ограничение составляет около 256 дескрипторов. Да, вы закрываете некоторые из них быстро, но достаточно быстро? Это не ясно. И неопределенность планирования легко объясняет различное поведение.

The way I understand openmp is that it goes into the loop body n times at a time where n is the number of threads (am I wrong?). So at any single time I should never have more than n*2 file descriptors which on my machine should be around 24.

Вероятно, это n*4 файловых дескриптора, но могут быть ограничения на параллелизм. Я недостаточно знаком с OpenMP, чтобы авторитетно комментировать это. Существуют ли прагмы, отличные от цикла for, которые следует установить? Мне не ясно, что выполнение показанного кода приводит к параллелизму на Mac, когда код скомпилирован с помощью Clang, который не жалуется на #pragma, в отличие от GCC 9.1.0, который предупреждает о неизвестной прагме при моей компиляции по умолчанию. опции.

Однако с форками и исполнителями, а также с потоками жизнь становится сложной. Файловые дескрипторы не могут быть закрыты, потому что файловые дескрипторы являются ресурсом уровня процесса, поэтому поток 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, пока один поток заблокирован.

Однако, когда я тестировал его с немного измененной версией кода, который выводит PID перед «хорошей» и «плохой» строками и перед выводом dump_descriptors(), я никогда не видел чередования операций. Я получил вывод, как:

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() по своей сути сложно (как Джон Боллинджеротмеченный) — обычно вы используете один или другой механизм, а не оба.

Я думаю, что ваш GCC жалуется, потому что вы должны предоставить -fopenmp при компиляции/ссылке

Adham Zahran 28.05.2019 12:42

Этот код работает как часть веб-сервера, где каждый запрос представляет собой поток. Каждый запрос должен порождать дочерний элемент. Поэтому я вынужден использовать как многопоточность, так и fork/exec. Может быть, есть другой способ, о котором я не знаю?

Adham Zahran 28.05.2019 12:45

@AdamZahran: Часть проблемы заключалась не в компиляции -fopenmp; Я нахожу удивительным, что в коде не было #include <omp.h>, учитывая, что он использует OMP. Я не уверен, насколько это важно. На моем Mac я сталкиваюсь с неопределенным символом __emutls_get_address(); в некоторых обстоятельствах это кажется известной проблемой, но разочаровывает то, что она затрагивает GCC 9.1.0, когда она была известна еще во времена GCC 4.7 или около того (4.x для умеренно большого x). И есть переменная env OMP_NUM_THREADS, которая тоже может быть важна. Это то, что люди, которые использовали это, знают.

Jonathan Leffler 28.05.2019 17:29

@AdamZahran: Вы нашли какие-либо доказательства неожиданных открытых файловых дескрипторов? Я вполне уверен, что вы бы. Возможно, вам придется подумать о циклическом просмотре доступных файловых дескрипторов (по крайней мере, до некоторого числа, например 64) и простом закрытии их, поскольку вы не знаете, для чего они нужны, но вашему дочернему процессу они не нужны. Это боль, но если нет центрального реестра дескрипторов файлов и их назначения, это может быть тем, что требуется. Конечно, вы можете сделать больше, чем просто напечатать o или -; вы можете определить тип файла и, возможно, имеет ли он набор O_CLOEXEC и т. д.

Jonathan Leffler 28.05.2019 17:33

Я закончил тем, что сделал это, и это сработало. Я просмотрел все открытые файловые дескрипторы и закрыл их сразу после форка. Однако я не понимаю, почему наличие открытых файлов приводит к зависанию ребенка. Я сделал dup2 для файлов каналов на стандартный ввод / стандартный вывод, зачем ему какие-либо другие открытые файлы? Хотел бы я понять, как это работает.

Adham Zahran 29.05.2019 13:08
Ответ принят как подходящий

Когда ваш main завис, введите 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(); таким образом, fd просачиваются в эти разветвленные процессы. Я говорю «утечка», потому что раздвоенный процесс не знает, что они там есть.

Канал продолжает отвечать на системные вызовы read(), пока он либо буферизовал данные, либо для него открыт хотя бы один файловый дескриптор записи.

Предположим, что у процесса 5 открыты два обычных конца двух каналов, указывающие на канал № 10 и канал № 11; а процесс 6 имеет канал №12 и канал №13. Но из-за утечки выше процесс 5 также имеет конец записи канала № 12, а процесс 6 имеет конец записи канала № 10. Процессы 5 и 6 никогда не завершатся, потому что они держат открытыми каналы чтения друг друга.

Решение в значительной степени то, о чем говорили люди в предыдущем фрагменте: потоки и вилки — сложная комбинация. Вам нужно будет сериализовать свои каналы, вилки, начальные закрытые биты, чтобы заставить их работать.

Почему ребенок зависает, потому что он наследует открытый канал (в который они не будут читать/записывать)? Почему бы им просто не читать/записывать в свой стандартный ввод/вывод, а затем умирать, оставляя незакрытые каналы для обработки ядра?

Adham Zahran 29.05.2019 13:04

Кстати, я решил проблему, закрыв все файловые дескрипторы сразу после разветвления. Я просто не понимаю, почему это было проблемой и почему мое решение работает.

Adham Zahran 29.05.2019 13:05

Я не был уведомлен о вашем редактировании, но я рад, что обновил страницу: D Хорошо, теперь я наконец понял. EOF не будет отправлен, или конвейер не будет разорван, если все концы записи не будут закрыты, будь то из основного процесса или одного из дочерних процессов, которые унаследованы по незнанию. а поскольку дети даже не знают об этих открытых файлах, у них нет причин их закрывать, и поэтому происходит взаимоблокировка. Большое спасибо.

Adham Zahran 29.05.2019 14:16

Причиной проблемы оказались открытые файлы, которые унаследованы дочерними процессами, как объяснили Джонатан Леффлер и Мевет в своих ответах. Пожалуйста, прочитайте их ответы, если у вас возникла эта проблема, а затем вернитесь к моему ответу, если вы все еще не понимаете или не знаете, что делать.

Я поделюсь своим объяснением так, как я бы понял сразу. Также поделитесь моим кодовым решением проблемы.

Рассмотрим следующий сценарий: Процесс А открывает канал (это два файла).

Процесс A порождает процесс B для связи с ним с помощью канала. Однако он также создает процесс C, который наследует канал (два файла).

Теперь процесс B будет постоянно вызывать read(2) в канале, что является блокирующим системным вызовом. (будет ждать, пока кто-нибудь не напишет в трубу)

Процесс А завершает запись и закрывает свой конец трубы. Обычно это приводит к сбою системного вызова read(2) в процессе B и выходу программы (это то, что мы хотим).

Однако в нашем случае, поскольку процесс C имеет открытый конец канала для записи, системный вызов read(2) в процессе B не завершится ошибкой и заблокирует ожидание записи из открытого конца записи в процессе C.

Все будет хорошо, когда процесс C просто завершится.

Настоящий тупик возник бы в другом сценарии, когда и B, и C держат трубы друг для друга (как объяснено в ответе Мевета). Каждый из них будет ждать, пока другой закроет свои концы труб. Что никогда не произойдет, вызывая тупик.

Мое решение состояло в том, чтобы закрыть все открытые файлы, которые мне не нужны, сразу после 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);
        }
    }
}

Другие вопросы по теме