Послушайте!
Ведь, если звезды зажигают — значит — это кому-нибудь нужно?В. В. Маяковский, 1914
Я занимаюсь программированием для встроенных систем, и данную статью решил написать для того, чтобы лучше разобраться с проблемой использования системных вызовов fork() и vfork(). Второй из них часто советуют не использовать, но ясно, что появился он не просто так.
Давайте разберёмся, когда и почему лучше использовать тот или иной вызов.
В качестве бонуса будет приведено описание реализаций vfork()/fork() в нашем проекте. Прежде всего, мой интерес связан с применением этих вызовов во встроенных системах, и главной особенностью приведённых реализаций является отсутствие виртуальной памяти. Возможно, хорошо разбирающиеся в системном программировании и во встроенных системах, дадут советы и поделятся опытом.
Кому интересно, прошу под кат.
Начнём с определения, то есть со стандарта POSIX, в котором эти функции определены:
fork() создаёт точную копию процесса за исключением нескольких переменных. При успешном выполнении функция возвращает значение ноль дочернему процессу и номер дочернего процесса — родителю (дальше процессы начинают «жить своей жизнью»).
vfоrk() определяется как fork() со следующим ограничением: поведение функции не определено, если созданный с её помощью процесс совершит хотя бы одно из следующих действий:
- Произведёт возврат из функции, в которой был вызван vfork();
- Вызовет любую функцию кроме _exit() или exec*();
- Изменит любые данные кроме переменной, в которой хранится возвращаемое функцией vfork() значение.
Для того чтобы понять, почему вообще существует системный вызов с такими сильными ограничениями, нужно разобраться, что такое точная копия процесса.
Одна из первых ссылок в поисковике по этой теме на русском языке — это описание параметров клонирования процессов в Linux. Из него следует, что некоторые параметры могут быть сделаны общими для родительского и порождённого процессов:
- Адресное пространство (CLONE_VM);
- Информация о файловой системе (CLONE_FS);
- Таблица открытых файлов (CLONE_FILES);
- Таблица обработчиков сигналов (CLONE_SIGHAND);
- Родительский процесс (CLONE_PARENT).
В POSIX для vfork() не допускается изменение переменных, а это наводит на мысль, что дело в клонировании адресного пространства. Эта ссылка подтверждает предположение:
В отличие от fork(), vfork() не создает копию родительского процесса, а создает разделяемое с родительским процессом адресное пространство до тех пор, пока не будет вызвана функция _exit или одна из функций exec.
Родительский процесс на это время останавливает свое выполнение. Отсюда следуют и все ограничения на использование – дочерний процесс не может изменять никакие глобальные переменные или даже общие переменные, разделяемые с родительским процессом.
Другими словами, если это утверждение верно, после вызова vfork() оба процесса, будут видеть одни и те же данные.
Давайте проведем эксперимент. Если это действительно так, то изменения, вносимые в данные порожденного процесса, должны быть видны в родительском процессе, и наоборот.
static int create_process(void) {
pid_t pid;
int status;
int common_variable;
common_variable = 0;
pid = fork();
if (-1 == pid) {
return errno;
}
if (pid == 0) {
/* Если исполняется дочерний процесс */
common_variable = 1;
exit(EXIT_SUCCESS);
}
waitpid(pid, &status, 0);
if (common_variable) {
puts("vfork(): common variable has been changed.");
} else {
puts("fork(): common variable hasn't been changed.");
}
return EXIT_SUCCESS;
}
int main(void) {
return create_process();
}
Если собрать и запустить эту программу, получим вывод:
fork(): common variable hasn't been changed.
При замене fork() на vfork(), вывод изменится:
vfork(): common variable has been changed.
Многие пользуются этим свойством, передавая данные между процессами, хотя по POSIX поведение таких программ не определено. Вероятно, это и создает проблемы, из-за которых советуют не использовать vfork().
Действительно, одно дело, когда разработчик осознанно меняет значение некоторой переменной, и совсем другое, когда он забывает о том, что дочернему процессу нельзя, например, совершать возврат из функции, в которой был вызван vfork() (ведь это разрушит структуру стека родительского процесса!). И даже действуя сознательно, как и обычно, вы используете недокументированные возможности на свой страх и риск.
А вот пара менее очевидных проблем:
- В книжке «Secure Programming for Linux and Unix HOWTO» говорится, что даже если в коде языка высокого уровня дочерний процесс действительно не изменяет никаких данных, в машинном коде это может быть не так (например, из-за появления скрытых временных переменных).
- В данном блоге анализируется следующий вопрос: что, если vfork() будет вызван в многопоточном приложении? Рассмотрим реализацию vfork() в Linux: мануал говорит, что при вызове родительский процесс останавливается, но на самом деле это происходит только с текущим потоком (что, конечно, проще реализовать). Это значит, что дочерний процесс продолжает исполняться параллельно с другими потоками, которые могут, например, менять права родительского процесса. И вот тут всё станет очень плохо: мы получим два процесса с разными правами в одном адресном пространстве, что открывает дыру в безопасности.
Теперь рассмотрим функции семейства exec*. Только их (не считая _exit()) можно вызывать в процессе, полученном с помощью vfork(). Они создают новое адресное пространство, а затем загружают в него код и данные из указанного файла. При этом старое адресное пространство, по сути, уничтожается.
Следовательно, если процесс создаётся с помощью fork(), а затем вызывает exec*(), создание (копирование) адресного пространства при вызове fork() было избыточным, а это — довольно трудоемкое действие, и, возможно, оно занимает основное время вызова fork(). В Википедии, например, этому моменту уделено больше всего внимания, и, в отличие от стандарта, прямо сказано:
The fork() operation creates a separate address space for the child. The child process has an exact copy of all the memory segments of the parent process.
Конечно, на большинстве современных систем с виртуальной памятью никакого копирования не происходит, все страницы памяти родительского процесса просто помечаются флагом copy-on-write. Тем не менее, при этом нужно пробегаться по всей иерархии таблиц, а это требует времени.
Получается что, вызов vfork() должен выполняться быстрее fork(), что упоминается и в LinuxMan page.
Проведем еще один эксперимент и убедимся, что это действительно так. Немного изменим предыдущий пример: добавим цикл для создания 1000 процессов, уберём общую переменную и вывод на экран.
#include <sys/types.h>
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>
#include <unistd.h>
#include <sys/wait.h>
static int create_process(void) {
pid_t pid;
int status;
pid = vfork();
if (-1 == pid) {
return errno;
}
if (pid == 0) {
/* child */
exit(EXIT_SUCCESS);
}
waitpid(pid, &status, 0);
return EXIT_SUCCESS;
}
int main(void) {
int i;
for (i = 0; i < 1000; i ++) {
create_process();
}
return EXIT_SUCCESS;
}
Запустим через команду time.
Вывод при использовании fork() | Вывод при использовании vfork() |
|
|
Результат, мягко говоря, впечатляет. От запуска к запуску данные будут незначительно отличаться, но всё равно vfork() будет от 4 до 5 раз быстрее.
Выводы следующие:
fork() — более «тяжелый» вызов, и если можно вызвать vfork(), лучше использовать его.
vfork() — менее безопасный вызов, и с ним легче выстрелить себе в ногу, и, соответственно, применять его нужно осмысленно.
fork()/vfork() нужно применять там, где нужно создать отдельные ресурсы для процесса (индексные дескрипторы, пользователь, рабочая папка), в остальных случаях стоит работать с pthread*, которые работают ещё быстрее.
fork() лучше применять в случае, когда действительно нужно создать отдельное адресное пространство. Впрочем, это очень трудно реализовать на небольших процессорных платформах без аппаратной поддержки виртуальной памяти.
Прежде чем перейти ко второй части статьи, отмечу, что в POSIX есть функция posix_spawn(). Эта функция, по сути, содержит в себе vfork() и exec(), и, следовательно, позволяет избежать проблем, связанных с vfork(), при отсутствии повторного создания адресного пространства как в fork().
Теперь перейдём к нашей реализации fork()/vfork() без поддержки MMU.
Реализация vfork
Реализуя vfork() в нашей системе, мы предполагали, что вызов vfork() должен происходит так: родитель переходит в режим ожидания, а первым из vfork() возвращается дочерний процесс, пробуждая родителя при вызове функции _exit() или exec*(). Это значит, что потомок может выполняться на родительском стеке, но с собственными ресурсами остальных типов: индексными дескрипторами, таблицей сигналов и так далее.
Хранилищем различных типов ресурсов в нашем проекте является задача (struct task). Именно эта структура описывает все ресурсы процесса, в том числе доступную память, индексные дескрипторы и список потоков, принадлежащих этому процессу. У задачи всегда есть основной поток — тот, который создается при её инициализации. Потоком в нашей системе называют объект планирования, подробнее об этом — в статье моей коллеги. Так как стеком у нас управляет поток, а не задача, можно предложить два варианта реализации:
- Поменять стек во вновь созданном потоке на стек родителя;
- «Подменить» задачу на новую для того же потока исполнения.
Так или иначе, задачу создавать придётся, а точнее — наследовать её от родительской: будет произведено клонирование таблицы сигналов, переменных окружения и так далее. Адресное пространство при этом, впрочем, наследоваться не будет.
Возврат из vfork() будет осуществлён дважды: для родительского и дочернего процессов. Значит, где-то должны быть сохранены регистры того фрейма стека, из которого был вызван vfork(). Сделать это на стеке нельзя, так как дочерний процесс может затереть эти значения во время исполнения. Тем не менее, сигнатура vfork() не подразумевает наличие какого-то буфера, поэтому сначала регистры сохраняются на стеке, а только потом — где-то в родительской задаче. Сохранение регистров на стеке можно было произвести с помощью системного вызова, но мы решили обойтись без него и сделали это самостоятельно. Естественно, функция vfork() написана на ассемблере.
vfork:
subl $28, %esp;
pushl %ds;
pushl %es;
pushl %fs;
pushl %gs;
pushl %eax;
pushl %ebp;
pushl %edi;
pushl %esi;
pushl %edx;
pushl %ecx;
pushl %ebx;
movl PT_END(%esp), %ecx;
movl %ecx, PT_EIP(%esp);
pushf;
popl PT_EFLAGS(%esp);
movl %esp, %eax;
addl $PT_END+4, %eax;
movl %eax, PT_ESP(%esp);
push %esp;
call vfork_body
Таким образом, сначала регистры сохраняются на стеке, а затем вызывается C-шная функция vfork_body(). В качестве аргумента ей передаётся указатель на структуру с набором регистров.
typedef struct pt_regs {
/* Pushed by SAVE_ALL. */
uint32_t ebx;
uint32_t ecx;
uint32_t edx;
uint32_t esi;
uint32_t edi;
uint32_t ebp;
uint32_t eax;
uint32_t gs;
uint32_t fs;
uint32_t es;
uint32_t ds;
/* Pushed at the very beginning of entry. */
uint32_t trapno;
/* In some cases pushed by processor, in some - by us. */
uint32_t err;
/* Pushed by processor. */
uint32_t eip;
uint32_t cs;
uint32_t eflags;
/* Pushed by processor, if switching of rings occurs. */
uint32_t esp;
uint32_t ss;
} pt_regs_t;
Код vfork_body() архитектурно независим. Он отвечает за создание задачи и сохранение необходимых для выхода регистров.
void __attribute__((noreturn)) vfork_body(struct pt_regs *ptregs) {
struct task *child;
pid_t child_pid;
struct task_vfork *task_vfork;
int res;
/* can vfork only in single thread application */
assert(thread_self() == task_self()->tsk_main);
/* create task description but not start its main thread */
child_pid = task_prepare("");
if (0 > child_pid) {
/* error */
ptregs_retcode_err_jmp(ptregs, -1, child_pid);
panic("vfork_body returning");
}
child = task_table_get(child_pid);
/* save ptregs for parent return from vfork() */
task_vfork = task_resource_vfork(child->parent);
memcpy(&task_vfork->ptregs, ptregs, sizeof(task_vfork->ptregs));
res = vfork_child_start(child);
if (res < 0) {
/* Could not start child process */
/* Exit child task */
vfork_child_done(child, vfork_body_exit_stub, &res);
/* Return to the parent */
ptregs_retcode_err_jmp(&task_vfork->ptregs, -1, -res);
}
panic("vfork_body returning");
}
Немного пояснений к коду.
Сначала происходит проверка на многопоточность (проблемы, связанные с ней при использовании vfork(), обсуждались выше). Затем создаётся новая задача, и, если это удалось, в ней сохраняются регистры для возврата из vfork().
После этого вызывается функция vfork_child_start(), которая, как следует из названия, «запускает» дочерний процесс. Кавычки здесь поставлены не случайно, так как на самом деле задача может запускаться позднее, всё зависит от конкретной реализации, которых в нашем проекте две. Прежде чем перейти к их описанию, рассмотрим функции _exit() и exec*().
При их вызове родительский поток должен разблокироваться. Будем говорить, что именно в этот момент происходит полноценный запуск дочернего процесса как отдельной сущности в системе.
int execv(const char *path, char *const argv[]) {
struct task *task;
/* save starting arguments for the task */
task = task_self();
task_resource_exec(task, path, argv);
/* if vforked then unblock parent and start execute new image */
vfork_child_done(task, task_exec_callback, NULL);
return 0;
}
Другие функции семейства exec* у нас выражены через вызов execv().
void _exit(int status) {
struct task *task;
task = task_self();
vfork_child_done(task, task_exit_callback, (void *)status);
task_start_exit();
{
task_do_exit(task, TASKST_EXITED_MASK | (status & TASKST_EXITST_MASK));
kill(task_get_id(task_get_parent(task)), SIGCHLD);
}
task_finish_exit();
panic("Returning from _exit");
}
Как, наверное, видно из приведенного кода, для того, чтобы разблокировать родительский процесс, используется функция vfork_child_done() с указанием обработчика в качестве одного из параметров. Для реализации того или иного алгоритма работы должны быть реализованы:
- vfork_child_start() — функция, вызываемая в начале процесса клонирования, должна заблокировать родительский процесс;
- vfork_child_done() — функция, вызываемая при окончательном старте дочернего процесса, родительский процесс при этом разблокируется;
- task_exit_callback() — обработчик для завершения дочернего процесса;
- task_exec_callback() — обработчик для полноценного запуска дочернего процесса.
Первая реализация
Идея первой реализации заключается в том, чтобы помимо того же стека использовать тот же поток управления. По сути, в этом случае нужно только «подменить» задачу для текущего потока на дочернюю до тех пор, пока дочерняя задача не стартует окончательно при вызове vfork_child_done().
int vfork_child_start(struct task *child) {
thread_set_task(thread_self(), child);
/* mark as vforking */
task_vfork_start(child);
/* Restore values of the registers and return 0 */
ptregs_retcode_jmp(&task_resource_vfork(child->parent)->ptregs, 0);
panic("vfork_child_start returning");
return -1;
}
Происходит следующее: текущий поток исполнения (то есть родительский) привязывается к порождённому процессу функцией thread_set_task() — для этого достаточно изменить соответствующий указатель в структуре текущего потока. Это значит, что при обращении к ресурсам, связанных с задачей, поток будет обращаться к задаче дочерней, а не к родительской, как раньше. Например, при попытке потока узнать, к какой задаче поток относится (функция task_self()), он получит именно дочернюю задачу.
После этого дочерняя задача помечается как созданная в результате vfork-а, этот флаг понадобится для того, чтобы функция vfork_child_done() выполнялась нужным образом (подробнее — чуть позже).
Затем восстанавливаются регистры, сохранённые при вызове vfork(). Напомним, что согласно POSIX вызов vfork() должен возвращать значение ноль дочернему процессу, что и происходит с помощью вызова ptregs_retcode_jmp(ptregs, 0).
Как уже говорилось, при вызове дочерним процессом функции _exit() или execv() функция vfork_chlid_done() должна разблокировать родительский поток. Помимо этого нужно подготовить дочернюю задачу к исполнению нужного обработчика.
void vfork_child_done(struct task *child, void * (*run)(void *), void *arg) {
struct task_vfork *vfork_data;
if (!task_is_vforking(child)) {
return;
}
task_vfork_end(child);
task_start(child, run, NULL);
thread_set_task(thread_self(), child->parent);
vfork_data = task_resource_vfork(child->parent);
ptregs_retcode_jmp(&vfork_data->ptregs, child->tsk_id);
}
void *task_exit_callback(void *arg) {
_exit((int)arg);
return arg;
}
void *task_exec_callback(void *arg) {
int res;
res = exec_call();
return (void*)res;
}
При вызове vfork_child_done() необходимо учесть случай использования exec()/_exit() без vfork() — тогда нужно просто выйти из текущей функции, ведь нет нужды заниматься разблокировкой родителя, и можно сразу перейти к запуску потомка. Если же процесс был создан с помощью vfork(), совершается следующее: сперва снимается флаг is_vforking с дочерней задачи с помощью task_vfork_end(), затем, наконец, стартует главный поток дочерней задачи. В качестве точки входа указывается функция run, которая должна быть одним из обработчиков, описанных ранее (task_exec_callback, task_exit_callback) — они необходимы при реализации vfork(). После этого меняется принадлежность потока к задаче: вместо дочерней вновь указывается родительская. Наконец, производится возврат в родительскую задачу из вызова vfork() с идентификатором дочернего процесса в качестве возвращаемого значения. Выше уже говорилось, что это делается с помощью вызова ptregs_retcode_jmp().
Вторая реализация vfork
Идея второй реализации заключается в использовании родительского стека новым потоком, который был создан вместе с новой задачей. Это получится автоматически, если в дочернем потоке восстановить регистры, ранее сохранённые в родительском потоке. При этом можно использовать настоящую синхронизацию между потоками, как описано в уже упомянутой статье. Это, безусловно, более красивое, но и более сложное в реализации решение, ведь когда родительский поток будет в ожидании, на этом же стеке будет выполняться его потомок. Значит, на время ожидания нужно переключиться на некоторый промежуточный стек, где можно спокойно ждать вызов потомком _exit() или exec*().
int vfork_child_start(struct task *child) {
struct task_vfork *task_vfork;
task_vfork = task_resource_vfork(task_self());
/* Allocate memory for the new stack */
task_vfork->stack = sysmalloc(sizeof(task_vfork->stack));
if (!task_vfork->stack) {
return -EAGAIN;
}
task_vfork->child_pid = child->tsk_id;
/* Set new stack and go to vfork_waiting */
if (!setjmp(task_vfork->env)) {
CONTEXT_JMP_NEW_STACK(vfork_waiting,
task_vfork->stack + sizeof(task_vfork->stack));
}
/* current stack was broken, can't reach any old data */
task_vfork = task_resource_vfork(task_self());
sysfree(task_vfork->stack);
ptregs_retcode_jmp(&task_vfork->ptregs, task_vfork->child_pid);
panic("vfork_child_start returning");
return -1;
}
Пояснения к коду:
Сначала выделяется место под стек, после этого сохраняется pid (process ID) дочернего, так как он потребуется родителю для возврата из vfork().
Вызов setjmp() позволит вернуться в то место стека, где был вызван vfork(). Как уже говорилось, ожидание должно выполняться на некотором промежуточном стеке, и переключение совершается с помощью макроса CONTEXT_JMP_NEW_STACK(), который меняет текущий стек и передаёт управление функции vfork_waiting(). В ней-то и будет происходить активация потомка и блокирование предка до вызова vfork_child_done().
static void vfork_waiting(void) {
struct sigaction ochildsa;
struct task *child;
struct task *parent;
struct task_vfork *task_vfork;
parent = task_self();
task_vfork = task_resource_vfork(parent);
child = task_table_get(task_vfork->child_pid);
vfork_wait_signal_store(&ochildsa);
{
task_vfork_start(parent);
task_start(child, vfork_child_task, &task_vfork->ptregs);
while (SCHED_WAIT(!task_is_vforking(parent)));
}
vfork_wait_signal_restore(&ochildsa);
longjmp(task_vfork->env, 1);
panic("vfork_waiting returning");
}
Как видно из кода, прежде всего сохраняется таблица сигналов дочернего процесса. На самом деле, будет переопределён сигнал SIGCHLD, который посылается при изменении статуса дочернего процесса. В данном случае он используется для разблокировки родителя.
static void vfork_parent_signal_handler(int sig, siginfo_t *siginfo, void *context) {
task_vfork_end(task_self());
}
Сохранение и восстановление таблицы сигналов совершается с помощью POSIX-вызова sigaction().
static void vfork_wait_signal_store(struct sigaction *ochildsa) {
struct sigaction sa;
sa.sa_flags = SA_SIGINFO;
sa.sa_sigaction = vfork_parent_signal_handler;
sigemptyset(&sa.sa_mask);
sigaction(SIGCHLD, &sa, ochildsa);
}
static void vfork_wait_signal_restore(const struct sigaction *ochildsa) {
sigaction(SIGCHLD, ochildsa, NULL);
}
После замены обработчика сигнала задача помечается как находящаяся в режиме ожидания, в котором она будет пребывать вплоть до настоящего запуска дочерней задачи при вызове _exit()/exec*(). В качестве точки входа в задачу используется функция vfork_child_task(), которая восстанавливает сохранённые ранее регистры и возвращается из vfork().
static void *vfork_child_task(void *arg) {
struct pt_regs *ptregs = arg;
ptregs_retcode_jmp(ptregs, 0);
panic("vfork_child_task returning");
}
При вызове _exit() и exec*() будет отправлен SIGCHLD, и обработчик этого сигнала снимет отметку об ожидании запуска потомка. После этого восстановится старый обработчик сигнала SIGCHLD, а управление вернётся в функцию vfork_child_start() с помощью longjmp(). Нужно помнить, что фрейм стека этой функции будет повреждён после выполнения дочернего процесса, поэтому локальные переменные будут содержать не то, что нужно. После освобождения выделенного ранее стека производится возврат из функции vfork() номера дочерней задачи.
Проверка работоспособности vfork
Для проверки корректного поведения vfork() нами был написан набор тестов, покрывающих несколько ситуаций.
Два из них проверяют корректный возврат из vfork() при вызове _exit() и execv() дочерним процессом.
TEST_CASE("after called vfork() child call exit()") {
pid_t pid;
pid_t parent_pid;
int res;
parent_pid = getpid();
pid = vfork();
/* When vfork() returns -1, an error happened. */
test_assert(pid != -1);
if (pid == 0) {
/* When vfork() returns 0, we are in the child process. */
_exit(0);
}
wait(&res);
test_assert_not_equal(pid, parent_pid);
test_assert_equal(getpid(), parent_pid);
}
TEST_CASE("after called vfork() child call execv()") {
pid_t pid;
pid_t parent_pid;
int res;
parent_pid = getpid();
pid = vfork();
/* When vfork() returns -1, an error happened. */
test_assert(pid != -1);
if (pid == 0) {
close(0);
close(1);
close(2);
/* When vfork() returns 0, we are in the child process. */
if (execv("help", NULL) == -1) {
test_assert(0);
}
}
wait(&res);
test_assert_not_equal(pid, parent_pid);
test_assert_equal(getpid(), parent_pid);
}
Ещё один тест проверяет использование одного и того же стека родительским и дочерним процессами.
TEST_CASE("parent should see stack modifications made from child") {
pid_t pid;
int res;
int data;
data = 1;
pid = vfork();
/* When vfork() returns -1, an error happened. */
test_assert(pid != -1);
if (pid == 0) {
data = 2;
/* When vfork() returns 0, we are in the child process. */
_exit(0);
}
wait(&res);
test_assert_equal(data, 2);
}
Однако, хотелось бы проверить корректность работы на какой-то реальной, причем сторонней, программе, и для этого был выбран достаточно известный пакет dropbear. При конфигурации он проверяет наличие fork(), и, если не находит его, может использовать vfork(). Сразу оговорюсь, что это было сделано ради поддержки ucLinux, а не в целях улучшения производительности.
ОС была сконфигурирована соответствующим образом (чтобы dropbear использовал vfork()), и с помощью ssh было успешно установлено соединение при обоих реализациях.
P.S. Также в нашем проекте удалось реализовать и сам fork() без использования MMU, в настоящий момент об этом составляется статья.
Автор: 0xdde