Выполнить команду в дочернем процессе

Вдохновлен этот ответ из SO я пытаюсь найти версию fork()/exec()/wait(), которая:

  • запускает дочерний процесс;
  • ловит stdout, stderr и код возврата отдельно;
  • должным образом обрабатывает все возможные ошибки от системных вызовов (например, нехватка памяти, невозможность открытия файлов, файл не найден) и гарантирует отсутствие утечек ресурсов, несмотря ни на что.
  • не будет падать, даже если дочерний процесс рухнул, и может сообщить, что дочерний процесс рухнул;
  • Потокобезопасный (int exec() только).

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

#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/types.h>
#include <sys/wait.h>
#include <signal.h>

int exec() {
int pipefd_out[2], pipefd_err[2];
    FILE* fp_out;
    FILE* fp_err;
    char buff[256];
    int status;

    if (pipe(pipefd_out) == -1) {
        // man 2 pipe
        perror("pipe()");
        return EXIT_FAILURE;
    }
    if (pipe(pipefd_err) == -1) {
        // man 2 pipe
        close(pipefd_out[0]);        
        close(pipefd_out[0]);
        perror("pipe()");
        return EXIT_FAILURE;
    }
    
    pid_t child_pid = fork(); //span a child process

    if (child_pid == -1) { // fork() failed, no child process created
        perror("fork(): ");
        return EXIT_FAILURE;
    }

    if (child_pid == 0) { // fork() succeeded, we are in the child process
        close(pipefd_out[0]);
        close(pipefd_err[0]);
        dup2(pipefd_out[1], STDOUT_FILENO);
        dup2(pipefd_err[1], STDERR_FILENO);


        char *const args[] = {"/bin/ls", "-l",
            "/path/that/definitely/does/not/exist/", NULL};
        execv(args[0], args);


        perror("execl()/execv()");
        // The exec() functions return only if an error has occurred.
        // The return value is -1, and errno is set to indicate the error.
        exit(EXIT_FAILURE);
        // Have to exit() explicitly in case of execl() failure.
    }
    
    //Only parent gets here
    close(pipefd_out[1]);
    close(pipefd_err[1]);

    if ((fp_out = fdopen(pipefd_out[0], "r")) == NULL) {
        perror("fdopen()");
        return EXIT_FAILURE;
    }
    if ((fp_err = fdopen(pipefd_err[0], "r")) == NULL) {
        perror("fdopen()");
        fclose(fp_out);
        return EXIT_FAILURE;
    }

    printf("===== stdout =====\n");
    while(fgets(buff, sizeof(buff) - 1, fp_out)) {            
        printf("%s", buff);
    }
    printf("===== stdout =====\n");

    printf("===== stderr =====\n");
    while(fgets(buff, sizeof(buff) - 1, fp_err)) {            
        printf("%s", buff);
    }    
    printf("===== stderr =====\n");
    fclose(fp_out);
    fclose(fp_err);

    // wait for the child process to terminate
    if (waitpid(child_pid, &status, 0) == -1) {
        perror("waitpid()");
        return EXIT_FAILURE;
    }
    if (WIFEXITED(status)) {
        printf("Child process exited normally, rc: %d\n", WEXITSTATUS(status));
    } else {
        printf("Child process exited unexpectedly ");
        if (WIFSIGNALED(status)) {
            printf("(terminated by a signal: %d)\n", WTERMSIG(status));
        } else if (WIFSTOPPED(status)) {
            printf("(stopped by delivery of a signal: %d)\n", WSTOPSIG(status));
        } else {
            printf("(unknown status: %d)\n", status);
        }
    }
}

int main(int argc, char** argv) {
    if (argc != 2) {
        printf("Usage: %s <0|1|2|3>\n", argv[0]);
        return EXIT_FAILURE;
    }
    exec(argv);    
}

Полную версию можно нашел здесьна тот случай, если вы захотите скомпилировать его, чтобы увидеть, что именно может произойти.

4 ответа
4

Код рискует вызвать неопределенное поведение:

if (argc != 2) {
    printf("Usage: %s <0|1|2|3>\n", argv[0]);
    return EXIT_FAILURE;
}

Разыменование argv[0] вызовет неопределенное поведение, если оно NULL.

Добавьте к нему чек.

/* Sanity check. POSIX requires the invoking process to pass a 
*  non-NULL argv[0].
*/
if (!argv[0]) {
    fputs ("A NULL argv[0] was passed in through an exec() system call.\n", stderr);
    return EXIT_FAILURE;
}

Не используйте магические числа:

char buff[256];

Здесь магическое число 256 должно быть именованной константой.

#define BUFSIZE 256

Это улучшает читаемость кода и упрощает его сопровождение; требуется только одно изменение именованной константы вместо каждого экземпляра числа. См. этот вопрос SO: Что такое магические числа и почему они плохие?

Указатели на строковые литералы должны быть объявлены с атрибутом const квалификатор:

// char *const args[] = {"/bin/ls", "-l",
            "/path/that/definitely/does/not/exist/", NULL};

const char *const args[] = {"/bin/ls", "-l", "/path/that/definitely/does/not/exist/", NULL};

С использованием const помогает сохранить целостность строки, потому что вы можете изменить ее только с помощью нового указателя на строку или передав его функции, которая изменяет строку.

Дочерние процессы должны использовать _exit() вместо exit():

Основное различие между exit() и _exit() заключается в том, что первый выполняет очистку, связанную с конструкциями пользовательского режима в библиотеке, и вызывает предоставленные пользователем функции очистки, тогда как второй выполняет только очистку ядра для процесса.

В дочерней ветке a fork()обычно неправильно использовать
exit()потому что это может привести к двойной очистке буферов stdio и неожиданному удалению временных файлов.

Код не соответствует всем требованиям:

Возвращаемые значения dup2(), close()и fclose() игнорируются.

Из справочной страницы для close()::

Работа с ошибками, возвращаемыми из close():

Внимательный программист проверит возвращаемое значение close()так как вполне возможно, что ошибки на предыдущем write(2)
операции сообщаются только в окончательном close() который выпускает описание открытого файла. Отсутствие проверки возвращаемого значения при закрытии файла может привести к скрытой потере данных. Это особенно заметно при использовании NFS и дисковой квоты.

Из справочной страницы для fclose():

fclose() функция также может дать сбой и установить errno для любой из ошибок, указанных для подпрограмм. close(2), write(2)или
fflush(3).

Вызов close() на закрытом дескрипторе может привести к катастрофическим результатам:

В exec()у нас есть следующие повторяющиеся вызовы close() с тем же файловым дескриптором:

close(pipefd_out[0]);        
close(pipefd_out[0]);

Со страницы руководства:

Повторная попытка close() после неудачного возврата — это неправильно, так как это может привести к закрытию повторно используемого файлового дескриптора из другого потока. Это может произойти из-за того, что ядро ​​Linux всегда освобождает файловый дескриптор в начале операции закрытия, освобождая его для повторного использования; шаги, которые могут вернуть ошибку, такие как сброс данных в файловую систему или устройство, происходят только позже в операции закрытия.

Вышесказанное справедливо для успешного close() операция.

Сохранять errno если это требуется для последующего использования:

if (pipe(pipefd_err) == -1) {
    // man 2 pipe
    close(pipefd_out[0]);        
    close(pipefd_out[0]);
    perror("pipe()");
}

Звонки в close() может перезаписать значение errno. Если значение требуется для последующего использования, рекомендуется сохранить его как можно скорее перед вызовами любых других функций.

if (pipe(pipefd_err) == -1) {
    // man 2 pipe
    int saved_errno = errno;

    close(pipefd_out[0]);        
    close(pipefd_out[0]);
    errno = saved_errno;
    perror("pipe()");
}

А может просто позвонить perror() прежде чем звонить close():

if (pipe(pipefd_err) == -1) {
    // man 2 pipe
    perror("pipe()");
    close(pipefd_out[0]);        
    close(pipefd_out[0]);
}

Нам нужно включить больше предупреждений компилятора — эти проблемы решаются тривиально:

gcc-12 -std=c17 -fPIC -gdwarf-4 -g -Wall -Wextra -Wwrite-strings -Wno-parentheses -Wpedantic -Warray-bounds -Wmissing-braces -Wconversion  -Wstrict-prototypes -fanalyzer       283851.c    -o 283851
283851.c:10:5: warning: function declaration isn’t a prototype [-Wstrict-prototypes]
   10 | int exec() {
      |     ^~~~
283851.c: In function ‘exec’:
283851.c:44:31: warning: initialization discards ‘const’ qualifier from pointer target type [-Wdiscarded-qualifiers]
   44 |         char *const args[] = {"/bin/ls", "-l",
      |                               ^~~~~~~~~
283851.c:44:42: warning: initialization discards ‘const’ qualifier from pointer target type [-Wdiscarded-qualifiers]
   44 |         char *const args[] = {"/bin/ls", "-l",
      |                                          ^~~~
283851.c:45:13: warning: initialization discards ‘const’ qualifier from pointer target type [-Wdiscarded-qualifiers]
   45 |             "/path/that/definitely/does/not/exist/", NULL};
      |             ^~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
283851.c:101:1: warning: control reaches end of non-void function [-Wreturn-type]
  101 | }
      | ^

Не хватает очистки нашего файлового дескриптора. Здесь мы дважды закрываем конец для чтения, но оставляем конец для записи открытым:

if (pipe(pipefd_err) == -1) {
    // man 2 pipe
    close(pipefd_out[0]);
    close(pipefd_out[0]);
    perror("pipe()");
    return EXIT_FAILURE;
}

И здесь мы оставляем все четыре дескриптора канала открытыми:

if (child_pid == -1) { // fork() failed, no child process created
    perror("fork(): ");
    return EXIT_FAILURE;
}

Там намного больше не хватает close() вызовы (даже в пути успеха).

Этот тип обработки ошибок часто лучше всего обрабатывается с помощью goto заявление. Настройте последовательность очистки, которая является отражением инициализации:

err_out_file:
        fclose(fp_out);
err_err_fds:
        close(pipefd_err[0]);
        close(pipefd_err[1]);
err_out_fds:
        close(pipefd_out[0]);        
        close(pipefd_out[1]);
err_initial:
        return EXIT_FAILURE;

Это гарантирует, что ошибки выхода на каждом этапе очистят все ресурсы от предыдущих этапов:

    if (pipe(pipefd_out) == -1) {
        perror("pipe()");
        goto err_initial;
    }
    if (pipe(pipefd_err) == -1) {
        perror("pipe()");
        goto err_out_fds;
    }
    
    pid_t child_pid = fork(); //span a child process

    if (child_pid == -1) { // fork() failed, no child process created
        perror("fork()");
        goto err_err_fds;
    }
    if ((fp_out = fdopen(pipefd_out[0], "r")) == NULL) {
        perror("fdopen()");
        goto err_err_fds;
    }
    if ((fp_err = fdopen(pipefd_err[0], "r")) == NULL) {
        perror("fdopen()");
        goto err_out_file;
    }

Чтение всего выходного потока перед чтением всего потока ошибок отлично подходит для этого простого lsно может вызвать взаимоблокировку, когда дочерний элемент записывает в поток ошибок больше, чем емкость канала, прежде чем он закроет выходной поток, здесь:

printf("===== stdout =====\n");
while(fgets(buff, sizeof(buff) - 1, fp_out)) {            
    printf("%s", buff);
}
printf("===== stdout =====\n");

printf("===== stderr =====\n");
while(fgets(buff, sizeof(buff) - 1, fp_err)) {            
    printf("%s", buff);
}    
printf("===== stderr =====\n");

Нам нужно использовать select() для правильного чередования чтений обоих потоков.


main() функция вводит в заблуждение. Он настаивает на том, чтобы пользователь предоставил аргумент, но он никогда не используется, так как exec игнорирует его необязательные аргументы (запись int exec(void) заметил бы это). Кроме того, мы игнорируем возвращаемое значение из exec()хотя это точно из чего мы должны возвращаться main().


Что касается стиля, мне крайне не нравятся пробелы в конце многих строк.

Кроме того, в современном C нет необходимости объявлять все переменные в начале их области видимости. Предпочитайте объявлять переменные там, где они могут быть инициализированы значениями, чтобы исключить возможность их использования до того, как они будут назначены.


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

Делиться

Улучшить этот ответ

Использовать posix_spawn

Или, возможно, posix_spawnp. Оно в стандарт POSIX именно для того, чтобы позаботиться об этом. Здесь не нужно изобретать велосипед.

Вы передаете этот системный вызов объекту атрибутов спавна. Например, чтобы переназначить произвольные файловые дескрипторы, такие как ваши каналы, на стандартный ввод и вывод дочернего процесса, вы должны использовать posix_spawn_file_actions_adddup2 дважды, чтобы инициализировать объект файловых действий, который вы передаете posix_spawn. Или для перенаправления файлов в качестве стандартного ввода или стандартного вывода вы должны использовать последовательность вызовов, включая posix_spawn_file_actions_addopen.

Запрос API POSIX

Вы, по-видимому, используете библиотеку, которая не объявляет exec для вас и написание вашей собственной декларации до ANSI.

Вместо этого добавьте в начало файла перед любыми заголовками библиотек:

 #define _POSIX_C_SOURCE 200809L
 #define _XOPEN_SOURCE 700

Или добавьте соответствующий -D параметры вашего make-файла.

Необходимые функции, в том числе exec()теперь будет объявлено в <unistd.h>. В Linux вы также должны иметь возможность использовать переносимый API в man -s 3p execи т. д.

Необязательный -1

Не нужно вычитать 1.

fgets Функция считывает не более чем на один символ меньше, чем число символов, указанное параметром n, из потока, на который указывает параметр stream, в массив, на который указывает параметр s.

// while(fgets(buff, sizeof(buff) - 1, fp_err)) {            
while(fgets(buff, sizeof buff, fp_err)) {            

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *