Unix2018/Техника fork — exec

Материал из iRunner Wiki

exec()

exec запускает исполняемый файл в контексте уже существующего процесса, заменяя предыдущий исполняемый файл.

На самом деле, функции с названием exec не существует. Есть несколько функций из этого семейства:

#include <unistd.h>
 
extern char **environ;
int execl(const char *path, const char *arg0, ... /*, (char *)0 */);
int execv(const char *path, char *const argv[]);
int execle(const char *path, const char *arg0, ... /*, (char *)0, char *const envp[]*/);
int execve(const char *path, char *const argv[], char *const envp[]);
int execlp(const char *file, const char *arg0, ... /*, (char *)0 */);
int execvp(const char *file, char *const argv[]);
  • Версия с l принимает аргументы через varargs, версия с v принимает массив строк.
  • Версия с e позволяет дополнительно передать переменные окружения.
  • Версия с p ищет исполняемый файл в PATH, без p требует полный путь.

Все они реализованы как обёртки вокруг системного вызова execve().

Техника fork-exec

Придумана Д. Ритчи.

Создание нового процесса выполняется двумя системными вызовами.

  • После fork появляется новый дочерний процесс, который продолжает выполнять программу родителя.
  • В результате exec процесс переключается на выполнение другой программы.
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
 
int main() {
    pid_t pid = fork();
 
    if (pid == -1) {
        fprintf(stderr, "Unable to fork\n");
    } else if (pid > 0) {
        printf("I am parent %d\n", getpid());
        printf("Child is %d\n", pid);
        int status;
        // wait(&status);
        waitpid(pid, &status, 0);
        printf("Wait OK\n");
    } else {
        // we are the child            
        printf("I am child %d of %d\n", getpid(), getppid());
        if (execlp("ls", "ls", "-l", NULL) == -1) {
            fprintf(stderr, "Unable to exec\n");
        }
    }
}

Проблема обработки ошибок

Ошибки могут случаться на разных стадиях.

  1. Неудачный вызов fork: возвращается −1, дочерний процесс вообще не порождается.
  2. Неудачный вызов exec в дочернем процессе. Например, указан несуществующий путь к запускаемой программе. В случае успеха функция exec не возвращает управление совсем, в случае неуспеха возвращается −1.
  3. Вызов exec был успешен, дочерний процесс начал работу и завершился с ошибкой.

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

Допустим, мы решили, что в случае неудачного exec наш дочерний процесс будет завершаться с кодом X, родительский процесс сможет прочитать этот код через waitpid. Но что если exec был успешен, а новый процесс сам по себе вышел с кодом X. Спрашивается, как мы в родительском процессе сможем это различать.

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

#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/wait.h>
 
void Run(const char* program, const char* arg) {
    printf("P> Running %s %s\n", program, arg);
    pid_t pid = fork();
 
    if (pid == -1) {
        fprintf(stderr, "Unable to fork\n");
    } else if (pid > 0) {
        printf("P> I am parent %d\n", getpid());
        printf("P> Child is %d\n", pid);
        int status;
        // wait(&status);
        waitpid(pid, &status, 0);
        printf("P> Wait OK\n");
        if (WIFEXITED(status)) {
            printf("P> Exit code = %d\n", WEXITSTATUS(status));
        }
        printf("\n");
    } else {
        // we are the child            
        printf("C> I am child %d of %d\n", getpid(), getppid());
        if (execlp(program, program, arg, NULL) == -1) {
            fprintf(stderr, "Unable to exec\n");
            _exit(42);
        }
    }
}
 
int main() {
    Run("ls", ".");
    Run("ls", "/usr/");
    Run("ls", "/abacaba/");
    Run("ls2", "/usr/");
    return 0;
}
P> Running ls .
P> I am parent 16379
P> Child is 16380
C> I am child 16380 of 16379
a.out  main.c
P> Wait OK
P> Exit code = 0

P> Running ls /usr/
P> I am parent 16379
P> Child is 16381
C> I am child 16381 of 16379
bin  games  include  lib  local  locale  sbin  share  src
P> Wait OK
P> Exit code = 0

P> Running ls /abacaba/
P> I am parent 16379
P> Child is 16382
C> I am child 16382 of 16379
ls: cannot access '/abacaba/': No such file or directory
P> Wait OK
P> Exit code = 2

P> Running ls2 /usr/
P> I am parent 16379
P> Child is 16383
C> I am child 16383 of 16379
Unable to exec
P> Wait OK
P> Exit code = 42

Каналы (pipes)

Вспомним для начала, что такое каналы.

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

Именованные каналы (named pipes)

Их мы рассматривали ранее. Они выглядят как файлы специального типа. Именованный канал — существует в системе и после завершения процесса. Он должен быть «отсоединён» или удалён, когда уже не используется. Процессы обычно подсоединяются к каналу для осуществления взаимодействия между ними. Именованный канал создаётся явно с помощью mknod или mkfifo, и два различных процесса могут обратиться к нему по имени.

Неименованные каналы (anonymous pipes)

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

Неименованный канал «безымянен», потому что существует анонимно и только во время выполнения процесса.

Двустороннее взаимодействие между процессами обычно требует наличия двух неименованных каналов.

#include <unistd.h>
 
int pipe(int filedes[2]);

filedes[0] предназначен для чтения, а filedes[1] предназначен для записи.

Решение проблемы отлова ошибки exec

Классическая задача с элегантным решением. Перед форком создаём канал (pipe) в родительском процессе. После форка родительский процесс закрывает write-конец канала и блокируется, пытаясь прочитать с read-конца. Дочерний процесс закрывает read-конец и выставляет флаг close-on-exec при помощи функции fcntl на write-конце.

Теперь если exec пройдёт успешно, write-конец канала будет закрыт без данных, и чтение в родительском процессе вернёт 0. Если же exec в дочернем процессе не сработал, он пишет в канал код ошибки, и в родителе read прочитает ненулевое число байт.

#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <sys/wait.h>
#include <sysexits.h>
#include <unistd.h>
 
void Run(const char* program, const char* arg) {
    printf("P> Running %s %s\n", program, arg);
 
    int pipefds[2];
    if (pipe(pipefds)) {
        perror("pipe");
        return;
    }
 
    if (fcntl(pipefds[1], F_SETFD, fcntl(pipefds[1], F_GETFD) | FD_CLOEXEC)) {
        perror("fcntl");
        return;
    }
 
    pid_t pid = fork();
 
    if (pid == -1) {
        perror("fork");
        return;
    } else if (pid > 0) {
        printf("P> I am parent %d\n", getpid());
        printf("P> Child is %d\n", pid);
 
        close(pipefds[1]);
 
        ssize_t count;
        int err;
        while ((count = read(pipefds[0], &err, sizeof(errno))) == -1) {
            if (errno != EAGAIN && errno != EINTR) {
                break;
            }
        }
        if (count) {
            printf("P> child's execvp: %s\n", strerror(err));
            return;
        }
        close(pipefds[0]);
 
        printf("P> Waiting for child...\n");
        int status;
        // wait(&status);
        waitpid(pid, &status, 0);
        printf("P> Wait OK\n");
        if (WIFEXITED(status)) {
            printf("P> Exit code = %d\n", WEXITSTATUS(status));
        }
        printf("\n");
    } else {
        // we are the child
        printf("C> I am child %d of %d\n", getpid(), getppid());
 
        close(pipefds[0]);
        if (execlp(program, program, arg, NULL) == -1) {
            perror("exec");
            write(pipefds[1], &errno, sizeof(int));
            _exit(0);
        }
    }
}
 
int main() {
    Run("ls", ".");
    Run("ls", "/usr/");
    Run("ls", "/abacaba/");
    Run("ls2", "/usr/");
    return 0;
}

Кастомный конвейер на базе анонимного канала

Делаем аналог шелловского

ls -l /etc/ | wc -l

Файловые дескрипторы

Вспомним это понятие.

Ко всем потокам ввода-вывода (которые могут быть связаны как с файлами, так и с каталогами, сокетами и FIFO) можно получить доступ через так называемые файловые дескрипторы. Файловый дескриптор — это неотрицательное целое число. Когда создается новый поток ввода-вывода, ядро возвращает процессу, создавшему поток ввода-вывода, его файловый дескриптор.

По умолчанию Unix-оболочки связывают файловый дескриптор 0 с потоком стандартного ввода процесса (терминал), файловый дескриптор 1 — с потоком стандартного вывода (терминал), и файловый дескриптор 2 — с потоком диагностики (куда обычно выводятся сообщения об ошибках). Это соглашение соблюдается многими Unix-оболочками и многими приложениями — и ни в коем случае не является составной частью ядра.

Стандарт POSIX.1 заменил «магические числа» 0, 1, 2 символическими константами STDIN_FILENO, STDOUT_FILENO и STDERR_FILENO соответственно.

Файловые дескрипторы могут принимать значения от 0 до OPEN_MAX. Согласно POSIX.1, значение OPEN_MAX равно 19. В реальных ОС это значение может быть больше.

File table and inode table.svg.png

Дублирование файловых дескрипторов

#include <unistd.h>
 
int dup(int oldfd);
int dup2(int oldfd, int newfd);

Функция dup принимает файловый дескриптор oldfd и создаёт его копию, используя наименьший неиспользуемый номер в качестве нового файлового дескриптора. Функция возвращает этот дескриптор.

После dup два дескриптора (старый и новый) можно использовать равнозначно. Они связаны с тем же самым открытым файлом, поэтому у них общее смещение (offset) и флаги статуса. Например, если использовать lseek с одним дескриптором для «перемотки», то изменения будут применены ко второму тоже.

Функция dup2 делает то же самое, но вместо взятия нового дескриптора с наименьшим номером берёт дескриптор, указанный в качестве newfd. Если он уже ранее был открыт, то он будет молча закрыт перед повторным использованием. Причём закрывание и переоткрывание будут выполнены атомарно.

Пример: перенаправление stdout в stderr:

#include <unistd.h>
...
dup2(2, 1); // 2-stderr; 1-stdout
...

Пример

Хотим научиться связывать процессы аналогично тому, как делает командная оболочка:

ls -1 | wc -l

Код с пары:

#include <unistd.h>
#include <stdio.h>
 
int main() {
    int fds[2];
    if (pipe(fds) == -1) {
        perror("pipe");
        return 1;
    }
 
    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }
 
    if (pid != 0) {
        if (dup2(fds[1], 1) == -1) {
            perror("dup2");
            return 1;
        }
        close(fds[0]);
        if (execlp("ls", "ls", "-1", NULL) == -1) {
            perror("execlp");
            return 1;
        }
    } else {
        if (dup2(fds[0], 0) == -1) {
            perror("dup2");
            return 1;
        }
        close(fds[1]);
        if (execlp("wc", "wc", "-l", NULL) == -1) {
            perror("execlp");
            return 1;
        }
    }
}

vfork() — оптимизация

Казалось бы, когда при fork() процесс копируется, выполняется лишняя работа, ведь далее результаты этого копирования никому не нужны: дочерний процесс вызовет exec(), и всё, начнёт работать совсем другая программа.

Разработчики подумали об этом. И был введён отдельный системный вызов.

#include <sys/types.h>
#include <unistd.h>
 
pid_t vfork(void);

По смыслу он аналогичен fork(). Но здесь новый дочерний процесс имеет право после начала жизни сделать только следующее:

  • выйти через _exit(),
  • загрузить новую программу. Стандарт POSIX разрешает делать это через любую функцию из семейства exec(). Документация к Linux гласит, что допускается использовать лишь вариант execve(). Выходит, что в этом месте ОС Linux не полностью следует стандарту и отходит от него. Дело в том, что в реализации execl() и подобных функций-обёрток могут выполняться небезопасные действия: выделение памяти под аргументы, конкатенация фрагментов пути и пр.

Если дочерний процесс будет делать что-то ещё, это будет неопределённым поведением. Нельзя даже выходить через return из функции, нельзя использовать exit(), только _exit().

В Linux при vfork() родительский процесс блокируется, дочерний использует его виртуальное адресное пространство, включая стек.

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

  • Легко случайно сделать недопустимое действие и получить неопределённое поведение.
  • Недоступен способ передачи сообщения об ошибке через пайп.
  • Если придёт сигнал в момент выполнения кода дочернего процесса, обработчик начнёт выполняться в адресном пространстве родителя, и могут быть проблемы.

Offtopic: запуск процесса в альтернативной ОС

Microsoft Windows не поддерживает модель fork-exec и не имеет системного вызова, аналогичного fork().

#include <stdio.h>
#include <Windows.h>
 
int main() {
    TCHAR cmd[] = TEXT("ls -l");
 
    STARTUPINFO si;
    PROCESS_INFORMATION pi;
 
    ZeroMemory(&si, sizeof(si));
    si.cb = sizeof(si);
    ZeroMemory(&pi, sizeof(pi));
 
    // Start the child process. 
    if (!CreateProcess(NULL,   // No module name (use command line)
        cmd,            // Command line
        NULL,           // Process handle not inheritable
        NULL,           // Thread handle not inheritable
        FALSE,          // Set handle inheritance to FALSE
        0,              // No creation flags
        NULL,           // Use parent's environment block
        NULL,           // Use parent's starting directory 
        &si,            // Pointer to STARTUPINFO structure
        &pi)           // Pointer to PROCESS_INFORMATION structure
        )
    {
        printf("CreateProcess failed (%d).\n", GetLastError());
        return 1;
    }
 
    // Wait until child process exits.
    WaitForSingleObject(pi.hProcess, INFINITE);
 
    // Close process and thread handles. 
    CloseHandle(pi.hProcess);
    CloseHandle(pi.hThread);
    return 0;
}