Unix2018/Техника fork — exec
Содержание
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"); } } }
Проблема обработки ошибок
Ошибки могут случаться на разных стадиях.
- Неудачный вызов fork: возвращается −1, дочерний процесс вообще не порождается.
- Неудачный вызов exec в дочернем процессе. Например, указан несуществующий путь к запускаемой программе. В случае успеха функция exec не возвращает управление совсем, в случае неуспеха возвращается −1.
- Вызов 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. В реальных ОС это значение может быть больше.
Дублирование файловых дескрипторов
#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; }