Недавно я наткнулся на реализацию popen() (та же идея, другой API) с использованием clone(2), где я открыл issue с запросом использования vfork(2) или posix_spawn() в целях лучшей переносимости на другие платформы. Оказывается, для Linux есть одно очень важное преимущество в использовании clone(2). И вот я думаю, что мне следует раскрыть тему, которую я там затронул, где-нибудь еще: в гисте, в блоге, да где угодно.
Итак, начнем.
Давным-давно я, как и многие фанаты Unix, думал, что fork(2) и модель порождения процессов fork-exec были лучшим решением в мире, а Windows нервно курил в сторонке, имея только exec*() и _spawn*(), последний из которых был чистым виндоусизмом.
После многих лет практики я пришел к выводу, что fork(2) на самом деле является злом. А vfork(2)
, долгое время считавшийся злом, на самом деле является добром. Изящная вариация vfork(2)
, которая избегает необходимости блокировать родителя, была бы даже лучше (об этом чуть позже).
Такие серьезные заявки требуют объяснений, поэтому позвольте мне объяснить все по порядку.
Я не буду утруждать себя объяснением, что такое fork(2) - если вы это читаете, я полагаю, вы уже знаете. Но я расскажу про vfork(2)
и почему он считается опасным. vfork(2)
очень похож на fork(2)
, но новый процесс, который он создает, запускается в том же адресном пространстве, что и родительский, как если бы он был потоком. Он даже разделяет тот же стек с потоком, который вызвал vfork(2)
! Два потока не могут совместно использовать стек, поэтому родительский процесс останавливается до момента, когда дочерний выполнит свою задачу: либо exec*(2)
, либо _exit(2)
.
3BSD добавила vfork(2)
, а несколько лет спустя 4.4BSD удалила его, так как к тому времени он стал считаться небезопасным. По крайне мере так говорят на страницах нескольких последующих руководств. Но производные 4.4BSD восстановили его и не называют небезопасным. Для этого есть причина: vfork(2)
гораздо дешевле, чем fork(2)
- намного, намного дешевле. Это потому, что fork(2)
должен либо скопировать адресное пространство родителя, либо организовать копирование при записи (что должно быть оптимизацией, чтобы избежать ненужных копий). Но даже копирование при записи очень дорого обходится, потому что требует изменения маппинга памяти, дорогостоящего устранения ошибок страниц и т. д. Современные ядра, как правило, seed’ят дочерний элемент копией резидентного набора родительского объекта, но если родительский элемент имеет большой объем потребляемой памяти (например, JVM), то RSS будет огромным. Таким образом, fork(2)
неизбежно дорог, за исключением небольших программ с небольшими объемами потребляемой памяти (например, shell).
Итак, вы начинаете понимать, почему fork(2)
- зло. И я еще не дошел до рисков, связанных с fork-safety! Соображения fork-safety во многом схожи с thread-safety (потокобезопасностью), но сделать библиотеки fork-safe сложнее, чем thread-safe. Я не буду здесь вдаваться в тонкости fork-safety: в этом нет необходимости.
(Прежде чем продолжить, я должен признаться в лицемерии: я пишу код, который использует fork(2)
, зачастую для многопроцессных демонов, в отличие от многопоточности, хотя я часто использую и последнее. Но форки там происходят очень рано, когда ничего fork-unsafe еще не произошло, а адресное пространство еще мало, что позволяет избежать большинства зол fork(2)
. vfork(2)
не может использоваться в этих целях. В Windows нужно было бы прибегать к CreateProcess()
или _spawn()
для реализации многопроцессных демонов, что является большой головной болью.)
Почему я тогда думал, что fork(2)
элегантен? По той же причине, что и все остальные: CreateProcess*()
, _spawn()
и posix_spawn()
, и такие функции чрезвычайно сложны, какими они и должны быть, потому что существует огромное количество вещей, которые можно сделать между fork()
и exec()
, скажем, shell. Но с fork()
и exec()
не нужен язык или API, которые могут выразить все эти вещи: язык хоста подойдет! fork(2)
дал создателям Unix возможность перенести всю эту сложность из kernel-land в user-land, где гораздо проще разрабатывать программное обеспечение - это сделало их более продуктивными, возможно, намного более эффективными. Цена, которую создатели Unix заплатили за эту элегантность, заключалась в необходимости копирования адресных пространств. Поскольку в то время программы и процессы были небольшими, их неэлегантность было легко не заметить. Но теперь процессы имеют тенденцию быть огромными, и это делает копирование даже только резидентного набора родительского объекта и возню с таблицей страниц для всего остального чрезвычайно дорогостоящим занятием.
Но у vfork()
есть вся эта элегантность и нет недостатков fork()
!
У vfork()
есть один недостаток: родительский процесс (в частности, поток в родительском процессе, который вызывает vfork()
) и дочерний процесс совместно используют стек, что требует остановки родительского (потока) до тех пор, пока дочерний процесс не выполнит exec()
или _exit()
. (Это можно простить из-за того, что vfork(2)
давно предшествовал потокам - когда потоки появились, необходимость в отдельном стеке для каждого нового потока стала совершенно очевидной и неизбежной. Исправление для потоковой передачи заключалось в использовании нового стека для нового потока и использовании коллбек-функции и аргумента как main()-подобия для этого нового стека.) Но блокировка - это плохо, потому что синхронное поведение плохо, особенно когда это единственный вариант, но все могло бы быть лучше. Асинхронная версия vfork()
должна будет запускать дочерний процесс в новом/альтернативном стеке. Назовем ее afork()
или avfork()
. afork()
должен быть очень похож на pthread_create()
: он должен принимать функцию для вызова в новом стеке, а также аргумент для передачи этой функции.
Я должен упомянуть, что все справочные страницы vfork()
, которые я видел, говорят, что родительский процесс останавливается до тех пор, пока дочерний не совершит exit/exec, но это предшествует потокам. Linux, например, останавливает только один поток в родительском процессе - тот который вызвал vfork()
, а не все потоки. Я считаю, что это правильно, но другие IIRC ОС останавливают все потоки в родительском процессе (что является ошибкой, IMO).
afork()
позволил бы API по типу popen()
очень быстро возвращать с соответствующими пайпами для I/O с детьми. Если что-то пойдет не так на стороне дочернего процесса, тогда дочерний процесс выйдет, и их выходной пайп (если есть) продемонстрирует EOF, и/или записи на дочерний вход получат EPIPE и/или вызовут SIGPIPE, после чего вызывающий popen()
сможет проверить наличие ошибок.
С таким же успехом можно было одолжить флаги forkx()/vforkx() Illumos и сделать так, чтобы afork()
выглядел как-то так:
pid_t afork(int (*start_routine)(void *), void *arg);
pid_t aforkx(int flags /* FORK_NOSIGCHLD и/или FORK_WAITPID */, int (*fn)(void *), void *arg);
Оказывается, afork()
легко реализовать в Linux: это просто вызов clone(<function>
, <stack>
, CLONE_VM | CLONE_SETTLS
, <argument>
). (Вы могли бы хотеть запросит, чтобы SIGCHLD был направлен на родителю, когда ребенок умирает, но это явно не желательно в реализации popen()
, так как противном случае программа может извлечь его перед тем, как pclose()
сможет извлечь его. Более подробно об этом смотрите на Illumos.)
Можно также реализовать что-то вроде afork ()
(без Illumos forkx()
флагов) в системах POSIX, используя pthread_create()
для запуска потока, который будет блокироваться в vfork()
, пока вызвавший afork()
процесс продолжает свои дела. Добавьте taskq, чтобы заранее создать столько рабочих потоков, сколько необходимо, и у вас будет быстрый afork()
. Однако afork()
, реализованный таким образом, не сможет вернуть PID, если только потоки не в taskq pre-vfork (хорошая идея!), вместо этого потребуется колбек по завершению, что-то вроде этого:
int emulated_afork(int (*start_routine)(void *), void *arg, void (*cb)(pid_t) /* может быть NULL */);
Если потоки pre-vfork, то может быть реализован возвращающий PID afork()
, хотя передача задачи pre-vfork
потоку может быть сложной задачей: pthread_cond_wait()
может не работать в дочернем процессе, поэтому придется использовать пайп, в который записывается указатель на отправленный запрос. (Пайпы безопасны для использования на дочерней стороне vfork()
. То есть, read()
и write()
на пайпе безопасны в дочернем процессе vfork()
) Вот как это будет работать:
// Это работает, только если vfork() останавливает только один поток в родительском процессе, который вызвал vfork(), а не все потоки. Например, как в Linux.
// В противном случае это не сработает, и возможности реализовать avfork() нет. Конечно, в Linux можно просто использовать clone(2).
static struct avfork_taskq_s { /* опущено */ ... } *avfork_taskq;
static void
avfork_taskq_init(void)
{
// Опущено, оставлено как упражнение для читателя
...
}
// Другие taskq функции, указанные ниже, также опущены
// taskq поток создает подпрограмму запуска
static void *
worker_start_routine(void *arg)
{
struct worker_s *me = arg;
struct job_s *job;
// Регистрируем воркера и pthread_cond_signal() на до одного потока, который может ждать воркера.
avfork_taskq_add_worker(avfork_taskq, me);
do {
if ((job = calloc(1, sizeof(*job))) == NULL ||
pipe2(job->dispatch_pipe, O_CLOEXEC) == -1 ||
pipe2(job->ready_pipe, O_CLOEXEC) == -1 ||
(pid = vfork()) == -1) {
avfork_taskq_remove(avfork_taskq, me, errno); // Вышли!
break;
}
if (pid != 0) {
// Дочерний процесс завершился выполнил exit или exec
if (job->errno)
// Дочерний процесс не смог получить задание
reap_child(pid);
else
// Дочерний процесс получил задание; запишите его, чтобы мы могли избавиться от него позже позже.
// Это также помечает этот воркер как доступный и сигнализирует до одного потока, который может ожидать его.
avfork_taskq_record_child(avfork_taskq, me, job, pid);
if (avfork_taskq_too_big_p(avfork_taskq))
break; // Динамическое сжатие taskq
continue;
}
// Это ребенок
// Обратите внимание, что здесь вызываются только read(2), write(2), _exit(2) и start_routine из вызова avfork(). avfork() start_routine() должна вызывать только функции, безопасные для асинхронных сигналов, и не должна вызывать ничего, что небезопасно на дочерней стороне vfork(). В зависимости от ОС или библиотеки C может оказаться невозможным использовать что-либо или все из перечисленного: блокировки, условные переменные, аллокаторы, RTLD и т.д. По крайней мере, dup2(2), close(2), sigaction(2), Функции маскирования сигналов, exec(2) и _exit(2) можно безопасно вызывать в start_routine(), и этого достаточно для реализации posix_spawn(), более совершенного popen(), улучшенного system () и т.д.
// Также обратите внимание, что дочерний процесс вообще не ссылается на taskq.
// Получаем задачу
if (net_read(me->dispatch_pipe[0], &job->descr, sizeof(job->descr)) != sizeof(job->descr)) {
job->errno = errno ? errno : EINVAL;
_exit(1);
}
job->descr->pid = getpid(); // Сохраняем pid, где поток в родительском процессе может видеть его
if(net_write(me->ready_pipe[1], "", sizeof("")) != sizeof("")) {
job->errno = errno;
_exit(1);
}
//Выполняем задачу
_exit(job->descr->start_routine(job->descr->arg));
} while(!avfork_taskq->terminated); // Возможно, это устанавливается через atexit()
return NULL;
}
pid_t
avfork(int (*start_routine)(void *), void *arg)
{
static pthread_once_t once = PTHREAD_ONCE_INIT;
struct worker_s *worker;
struct job_descr_s job;
struct job_descr_s *jobp = &job;
char c;
// avfork_taskq_init() здесь опущен, но можно представить, как он выглядит. Он может вырасти до N рабочих потоков, и после этого, если нет доступных воркеров, тогда taskq.get_worker() блокируется в pthread_cond_wait(), пока воркер не будет готов.
pthread_once(&once, avfork_taskq_init);
// Описание задачи
memset(&job, 0, sizeof(job));
job.start_routine = start_routine;
job.arg = arg;
worker = avfork_taskq_get_worker(avfork_taskq); // Без блокировки, когда это возможно; при необходимости запускает воркера
// Отправляем воркеру нашу задачу. Если нам повезет, мы только ждем, пока pre-vfork() ребенок прочитает нашу задачу и укажет на готовность. Если нам не повезло, то наш воркер занят обработкой vfork(). Однако рабочие потоки на самом деле мало что делают, поэтому обычно нам должно везти.
//
// Размер taskq должен быть таким, чтобы не было слишком много конфликтов для воркеров, и динамически расти, чтобы изначально не было воркеров.
// Возможно, он может неограниченно расти, когда спрос велик, а затем сокращаться, когда спрос низкий (смотрите worker_start_routine()).
if (net_write(worker->dispatch_pipe[1], &jobp, sizeof(jobp)) != sizeof(jobp) ||
net_read(worker->ready_pipe[0], &c, sizeof(c)) != sizeof(c))
job.errno = errno ? errno : EINVAL;
// Очистка
(void) close(worker->dispatch_pipe[0]);
(void) close(worker->dispatch_pipe[1]);
(void) close(worker->ready_pipe[0]);
(void) close(worker->ready_pipe[1]);
if (job.errno)
return -1;
return job.pid; // когда чтение заканчивается, PID находится в pid
}
В заголовке также говорится, что clone(2) - это глупо. Позвольте мне объяснить это. clone (2) изначально был добавлен как альтернатива полноценным потокам POSIX, которые можно было использовать для реализации потоков POSIX. Идея заключалась в том, что было бы неплохо иметь множество вариаций fork()
, и, как мы видим здесь, это действительно так, что касается avfork()
! Однако avfork()
не был изначальной мотивацией. На пути к NPTL было сделано много ошибок.
В Linux должен был быть системный вызов создания потока - тогда он избавил бы себя от боли, связанной с первой реализацией pthread
для Linux. Linux следовало извлечь уроки из Solaris/SVR4, где эмуляция сокетов BSD через libsocket поверх STREAMS оказалась очень долгой и дорогостоящей ошибкой. Эмулировать один API из другого API с несоответствием импеданса в лучшем случае сложно.
С тех пор clone()
превратился в швейцарский армейский нож - он эволюционировал, чтобы иметь функции входа в зоны/jail’ы, но только своего рода: в Linux нет полноценных зон/jail’ов, вместо этого добавляются новые пространства имен и новые флаги clone(2)
, которые идут с ними. И поскольку новые связанные с контейнером флаги clone(2)
добавляются, старый код может захотеть их использовать... только придется изменить и перестроить рутину вызовов clone(2)
, что явно не элегантно.
В Linux должны быть первоклассные системные вызовы fork()
, vfork()
, avfork()
, thread_create()
и container_create()
. Семейство форков могло бы быть одним системным вызовом с параметрами, но потоки не являются процессами и не являются контейнерами (хотя контейнеры могут иметь процессы и могут иметь процесс minder/init). Объединение всего этого в один системный вызов кажется немного сложным, хотя даже это было бы нормально, если бы был только один флаг для записи контейнера/запуска/форка/любой метафоры, применяемой к контейнерам. Но дизайн clone(2)
или его разработчики поощряют распространение флагов, что означает, что нужно постоянно обращать внимание на возможную необходимость добавления новых флагов в существующие места вызовов.
Мои друзья часто говорят мне, и я много где это встречаю, что «нет, контейнеры - это не зоны/jail’ы, они не предназначены для такого использования», но меня не волнуют эти аргументы. Миру нужны зоны/jail’ы, а контейнеры Linux действительно хотят быть зонами/jail’и. Да, хотят. Зоны/jail’ы должны начинать жизнь максимально изолированными, а совместное использование нужно добавлять явно с хоста. Делать это наоборот - плохо, потому что каждый раз, когда изоляция увеличивается, приходится патчить clone(2) вызовы. Это не лучший подход к безопасности для ОС, которая не интегрирована по принципу «сверху вниз» (в Linux у всего есть разные мейнтейнеры и сообщества: ядро, библиотеки C, каждая важная системная библиотека, shell’ы, система инициализации, все пользовательские программы - у всего). В таком мире контейнеры нужно начинать с максимальной изоляции.
Я мог бы продолжать. Я мог бы поговорить о безопасности форка. Я мог бы обсудить все функции, которые обычно или в определенных случаях безопасны для вызова в fork()
, в сравнении с дочерним процессом vfork()
, в сравнении с дочерним процессом afork()
(если он у нас есть) или дочерним процессом вызова clone()
(но мне пришлось бы рассмотреть довольно много комбинаций флагов!). Я мог бы рассказать, почему 4.4BSD удалил vfork()
(хотя мне пришлось бы немного углубиться в тонкости). Я думаю, что длина этой статьи, вероятно, уже достигла оптимума, поэтому здесь я и остановлюсь.
Перевод материала подготовлен в преддверии старта курса «Программист С».
staticmain
Очень кривой гугл-транслейт. Тяжело читать. Тот кто переводил не знает предметную область.