...или о fork() в двух словах.
Как люди решают задачи
Обычно у каждой задачи есть одно простое решение, которое воспринимается всеми как правильное. Люди воспринимают такое решение правильным либо исходя из личного опыта¹; исходя из опыта других людей² или просто не задумываясь о правильности³. И самое удивительное, что мир не взорвался, никто (массово) от этого не умер, код работает и приносит деньги.
¹ "всегда так пишу код, никто не умер"
² "копирую код из stack overflow который набрал больше всех плюсов"
³ "копирую первый попавшийся код из stack overflow"
Однако самый простой - не всегда самый правильный. Да, можно скопировать самый заплюсованный ответ и сказать, что миллионы не могут ошибаться, но ведь миллионы людей делают прививки и миллионы людей не делают прививки и одна из групп, исходя из простейшей логики, совершила ошибку.
Однако мы отвлеклись. Поставим перед собой задачу:
Нам необходимо наиболее правильным способом запустить из своего кода другую программу.
Не так важно, зачем. Это может быть запуск игры из лаунчера, запуск утилиты ping чтобы не реализовывать отправку ICMP-пакетов самостоятельно, запуск программы по клику на ярлык, миллион вариантов, думаю, что вы сами хотя бы раз в жизни сталкивались с такой задачей.
Кстати, познакомьтесь, это Картошка. Она будет нам помогать учиться пользоваться вилкой:
Содержание статьи:
Как кушать пингвина вилкой?
Общие знания о запуске процессов под LINUX-системамиКак кушать корову если есть вилка?
Copy-on-write, что это и зачем? vfork и почему он не лучшеКак кушать икру?
posix_spawn и почему он не замещает fork()Как кушают клоны?
clone() под капотом у fork()Почему когда ешь суп вилкой, он утекает?
Утечка дескрипторов после fork() и как этого избежатьПочему у вилки три зуба?
Важность обработки всех вариантов возврата fork()Как кушать демонов вилкой?
Запуск демонизирующихся процессов при помощи fork()Как наложить вилкой в другую тарелку?
Переназначение дескрипторов вывода для нового процессаКак сигналить вилке?
Взаимоотношения обработки сигналов и fork()Как пользоваться вилкой когда сломалась ручка?
Самоликвидация дочернего процесса после завершения материнскогоКак подготовиться к использованию вилки?
Сценарии использования pthread_atfork()Как поцарапать окно вилкой?
Запуск дочернего процесса под Windows-системойКак систематически пользоваться вилкой?
Почему вам не стоит пользоваться system()Заключение.
Благодарности и выводы
Самое простое решение
Войдем в hivemind и зададим вопрос "как запустить программу из своей программы?". И, о чудо, мы сразу же видим ответ (с наибольшим количеством положительных оценок):
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h> /* for fork */
#include <sys/types.h> /* for pid_t */
#include <sys/wait.h> /* for wait */
int startup()
{
/*Spawn a child to run the program.*/
pid_t pid=fork();
if (pid==0) { /* child process */
static char *argv[]={"echo","Foo is my name.",NULL};
execv("/bin/echo",argv);
exit(127); /* only if execv fails */
}
else { /* pid!=0; parent process */
waitpid(pid,0,0); /* wait for child to exit */
}
return 0;
}
Ну, или если вы в отличие от меня используете самую распространенную пользовательскую операционную систему, то так (нет, ну вы, конечно, можете использовать и первый вариант, но только под специфичным окружением (вроде cygwin или WSL) и "под капотом" всё равно будет вот такой код):
#include <Windows.h>
void startup(LPCSTR lpApplicationName)
{
// additional information
STARTUPINFOA si;
PROCESS_INFORMATION pi;
// set the size of the structures
ZeroMemory(&si, sizeof(si));
si.cb = sizeof(si);
ZeroMemory(&pi, sizeof(pi));
// start the program up
CreateProcessA
(
lpApplicationName, // the path
argv[1], // Command line
NULL, // Process handle not inheritable
NULL, // Thread handle not inheritable
FALSE, // Set handle inheritance to FALSE
CREATE_NEW_CONSOLE, // Opens file in a separate console
NULL, // Use parent's environment block
NULL, // Use parent's starting directory
&si, // Pointer to STARTUPINFO structure
&pi // Pointer to PROCESS_INFORMATION structure
);
// Close process and thread handles.
CloseHandle(pi.hProcess);
CloseHandle(pi.hThread);
}
Выглядит просто, оба варианта являются ответами с наибольшим количеством плюсов.
Вариантом для людей которые идут против течения (или выбирают ответ наугад) будет
int result = system("C:\\Program Files\\Program.exe");
...что верно и допустимо для обоих систем¹.
¹Опустим вопрос направления слешей.
Но что, если я скажу вам, что все эти решения не являются абсолютно верными?
Как кушать пингвина вилкой?
Для начала разберем решение для Linux систем. Если целиком убрать все синтаксические костыли, то оно сводится к следующему:
Создать копию текущего процесса (fork).
Заместить копию новой программой (exec).
"Создать копию текущего процесса" звучит максимально странно, как если бы чтобы нарисовать вторую половинку вилки мы бы отзеркалили первую, а потом стерли её и на её месте нарисовали ручку.
The child process is created with a single thread—the one that
called fork(). The entire virtual address space of the parent is
replicated in the child, including the states of mutexes, condition
variables, and other pthreads objects; the use of pthread_atfork(3)
may be helpful for dealing with problems that this can cause.
(man 2 fork)
Давайте тогда разберемся, почему мы копируем текущий процесс вместо того чтобы просто сказать ядру "эй ты, запусти еще один процесс и дай мне его ID", ведь так было бы намного "дешевле", чем копировать огромные объемы памяти текущего процесса для того, чтобы запустить что-то маленькое. Ответ на самом деле очень прост:
Исторически (до fork()) для запуска новой программы оболочка открывала необходимые дескрипторы для ввода\вывода, загружала подпрограмму-загрузчик и запускала программу, которая замещала процесс оболочки. После получения команды exit() нужно было восстановить предыдущее состояние и вернуться к шагу, с которого мы начали.
Когда понадобилось запускать программы в новых процессах, придумали fork(), который позволял повторно использовать уже имевшийся код замещения процесса (то, что позже стало exec()). Это было проще и обошлось всего в 27 строк ассемблерного кода. Поэтому fork() копирует (см. главу Как кушать корову если есть вилка?) память родитеского процесса и не открывает новых файлов¹.
¹А так как в UNIX "всё есть файл" то это также касается "файлов", которые используются процессом для стандартного ввода/вывода.
Почему тогда не создать системный вызов, который будет принимать на вход список переменных окружения и файлов, которые необходимо передать дочернему процессу, список сигналов, которые будет обрабатывать процесс, идентификатор процесса с битом subreaper, к которому будет привязан процесс, права доступа, флаги приоритетов, текущую директорию, еще десяток "ну точно нужных аргументов, которые никогда не будут NULL"?
Дело в том, что такой вызов уже создали.
Под Windows системами функция CreateProcess принимает 9 входных параметров, из которых 4 - это структуры с заполняемыми полями, 1 список строк и 1 аргумент, который представляет из себя битовую маску. И знаете, что? Этого оказалось недостаточно и чуть позже в статье я расскажу, почему.
В Linux (а точнее в UNIX) такой вызов тоже есть и мы его рассмотрим, но для пользователя сделали функцию максимально простой, чтобы другими функциями выставлять только необходимые значения, а не передавать около сотни параметров в надежде что через 10-20-30-40 лет никто не изобретет новую сущность, настройку безопасности которой кровь из носу нужно добавить в этот вызов.
Но, хотя это и звучит максимально просто, со временем даже в такой простой механизм пришлось добавить много дополнений и "сносок".
Как кушать корову если есть вилка?
CoW (или Copy-on-Write) - это первый механизм, который был добавлен к вызову fork(). Его суть такова, что до тех пор, пока данные не изменены (write) они не будут скопированы (copy). Если над данными проводятся только процедуры чтения, то чаще всего копия данных создаваться не будет.
Конечно, возможна ситуация, когда родительский процесс сам захочет изменить состояние памяти, которая была (не-)скопирована дочерним процессом. В таком случае дочерний процесс будет по-прежнему обращаться к странице памяти, которая была ему предоставлена, а новое значение (которое родительский процесс хочет записать) будет записано в новую страницу памяти благодаря сработавшему отказу страницы (см. первый вариант срабатывания в "легком" отказе):
Отказ происходит в следующих случаях:
* страница присутствует в памяти, но включена в рабочее множество другого процесса (например, если несколько процессов взаимодействуют через разделяемую память).
Зачем нужно было добавлять такой механизм? Ответ прост до банальности - когда создавался fork() оперативная память измерялась в килобайтах, а скопировать несколько килобайт памяти - (достаточно) быстрый процесс, даже если вы в 1969-м. В современном мире приложения иногда оперируют сотнями гигабайт и, если бы мы продолжали копировать всю занимаемую память, это занимало бы несколько секунд, даже если бы мы использовали самое современное оборудование:
Стандарт |
Частота (MHz) |
Скорость передачи (GB/s)¹ |
Сколько будем копировать 150 GB |
---|---|---|---|
DDR4 |
2133 |
17 |
8.8 c |
2400 |
19.2 |
7.8 c |
|
2666 |
21.3 |
7.0 c |
|
3000 |
25.6 |
5.8 c |
|
DDR5 |
51.2 |
2.9 c |
¹ При условии что вы там запущены как единственное приложение, система ничего не копирует, а производитель заявил честную скорость.
Для решения этой проблемы была создана другая функция - vfork(). При вызове vfork вызывающая нить замораживается, покуда дочерний "процесс" не вызовет execve, _exit или не упадет, убитый защитой доступа памяти. Кроме того, для ускорения вызова память родителя не копируется, а используется как есть - вместе с кучей и стеком. В своё время это вызвало разлад в BSD сообществе, часть разработчиков считала vfork архитектурным провалом, часть - единственной возможностью адекватно пускать приложения без задержки в несколько секунд.
Но после введения CoW выигрыш от vfork стал настолько незначительным, что в современности эта функция практически не используется. Исключения - платформы, на которых CoW не реализован из-за технических ограничений (например MIPS CPU без MMU).
Например, Java 7 использовала vfork(), потому что при копировании адресных таблиц и виртуальной памяти при включенном флаге overcommit = 2 приложение могло свалиться с Out-of-Memory, поскольку в этом режиме размер виртуальной памяти не может превысить размер физической.
Сегодня Java использует posix_spawn(), про который мы еще поговорим.
Как кушать икру?
Чуть выше я упомянул функцию posix_spawn
, давайте рассмотрим её немного подробнее.
int posix_spawn(pid_t *pid, const char *path,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]);
int posix_spawnp(pid_t *pid, const char *file,
const posix_spawn_file_actions_t *file_actions,
const posix_spawnattr_t *attrp,
char *const argv[], char *const envp[]);
Сама функция создавалась для того, чтобы системы без MMU могли запускать процессы привычным им способом. Прямой запуск fork() + exec() там обычно невозможен из-за уже упомянутых проблем с отсутствием CoW, поэтому, так как программы на C должны работать на всех доступных платформах, было необходимо решить эту проблему.
Первоначально glibc (самый популярный рантайм C) версии 2.4 реализовывал posix_spawn как обычный fork() + exec(), имея, таким образом, решение "для галочки". Затем, для того, чтобы не ломать совместимость со старыми версиями, был добавлен GNU-специфичный флаг POSIX_SPAWN_USEVFORK, который форсирует использование vfork внутри этой функции (есть еще несколько условий, но мы их рассматривать не будем).
После версии glibc 2.24 posix_spawn использует clone с флагом CLONE_VFORK
+ exec всегда, когда есть такая возможность. Также posix_spawn в новых версиях (в отличие от прямого вызова vfork) использует раздельный стек для дочернего процесса, во избежание повреждения родительского стека при манипуляциях с дочерним процессом. Дополнительно происходит блокировка сигналов, которую мы рассмотрим в главе "как сигналить вилке?".
Как кушают клоны?
Однако все эти функции, которые мы обсудили (fork, vfork и posix_spawn) являются всего лишь оберткой над одним системным вызовом, который называется clone
.
int clone(
int (*fn)(void *),
void *stack,
int flags,
void *arg,
...
// pid_t *parent_tid,
// void * tls,
// pid_t *child_tid
);
Это, конечно, не миллион аргументов как в CreateProcess, но тоже немало. Примитивный вызов clone будет выглядеть примерно вот так (обратите внимание, что здесь и далее автор умышленно оставляет обработку ошибок за скобками):
char * stack = mmap(
NULL, // Стартовый адрес (не используется)
STACK_SIZE, // Размер стека
PROT_READ | PROT_WRITE, // Права на запись и чтение страниц
MAP_PRIVATE | MAP_ANONYMOUS | MAP_STACK,
// Приватная память, без отображения
// на файл, аллоцировать память в области стека
-1, // Не используется отображение на файл - нет идентификатора
0 // Не инициализируем память, не нужен сдвиг инициализации
);
char * stacktop = stack + STACK_SIZE;
// Так как стек растет вниз (обычно),
// установим голову стека в конце выделенной памяти
int flags = CLONE_FS; // Копируем информацию о подлежащей файловой системе
clone(
func, // Точка входа в новый процесс
stacktop, // Вершина стека
flags, // Флаги
NULL // Ничего не передаем дочернему процессу
);
Передача аргументов дочернему процессу работает через void * ptr. Примерно как в pthread_create. Да, собственно pthread_create именно так и работает:
#include <pthread.h>
void * _thread(void * ptr) {
int * pint = ptr;
*pint = 5;
pthread_exit(NULL);
}
int main(void) {
int val = 4;
pthread_t thread;
pthread_create(&thread, NULL, _thread, &val);
pthread_join(thread, NULL);
return val;
}
$ gcc pthread.c -o pthread.elf -lpthread && strace ./pthread.elf 2>&1 | grep clone
clone(child_stack=0x7ff8905a6fb0, flags=CLONE_VM|CLONE_FS|CLONE_FILES|CLONE_SIGHAND|CLONE_THREAD|CLONE_SYSVSEM|CLONE_SETTLS|CLONE_PARENT_SETTID|CLONE_CHILD_CLEARTID, parent_tidptr=0x7ff8905a79d0, tls=0x7ff8905a7700, child_tidptr=0x7ff8905a79d0) = 7418
Как итог можно сказать, что clone()
, являясь самым общим вызовом, позволяет очень тонко настроить все аттрибуты создаваемого субпроцесса - общую память, дескрипторы и даже адресное пространство. Конечно, вам скорее всего, не будет нужно вызывать его самостоятельно, однако на ограниченных платформах, где, например, нет уже упомянутого CoW, даже vfork() может вам не подойти, являясь, по сути, преднастроенным clone().
Почему когда ешь суп вилкой, он утекает?
А теперь, наконец, поговорим о том, как правильно пользоваться fork. Всё это время мы рассуждали зачем он нужен, да как работает, и теперь пора узнать, что обычно идет не так если копировать код бездумно.
Представим следующую программу:
// prog1.c
#include <sys/types.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int f = open("/etc/passwd", O_RDONLY);
switch (fork()) {
case -1: _exit(EXIT_FAILURE);
case 0 : {
static char * argv[] = { "./prog2.elf", NULL };
execv("./prog2.elf", argv);
exit(127);
}
default:
close(f);
wait(NULL);
}
return EXIT_SUCCESS;
}
// prog2.c
#include <unistd.h>
#include <stdio.h>
#include <stdlib.h>
int main(void) {
int fd = 3;
char buff[128] = { 0 };
read(fd, buff, sizeof(buff) - 1);
puts(buff);
return EXIT_SUCCESS;
}
$ ./prog1.elf
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:
Произошло следующее:
Родительский процесс открыл файл и получил его дескриптор. Так как дескрипторы раздаются по принципу "первый незанятый номер", то его значение 3. (Помним, что 0..2 заняты stdin/out/err)
Дочерний процесс получил копию всех открытых дескрипторов родительского процесса.
Родительский процесс закрыл дескриптор.
Дочерний процесс обращается к (всё ещё открытому¹) дескриптору #3 и читает из него данные.
¹ Всё еще открыт дескриптор из-за невыставленного флага CLONE_FILES, переданного "под капотом" в функцию clone.
В данный момент неважно, сработал ли механизм CoW или нет, нам в любом случае дадут в личное пользование все дескрипторы родительского процесса.
Важно помнить, что в UNIX-like системах принцип "всё есть файл" выполняется почти всегда, следовательно:
Файловый дескрипторы (что очевидно) - файлы.
Устройства (что менее очевидно, но допустим) - тоже файлы.
Информация о ядре (?) - файлы.
Информация о процессах (???) - файлы.
Настройки ядра (????) - файлы.
И даже сетевые сокеты (!!!) - тоже файлы.
Таким образом, что бы ни использовал родительский процесс и как бы ни пытался закрыть дескрипторы после вызова fork - дочерний процесс получит полный контроль над уже открытыми дескрипторами. А вы, ведь, не хотите, чтобы вызвав "зараженный" ping в торговом приложении, оно начало рассылать по сети враждебные команды или пытаться строить из себя MitM? Или перебрав список открытых дескрипторов прочло содержимое открытых файлов. Или не дало вам в следующий раз запустить сервер на выбранном порту?
Испугались? Это хорошо. А теперь научимся защищать дескрипторы. Даже если мы выставим флаг CLONE_FILES, максимум, чего мы можем добиться - избавиться от полного доступа и устроить гонку (если родитель не успел закрыть файл до того, как дочерний процесс его прочитал - доступ всё равно будет получен).
Для полного решения проблемы файлу (сокету/дескриптору) можно выставить флаг FD_CLOEXEC, который принудительно закроет данный файл в пространстве склонированного процесса при замещении процесса во время выполнения функции exec():
man fcntl
If the FD_CLOEXEC bit is
set, the file descriptor will automatically be closed during a
successful execve(2). (If the execve(2) fails, the file
descriptor is left open.) If the FD_CLOEXEC bit is not set,
the file descriptor will remain open across an execve(2).
Модифицируем prog1, добавив защиту:
int f = open("/etc/passwd", O_RDONLY);
fcntl(f, F_SETFD, FD_CLOEXEC);
Если у вас есть доступ к функциям open или socket (иными словами, если эти вызовы делаете вы, а не библиотека), то вы можете сразу выставлять этот флаг через передачу флагов O_CLOEXEC и SOCK_CLOEXEC соответственно).
Вот, собственно, спустя 10 минут чтения статьи, первый совет:
Всегда защищайте файловые дескрипторы флагом FD_CLOEXEC, если планируете вызывать fork+execv для программ, которые не должны их видеть.
Почему у вилки три зуба?
В самом начале статьи я привел следующий код:
pid_t pid=fork();
if (pid==0) { /* child process */
static char *argv[]={"echo","Foo is my name.",NULL};
execv("/bin/echo",argv);
exit(127); /* only if execv fails */
}
else { /* pid!=0; parent process */
waitpid(pid,0,0); /* wait for child to exit */
}
И вот вам еще один нюанс: у этой "вилки" должно быть три зуба:
RETURN VALUE
On success, the PID of the child process is returned in the parent, and
0 is returned in the child. On failure, -1 is returned in the parent,
no child process is created, and errno is set appropriately.
Таким образом любой код, который использует конструкцию "если ==0 ... иначе" не просто неправилен с точки зрения мануала (например как если бы вы копировали пересекающиеся области памяти при помощи memcpy), но еще и может навредить всем окружающим его процессам.
Представим следующую ситуацию: по какой-либо причине (например нехватка дескрипторов или памяти) fork завершился неудачно, вернув вам -1. В случае, если вы передадите такой "дескриптор" в waitpid это будет означать "ждать любой дочерний процесс" (что уже поломает логику работы программы), но что еще хуже, если вы, вдруг, захотите досрочно завершить свой дочерний процесс при помощи kill, то...
If pid equals -1, then sig is sent to every process for which the call‐
ing process has permission to send signals, except for process 1
(init), but see below.
...то вы пошлете KILL/TERM/QUIT/Какой-вы-там-хотели сигнал ВСЕМ процессам, которые запущены с теми же правами доступа. А под linux-системами будете как горец, один стоять и окровавленным мечом размахивать:
POSIX.1 requires that kill(-1,sig) send sig to all processes that the
calling process may send signals to, except possibly for some implemen‐
tation-defined system processes. Linux allows a process to signal
itself, but on Linux the call kill(-1,sig) does not signal the calling
process.
Поэтому совет номер два:
всегда обрабатывайте возможный возврат ошибки fork.
(если вы, конечно, не пишете извращенный OOM-killer, который убивает все процессы в случае нехватки памяти):
pid_t p;
switch (p = fork()) {
case -1 : // error, process it
break;
case 0: // child process
break;
default:
// parent process
}
Как кушать демонов вилкой?
Ядро Linux 3.4 принесло много хороших изменений: частичную поддержку архитектуры Kepler от NVIDIA, огромное количество улучшений поддержки BTRFS, и, в числе всего прочего, флаг PR_SET_CHILD_SUBREAPER. О нём сейчас и поговорим.
Предположим, что вы вызвали fork, в котором запустили другое приложение (к примеру вы реализуете супервизор, ну или в принципе хотите 100% дождаться выполнения подлежащего приложения). Иногда бывает так, что это подлежащее приложение может вызывать свои суб-утилиты. А иногда бывает так, что приложение, почувствовав, что его выпустили на волю решает запустить себя в качестве демона.
Такой запуск приводит к следующему: когда завершается "оболочка" приложения (запустив при этом "демона") ваш вызов waitpid завершается. При этом сам "демонизированный" процесс продолжает работать. А знаете, что ещё происходит, когда завершается "оболочка"? "Демонизированный" процесс меняет своего родителя. На init (pid 1). Это происходит, потому что этот процесс становится "сиротой" и, так как такого быть не должно, его принудительно "удочеряет" init.
Как в таком случае дождаться завершения такого процесса? У нас нет ни его PID, ни каких-либо других улик, говорящим нам о том, что там что-то выполняется. Я встречался с банковской системой, которая при вызове утилиты для проведения оплаты через терминал вызывала такой процесс для обработки платежа, а вызываемое пользователем приложение спокойно завершала. Как же в таком случае понять, когда транзакция успешно завершилась?
Для этого и нужен флаг PR_SET_CHILD_SUBREAPER. Устанавливается он довольно просто:
if (prctl(PR_SET_CHILD_SUBREAPER, 1lu))
_report_error(errno);
Обратите внимание на приведение типа. В мануале чётко сказано, что функция объявлена как
int prctl(int option, unsigned long arg2, unsigned long arg3,
unsigned long arg4, unsigned long arg5);
Однако на некоторых имплементациях это
int prctl(int option, ...);
Теперь, если какое-либо подлежащее приложение создаст демона, завершение которого вам нужно отследить, вы всегда можете, установив этот флаг и использовав wait()/waitpid(-1, ...) определить, что он завершен.
Обратите внимание, что wait()/waitpid(-1, ...) ожидает завершения любого дочернего процесса. Т.е. если вы оставите только один wait - демон всё еще будет работать, потому что wait сработает для его оболочки. Вам нужно вызвать wait несколько раз, ожидая, пока он не вернет -1 и не установит errno в ECHILD, что будет означать, что у процесса нет потомков, завершение которых можно ожидать.
Итак, совет #3:
Если вы хотите дождаться выполнения всех потомков, используйте PR_SET_CHILD_SUBREAPER.
Альтернативно вы можете использовать ptrace для отслеживания SYS_clone, но это замедлит выполнение приложения и даст ему возможность узнать, что его отслеживают (такое, например, не любят античит-системы и DRM).
Как наложить вилкой в другую тарелку?
Продолжая разговор о супервизорах. Достаточно часто перед программистом стоит задача переопределить вывод запускаемого приложения в файл. Поскольку, если этого не делать, то вывод двух приложений (родительского и дочернего) будет смешан. А если вывод осуществляется не построчно (сначала формируется вся строка лога, а затем отображается одним выводом write) а частями (например сначала дата\время, потом название функции, потом сообщение), то вывод будет перемешан даже в рамках одной строки. При этом stderr даже не буферизуется, поэтому там могут быть ещё более странные сюрпризы.
Поэтому, не оттягивая неизбежное, рассмотрим следующий код:
static bool _redirect_handler(const char * filename, int handle) {
static const mode_t filemode = S_IRUSR | S_IWUSR | S_IRGRP | S_IROTH;
static const int fileflags = O_APPEND | O_CREAT | O_WRONLY;
int fd = open(filename, fileflags, filemode);
if (fd == -1) {
fprintf(stderr, "Не удалось открыть файл \"%s\"\n", filename);
return false;
}
if (dup2(fd, handle) < 0) {
close(fd);
fprintf(stderr, "Не удалось переопределить дескриптор \"%d\"\n", handle);
return false;
}
close(fd);
return true;
}
int main(void) {
switch (fork()) {
case -1: _exit(EXIT_FAILURE);
case 0 : {
if (!_redirect_handler("/tmp/out.log", STDOUT_FILENO) ||
!_redirect_handler("/tmp/err.log", STDERR_FILENO)) {
fprintf(stderr, "Не удалось переопределить вывод потомка\n");
_exit(EXIT_FAILURE);
}
static char * argv[] = { "./prog2.elf", NULL };
execv("./prog2.elf", argv);
exit(127);
}
default:
wait(NULL);
}
return EXIT_SUCCESS;
}
Мы:
Создали новый дескриптор, указывающий на файл (open).
Переопределили дескриптор stdout файловым (dup2).
Закрыли более не нужную копию (close).
Повторили для stderr.
Если у вас появился вопрос, почему мы не делаем close(STDOUT_FILENO)+open, я отвечу, что мануал ответит лучше меня:
The steps of closing and reusing the file descriptor newfd are per‐
formed atomically. This is important, because trying to implement
equivalent functionality using close(2) and dup() would be subject to
race conditions, whereby newfd might be reused between the two steps.
Such reuse could happen because the main program is interrupted by a
signal handler that allocates a file descriptor, or because a parallel
thread allocates a file descriptor.
Совет #4:
Используйте переопределение дескрипторов для запускаемых процессов, если хотите сохранить вывод основного приложения в порядке.
Как сигналить вилке?
Если ваше приложение использует signal/sigaction и fork одновременно - то я готов поспорить, что вы попались в ловушку.
Дело в том, что forked-процесс наследует все ваши обработчики сигналов. При этом создаются следующие проблемы:
Сразу после вызова fork() к вам может прилететь сигнал, который может прилететь в оба процесса сразу, когда: сигнал был послан всей группе, а оба процесса будут в ней находиться или сигнал был послан всем находящимся в одной контрольной группе, а OOM или systemd послали сигнал всей группе. И если материнский процесс в таком случае адекватно отреагирует на сигнал (выставит флаги завершения, завершит работу), то дочерний процесс ничего этого не сделает, поскольку обработанные до замещения процесса сигналы не повлияют на работу замещенного процесса, который доступа к выставленным флагам уже не имеет.
В случае, если вы создаете какие-то побочные эффекты в обработчике сигнала они могут повлиять на выполнение дочернего процесса (например выведет что-то в лог, тем самым создав такой файл).
Распишем решение этой проблемы:
static __thread sigset_t g_sig_blocked;
/*! \brief Временно блокирует входящие сигналы для предотвращения их срабатывания
* в fork()-процессе до установки нормальных обработчиков
* \return Истина, если блокировка успешно завершилась */
bool _sigreset_block(void) {
sigset_t setnew;
sigfillset(&setnew);
sigemptyset(&g_sig_blocked);
return pthread_sigmask(SIG_SETMASK, &setnew, &g_sig_blocked) == 0;
}
/*! \brief Снимает блокировку сигналов, оставляя заблокированными сигналы, которые
* были заблокированы до парного вызова `_sigreset_block`
* \return Истина, если блокировка успешно снята */
bool _sigreset_unblock(void) {
return pthread_sigmask(SIG_SETMASK, &g_sig_blocked, NULL) == 0;
}
/*! \brief Устанавливает обработчики всех сигналов на обработчики по-умолчанию.
* После вызова fork() в дочернем процессе все сигналы выставлены как у родителя,
* что не является желанным поведением для дочернего процесса.
* \return Истина, если обработчики выставлены на обработчики по-умолчанию */
bool _sigreset_default(void) {
struct sigaction signal_act = {
.sa_handler = SIG_DFL
};
if (sigfillset(&signal_act.sa_mask) < 0)
return false;
for (i32 i = 1; i <= SIGRTMAX; i++)
if (sigismember(&signal_act.sa_mask, i) == 1)
sigaction(i, &signal_act, NULL);
return _sigreset_unblock();
}
/*! \brief Запускает процесс
* \param[in] program Имя программы
* \param[in] pargs Аргументы программы */
static int _execute_process( const char * program, char * pargs[_ARGS_COUNT]) {
if (!_sigreset_block()) {
_err("Не удалось подготовить блокировку обработки сигналов");
return -1;
}
int process = fork();
switch (process) {
case -1 :
_err("Ошибка запуска fork-процесса");
break;
case 0 :
/* Пока материнский процесс не получает информацию о статусе выполнения
* мы можем только принудительно завершить (_Exit) подлежащий процесс */
if (!_sigreset_default()) {
_err("Не удалось установить обработчики сигналов по-умолчанию");
_Exit(EXIT_FAILURE);
}
/* Обратите внимание, что мы не используем третью ветку,
поскольку мы возвращает pid */
execve(program, pargs, environ);
_err("Ошибка запуска программы \"%s\"", program);
_Exit(EXIT_FAILURE);
}
if (!_sigreset_unblock()) {
_err("Не удалось разблокировать обработчики сигналов");
/* Мы не просто не смогли сделать действие, но и обрушили
* workflow материнского процесса. Теперь единcтвенный способ
* выхода из приложения - exit, так как обработчики сигналов стерты */
exit(EXIT_FAILURE);
}
return process;
}
Мы заблокировали приход всех сигналов в главный процесс.
Вызвали fork() и установили обработчики по умолчанию для дочернего процесса.
Вернули сохраненные обработчики для материнского процесса.
В данном примере мы опустили защиту от одновременного доступа к g_sig_blocked, а ведь если одновременно стартуют два процесса - то произойдёт состояние гонки. Вам необходимо самостоятельно добавить защиту от этого.
Обратите внимание, что сигналы, которые успели прийти в приложение выстроятся в очередь пока вы не включите обработчики. При этом обычные сигналы придут в единственном экземпляре (для них не существует очереди), в отличие от сигналов реального времени, которые сохранят исходный порядок. Помните о технической возможности прихода другого сигнала, когда вы обрабатываете предыдущий. Это может привести к повторному входу в функцию (если вы используете один обработчик для разных сигналов), что может заблокировать например mutex доступа второй раз в рамках одной нити. Поэтому повторюсь, лучший вариант обработчика сигналов - это выставление одной переменной.
Совет #5:
Блокируйте приход сигналов перед вызовом fork() и выставляйте обработчики по умолчанию для дочернего процесса, иначе вы рискуете нарушить ход работы программы.
Как пользоваться вилкой когда сломалась ручка?
Чаще всего существование дочернего процесса не имеет смысла, когда родительский процесс совершил судоку.
Но как дочерний процесс должен понять, что наступила его очередь совершать хачапури?
Постоянно отслеживать parent pid?
А что, если родительский процесс завершился до того, как мы в первый раз запомнили его? Хорошо, если это pid 1, это мы проверить сможем, а вот если кто-то установил SUBREAPER-флаг на несколько уровней выше? Да и, к тому же, чаще всего дочерний процесс это не наша программа и мониторинг ppid мы добавить не можем.
Но не бойтесь, самураи. Время фудзиямы мы не пропустим благодаря функции prctl:
PR_SET_PDEATHSIG (since Linux 2.1.57)
Set the parent death signal of the calling process to arg2
(either a signal value in the range 1..maxsig, or 0 to clear).
This is the signal that the calling process will get when its
parent dies. This value is cleared for the child of a fork(2)
and (since Linux 2.4.36 / 2.6.23) when executing a set-user-ID
or set-group-ID binary, or a binary that has associated capabil‐
ities (see capabilities(7)). This value is preserved across
execve(2).
Warning: the "parent" in this case is considered to be the
thread that created this process. In other words, the signal
will be sent when that thread terminates (via, for example,
pthread_exit(3)), rather than after all of the threads in the
parent process terminate.
Обратите особенное внимание на последний абзац - посылка сигнала происходит не после завершения родительского процесса, а после завершения родительской (той, которая вызвала fork()) нити.
Перед вызовом дочернего процесса добавим следующий вызов:
if (prctl(PR_SET_PDEATHSIG, SIGKILL, 0, 0, 0) == -1) {
_err("Не удалось установить посыл сигнала после смерти родителя");
_Exit(EXIT_FAILURE);
}
Кроме того, нужно учесть возможность "гонки", когда родитель умер до того, как fork()-нутый процесс выставил PR_SET_DEATHSIG. Для предотвращения этого состояния необходимо запомнить pid родителя до fork(), после чего проверить его в дочернем процессе после выставления флага. И если он не совпадает - значит, вы выиграли в лотерею новых родителей.
В данном случае мы используем SIGKILL, но вы в праве установить посыл любого другого сигнала, если не хотите быстрой смерти процесса. Например, SIGTERM. Правда, в таком случае завершение процесса не гарантируется, так как обработчик сигналов дочернего приложения может игнорировать этот сигнал. Так что выбирайте - либо довериться процессу и дать ему сохранить данные, но слать SIGTERM, либо не доверять процессу и слать SIGKILL с шансом повредить данные при записи.
На самом деле это сложный вопрос, поскольку всё очень сильно зависит от того, что может случиться, если дочерний процесс будет неожиданно завершен. Не сохранятся настройки будильника? Не сохранится конфигурационный файл transmission? Вал станка будет продолжать раскручиваться до максимальных оборотов?
Совет #6
Устанавливайте посыл сигнала дочернему процессу, если не хотите, чтобы он работал после смерти родителя.
Как подготовиться к использованию вилки?
Предположим, что вы используете mutex для защиты доступа к какому-нибудь ресурсу в памяти (например, массиву). В таком случае после вызова функции fork() в дочернем процессе вы получите копию массива и mutex в том состоянии, в котором он был до дубликации памяти. Следовательно, это состояние уже некорректно (так как мы его не знаем) и все подобные мьютексы должны быть заново инициализированы. Но вписывать после каждого fork() список мьютексов со всего приложения было бы неразумно, особенно учитывая, что чаще всего они являются private-свойствами модулей. В таком случае нам поможет функция pthread_atfork:
int pthread_atfork(void (*prepare)(void), void (*parent)(void), void (*child)(void));
Она позволяет установить три обработчика для данной нити, каждый из которых будет вызван во время соответствующего ему события:
prepare - Во время вызова fork(), до создания нового процесса, со стороны родителя.
parent - Во время вызова fork(), после создания и инициализации нового процесса, со стороны родителя.
child - Во время вызова fork(), после создания и инициализации нового процесса, со стороны дочернего процесса.
По факту, это очень специфичная функция, которая была призвана решить проблему того, что дочерний процесс не может (согласно стандарту POSIX.1) вызывать не async-signal-safe функции до вызова exec, в то время как pthread_mutex_lock и pthread_mutex_unlock как раз-таки являются не async-signal-safe функциями.
С этой функцией может быть много проблем, так как вы не имеете возможности проверить, был ли этот mutex инициализирован или освобожден, доступен ли сам ресурс и так далее. Да, вы можете добавлять функции в LIFO-порядке последовательными вызовами pthread_atfork, но не имеете возможности их оттуда удалить. Моя рекомендация - не использовать в дочернем процессе эти ресурсы, чтобы в корне избежать проблем с синхронизацией. Однако ваша архитектура приложения может этого не позволить и тогда pthread_atfork будет вашей единственной возможностью адекватно переинициализировать блокировки.
Как поцарапать окно вилкой?
А что же Windows-системы? Да почти всё то же самое, за исключением сигналов (поскольку под Windows это сигналы Шрёдингера , они вроде есть, но их вроде нет):
/*! \brief Запускает процесс платформо-зависимым способом
* \param[in] program Имя программы
* \param[in] creationflags Настройки процессов
* \param[in] pargs Аргументы программы
* \return Информация о процессе */
PROCESS_INFORMATION _execute_process( const char * program, DWORD creationflags, char * pargs[_ARGS_COUNT]) {
/* https://www.linux.org.ru/forum/development/7216318
* Что делать, если на мингвине очень хочется форка?
* knkd(04.01.12 02:10:43)
* заплакать
* alex_custov(04.01.12 02:16:55) */
LPWSTR wprog = _stringa2w(program); // Просто переводит UTF-8 в WIDECHAR
LPWSTR wargs = _argsa2w (pargs);
SECURITY_ATTRIBUTES sa;
sa.nLength = sizeof(sa);
sa.lpSecurityDescriptor = NULL;
sa.bInheritHandle = TRUE;
PROCESS_INFORMATION pi = { 0 };
STARTUPINFO si = { 0 };
si.cb = sizeof(STARTUPINFO);
if (!CreateProcessW(
wprog,
wargs,
NULL,
NULL,
TRUE,
creationflags,
NULL,
NULL,
&si,
&pi
)) {
_err("Ошибка запуска fork-процесса");
memset(&process, 0, sizeof(PROCESS_INFORMATION));
}
free(wprog);
free(wargs);
return pi;
}
А теперь начнем наращивать "мясо":
Переопределяем вывод процесса в отдельные файлы (глава "Как наложить вилкой в другую тарелку?"):
/*! \brief Переопределяет конкретный идентификатор файлом
* \param[in] filename Имя файла или NULL
* \param[in] handle Идентификатор вывода (stdout/stderr)
* \return Истина, если идентификатор переопределен */
static bool _redirect_handler(const char * filename, HANDLE * handle) {
static const DWORD fileaccess = FILE_APPEND_DATA;
static const DWORD fileshare = FILE_SHARE_WRITE | FILE_SHARE_READ;
static const DWORD filedisp = OPEN_ALWAYS;
static const DWORD fileattr = FILE_ATTRIBUTE_NORMAL;
LPWSTR wfile = _stringa2w(filename);
HANDLE hfile = CreateFile(
wfile,
fileaccess,
fileshare,
NULL,
filedisp,
fileattr,
NULL
);
free(wfile);
if (hfile == INVALID_HANDLE_VALUE) {
_err("Не удалось открыть файл \"%s\"", filename);
return false;
}
*handle = hfile;
return true;
}
// До вызова CreateProcess:
if !_redirect_handler(filename_out, &si->hStdOutput) ||
!_redirect_handler(filename_err, &si->hStdError ) {
_err("Не удалось переопределить вывод");
}
Просим процесс завершиться вместе с родителем (глава "Как пользоваться вилкой когда сломалась ручка?"):
/*! \brief Экземпляр JOB для установки дочерним процессам */
static HANDLE gJob = 0;
/*! \brief Создание экземпляра JOB */
static void _job_create(void) {
JOBOBJECT_EXTENDED_LIMIT_INFORMATION jeli = { 0 };
gJob = CreateJobObject(NULL, NULL);
jeli.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
SetInformationJobObject(
gJob,
JobObjectExtendedLimitInformation,
&jeli,
sizeof(jeli)
);
}
// До вызова CreateProcess:
if (diewithparent) {
BOOL bIsProcessInJob;
if (IsProcessInJob(GetCurrentProcess(), NULL, &bIsProcessInJob) == 0)
return false;
creationflags = bIsProcessInJob ? CREATE_BREAKAWAY_FROM_JOB : 0;
static pthread_once_t ponce;
pthread_once(&ponce, _job_create);
}
// После вызова CreateProcess:
if (diewithparent) {
if (AssignProcessToJobObject(gJob, pi->hProcess) == 0)
// Всё наоборот в отличие от Linux
return ...; // Ошибка
}
Как систематически пользоваться вилкой?
Нам осталось рассмотреть только самый последний вариант, который также упоминался в самом начале - вызов system(). На первый взгляд можно подумать, что это идеальная для повседневного использования функция, если нам нужно в блокирующем режиме запустить программу и дождаться её выполнения. Да даже и не в блокирующем:
static void * _thread_func(void * data) {
g_result = system(program);
g_finished = true;
pthread_exit(NULL);
return NULL;
}
...
g_finished = false;
pthread_create(&thread, NULL, _thread_func, NULL);
while (!g_finished) {
// Делать действия
sleep(1);
}
Для лабораторной работы - пойдёт. Для чего-то большего - точно нет. Попробуем перечислить все недостатки system:
Блокирующий режим
Тут всё просто - system на самом деле - это связка fork() + exec() + wait(), мы даже можем написать свой примитивный system, который будет почти соответствовать системному:
int _system(const char * command) {
pid_t child = fork();
switch (child) {
case -1:
return -1;
case 0 :
execl("/bin/sh", "sh", "-c", command, (char *) NULL);
_exit(127);
default:
int status = 0;
while (waitpid(child, &status, 0) == -1) {
if (errno != EINTR) {
status = -1;
break;
}
}
return status;
}
}
Блокирование главной нити программы для выполнения какой-то операции очень плохая практика. Значит нам нужно создавать отдельную нить для выполнения там system(), контролировать возврат из нити, пробрасывать результат, делать busy wait, так как мы не знаем pid. Не проще ли использовать fork() самостоятельно?
Отсутствие Thread safety
Если вы запустите man system
на большинстве современных Linux-систем, вы увидите следующую картину:
┌──────────┬───────────────┬─────────┐
│Interface │ Attribute │ Value │
├──────────┼───────────────┼─────────┤
│system() │ Thread safety │ MT-Safe │
└──────────┴───────────────┴─────────┘
Если что - это наглая ложь, поскольку имплементация system использует функцию sigaction
, которая меняет обработчики сигналов для ВСЕХ нитей сразу. Причины этого поведения мы объясним чуть позже, а пока заметим, что в других системах функция описана по-другому:
BSD:
STANDARDS
The system() function conforms to ISO/IEC 9899:1990 ("ISO C90") and is
expected to be IEEE Std 1003.2 ("POSIX.2") compatible.
Заявлена как соответствующая POSIX, который вообще никак не утверждает её потокобезопасность.
+-----------------------------+-----------------------------+
| ATTRIBUTE TYPE | ATTRIBUTE VALUE |
+-----------------------------+-----------------------------+
| Interface Stability | Standard |
+-----------------------------+-----------------------------+
| MT-Level | Unsafe |
+-----------------------------+-----------------------------+
В чем заключается потоконебезопасность? Во всех имплементациях, что мне удалось найти - для одновременных вызовов system защита присутствует. Но вот если одновременно с этим пользователь будет сам манипулировать обработчиками SIGINT, SIGCHLD или SIGQUIT - то наступит хаос и разруха в клозетах, не делайте так:
The system() function manipulates the signal handlers for SIGINT,
SIGQUIT, and SIGCHLD. It is therefore not safe to call system() in a
multithreaded process, since some other thread that manipulates these
signal handlers and a thread that concurrently calls system() can in-
terfere with each other in a destructive manner. If, however, no such
other thread is active, system() can safely be called concurrently from
multiple threads. See popen(3C) for an alternative to system() that is
thread-safe.
Обработчики SIGQUIT и SIGINT
Давайте создадим простое приложение и запустим его в терминале:
#include <stdlib.h>
#include <signal.h>
#include <stdio.h>
void sig_handler(int signum){
// Устаревшая функция, но для демонстрации пойдёт
printf("Пришёл сигнал %d\n", signum);
exit(0);
}
int main(void) {
signal(SIGINT, sig_handler);
system("sleep 10");
printf("Завершилось потому что завершилась программа\n");
return 0;
}
$ gcc sigdemo.c -o sigdemo.elf
$ ./sigdemo.elf
^CЗавершилось потому что завершилась программа
Мы послали сигнал SIGINT, вот только материнская программа его не получила. Потому что из-за переопределения обработчиков сигналов его получило и обработало дочернее приложение. Мы могли бы обрабатывать случай выхода по сигналу у дочернего приложения, но это уже как-то не клеится с концепцией "Run & Go":
int ret = system("foo");
if (WIFSIGNALED(ret) &&
(WTERMSIG(ret) == SIGINT || WTERMSIG(ret) == SIGQUIT))
break;
Кроме того о таком поведении в мануалах написано, а вот как его обрабатывать указали разве что в glibc. В любом случае простой вызов system() внезапно усложнился проверками, отсутствие которых может оставить материнскую программу в бесконечном цикле.
Привязка к shell
Достаточно забавным фактом является то, что system не вызывает напрямую переданную команду. Он не может этого сделать без парсинга аргументов (ведь входящий аргумент - единичная const char *
). Парсинг - штука сложная и дорогая, поэтому разработчики решили упросить себе жизнь и вместо этого перекладывают эту ответственность на сторону shell:
34 #define SHELL_PATH "/bin/sh" /* Path of the shell. */
35 #define SHELL_NAME "sh" /* Name to give it. */
...
147 ret = __posix_spawn (&pid, SHELL_PATH, 0, &spawn_attr,
148 (char *const[]){ (char *) SHELL_NAME,
149 (char *) "-c",
150 (char *) line, NULL },
151 __environ);
Какие ограничения это накладывает:
Система должна хотя бы частично соответствовать POSIX-стандартам, как минимум иметь
/bin/sh
. Обычно это так и есть, но приложения в контейнерах могут и не иметь полного набора всех утилит. Поэтому если ваш контейнер не имеет shell по адресу/bin/sh
- программы использующие system() нормально работать не будут.Экранирование и скобки становятся заботой пользователя. Если вам нужно передать несколько аргументов которые могут содержать пробелы - вам нужно экранировать пробелы вручную через добавление
\␣
или через скобки, которые скорее всего тоже нужно будет экранировать, если это будут двойные скобки.Раскрытие shell-переменных. Иногда это хорошо, но иногда может помешать вам передать в программу последовательность символов, которую shell попробует развернуть. Например, вы хотите передать в программу "$100":
#include <stdlib.h>
int main(void) {
system("echo $100");
return 0;
}
$ ./sigdemo.elf
<пусто>
Этого можно избежать, если экранировать аргумент $100
с помощью одинарных кавычек (переменные в одинарных кавычках не раскрываются shell) или экранировав символ $
, чтобы избежать раскрытия аргументов.
Это даже не всегда будет sh-shell. Это под NIX-системами там будет
/bin/sh
(dash/bash/zsh). Под MS-DOS это будетCOMMAND.COM
, под MSVC -cmd.exe
. Все они имеют разный синтаксис переменных, так что написать кроссплатформенную программу которая будет передавать переменные в shell просто так не получится.
Поэтому, по итогам главы, совет #7:
Не используйте
system()
для чего-то сложнее демонстрации.
Заключение
Надеюсь что этот гайд-справочник пригодится вам при написании ваших программ и что вы теперь станете меньше верить ответам со stackoverflow с наивысшим рейтингом.
Особая благодарность в подготовке статьи следующим людям:
Комментарии (13)
oleg-m1973
14.02.2022 22:13-2А зачем вообще нужно делать fork? Вроде большинство задач неплохо решаются в потоках
staticmain Автор
14.02.2022 22:34Fork() предназначен не для решения задачи как в потоке. Он решает концептуально другую задачу — запускает код другой программы из вашего приложения.
Нам необходимо наиболее правильным способом запустить из своего кода другую программу.
Не так важно, зачем. Это может быть запуск игры из лаунчера, запуск утилиты ping чтобы не реализовывать отправку ICMP-пакетов самостоятельно, запуск программы по клику на ярлык, миллион вариантов, думаю, что вы сами хотя бы раз в жизни сталкивались с такой задачей.oleg-m1973
14.02.2022 22:42-2Вообще-то важно. И цитата вообще ни об чём. Зачем запускать код другой программы из вашего приложения? И причём тут fork?
staticmain Автор
14.02.2022 22:52+5Зачем запускать код другой программы из вашего приложения?
Редкость, когда одна программа не запускает другую. Файловый менеджер при клике на файл открывает приложение, ассоциированное с этим форматом — это запуск процесса из процесса. Файловый менеджер был запущен менеджером рабочего стола после клика на ярлык на рабочем столе — это запуск процесса из процесса. Лаунчер игры запускает игру — процесс из процесса.
Без запуска приложения из приложения у нас было бы одно суперприложение (от одного разработчика) которое делает вообще всё — от запуска музыки и заканчивая cron-заданиями (cron, кстати тоже запускает чужие программы, запуская процесс-из-процесса). Правда запустить это приложение без fork() init (pid 1) тоже не сможет.И причём тут fork?
Под NIX-системами fork — это (почти, см. статью) единственный способ порождения нового процесса.
ivanych
15.02.2022 10:36-3Насколько хорошая статья технически, настолько же мерзкая стилистически. Сюсюканья и шутеечки про "кушать" и прочие вилки - просто кровь из глаз.
Ostrie_Brevna
15.02.2022 10:42+4Тёплый ламповый чистый C (и неплохой "пласт" знаний о system level, что полезно). Плюсую и призываю писать ещё :)
mezantrop
15.02.2022 17:40-1Отлично просто, но от обилия несколько лакейского слова "кушать" передергивает. Впрочем, это ИМХО. В остальном лайк.
zzzzzzzzzzzz
16.02.2022 20:58Вот начинаешься такого на ночь, а потом начинают вопросы мучить: а бывают ли вообще в природе безглючные программы?
staticmain Автор
16.02.2022 23:51Вот вы вынуждаете меня запланировать статью про malloc, в которой основным посылом будет что любая программа с malloc в дефолтном linux может упасть в любой момент и вы с этим сделать ничего не сможете.
zzzzzzzzzzzz
17.02.2022 08:05Да, это интересно.
Хотя там и так есть классический "паттерн" по непроверке возвращаемого значения на NULL, позволяющий добавить глючности в программу. Любопытно, если система дополнительно борется с теми, кто попытался всё-таки проверять.
staticmain Автор
17.02.2022 08:13+1Ну собственно не проверяя на NULL вы хуже не сделаете, потому что NULL (опять же на дефолтном линуксе) там никогда не будет.
Про это уже много кто писал, но, вероятно, если собрать еще информацию о том как работают арены, да обмазать кодом, да показать как свой аллокатор сделать, может кому-то интересно и будет.
MinimumLaw
Картинки хороши. Да и текст тоже. Впрочем, если задаться целью покопаться, то все перечисленное лишь вершина айсберга...
Но в одном месте и понятным языком описаннное - да, пожалуй достойно.