Как кушать вилкой

в 12:53, , рубрики: C, exec, fork, системное программирование

...или о fork() в двух словах.

Как люди решают задачи

Обычно у каждой задачи есть одно простое решение, которое воспринимается всеми как правильное. Люди воспринимают такое решение правильным либо исходя из личного опыта¹; исходя из опыта других людей² или просто не задумываясь о правильности³. И самое удивительное, что мир не взорвался, никто (массово) от этого не умер, код работает и приносит деньги.

¹ "всегда так пишу код, никто не умер"
² "копирую код из stack overflow который набрал больше всех плюсов"
³ "копирую первый попавшийся код из stack overflow"

Однако самый простой - не всегда самый правильный. Да, можно скопировать самый заплюсованный ответ и сказать, что миллионы не могут ошибаться, но ведь миллионы людей делают прививки и миллионы людей не делают прививки и одна из групп, исходя из простейшей логики, совершила ошибку.

Однако мы отвлеклись. Поставим перед собой задачу:

Нам необходимо наиболее правильным способом запустить из своего кода другую программу.

Не так важно, зачем. Это может быть запуск игры из лаунчера, запуск утилиты ping чтобы не реализовывать отправку ICMP-пакетов самостоятельно, запуск программы по клику на ярлык, миллион вариантов, думаю, что вы сами хотя бы раз в жизни сталкивались с такой задачей.

Кстати, познакомьтесь, это Картошка. Она будет нам помогать учиться пользоваться вилкой:

Как кушать вилкой - 1

Содержание статьи:

  1. Как кушать пингвина вилкой?
    Общие знания о запуске процессов под LINUX-системами

  2. Как кушать корову если есть вилка?
    Copy-on-write, что это и зачем? vfork и почему он не лучше

  3. Как кушать икру?
    posix_spawn и почему он не замещает fork()

  4. Как кушают клоны?
    clone() под капотом у fork()

  5. Почему когда ешь суп вилкой, он утекает?
    Утечка дескрипторов после fork() и как этого избежать

  6. Почему у вилки три зуба?
    Важность обработки всех вариантов возврата fork()

  7. Как кушать демонов вилкой?
    Запуск демонизирующихся процессов при помощи fork()

  8. Как наложить вилкой в другую тарелку?
    Переназначение дескрипторов вывода для нового процесса

  9. Как сигналить вилке?
    Взаимоотношения обработки сигналов и fork()

  10. Как пользоваться вилкой когда сломалась ручка?
    Самоликвидация дочернего процесса после завершения материнского

  11. Как подготовиться к использованию вилки?
    Сценарии использования pthread_atfork()

  12. Как поцарапать окно вилкой?
    Запуск дочернего процесса под Windows-системой

  13. Как систематически пользоваться вилкой?
    Почему вам не стоит пользоваться system()

  14. Заключение.
    Благодарности и выводы

Самое простое решение

Войдем в 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 систем. Если целиком убрать все синтаксические костыли, то оно сводится к следующему:

  1. Создать копию текущего процесса (fork).

  2. Заместить копию новой программой (exec).

"Создать копию текущего процесса" звучит максимально странно, как если бы чтобы нарисовать вторую половинку вилки мы бы отзеркалили первую, а потом стерли её и на её месте нарисовали ручку.

Как кушать вилкой - 2
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 лет никто не изобретет новую сущность, настройку безопасности которой кровь из носу нужно добавить в этот вызов.

Но, хотя это и звучит максимально просто, со временем даже в такой простой механизм пришлось добавить много дополнений и "сносок".

Как кушать корову если есть вилка?

Как кушать вилкой - 3

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

7200

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(), про который мы еще поговорим.

Как кушать вилкой - 4

Как кушать икру?

Чуть выше я упомянул функцию 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
);
Как кушать вилкой - 5

Это, конечно, не миллион аргументов как в 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().

Почему когда ешь суп вилкой, он утекает?

Как кушать вилкой - 6

А теперь, наконец, поговорим о том, как правильно пользоваться 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:

Произошло следующее:

  1. Родительский процесс открыл файл и получил его дескриптор. Так как дескрипторы раздаются по принципу "первый незанятый номер", то его значение 3. (Помним, что 0..2 заняты stdin/out/err)

  2. Дочерний процесс получил копию всех открытых дескрипторов родительского процесса.

  3. Родительский процесс закрыл дескриптор.

  4. Дочерний процесс обращается к (всё ещё открытому¹) дескриптору #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% дождаться выполнения подлежащего приложения). Иногда бывает так, что это подлежащее приложение может вызывать свои суб-утилиты. А иногда бывает так, что приложение, почувствовав, что его выпустили на волю решает запустить себя в качестве демона.

Как кушать вилкой - 7

Такой запуск приводит к следующему: когда завершается "оболочка" приложения (запустив при этом "демона") ваш вызов 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;
}

Мы:

  1. Создали новый дескриптор, указывающий на файл (open).

  2. Переопределили дескриптор stdout файловым (dup2).

  3. Закрыли более не нужную копию (close).

  4. Повторили для 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:

Используйте переопределение дескрипторов для запускаемых процессов, если хотите сохранить вывод основного приложения в порядке.

Как сигналить вилке?

Как кушать вилкой - 8

Если ваше приложение использует signal/sigaction и fork одновременно - то я готов поспорить, что вы попались в ловушку.

Дело в том, что forked-процесс наследует все ваши обработчики сигналов. При этом создаются следующие проблемы:

  1. Сразу после вызова fork() к вам может прилететь сигнал, который может прилететь в оба процесса сразу, когда: сигнал был послан всей группе, а оба процесса будут в ней находиться или сигнал был послан всем находящимся в одной контрольной группе, а OOM или systemd послали сигнал всей группе. И если материнский процесс в таком случае адекватно отреагирует на сигнал (выставит флаги завершения, завершит работу), то дочерний процесс ничего этого не сделает, поскольку обработанные до замещения процесса сигналы не повлияют на работу замещенного процесса, который доступа к выставленным флагам уже не имеет.

  2. В случае, если вы создаете какие-то побочные эффекты в обработчике сигнала они могут повлиять на выполнение дочернего процесса (например выведет что-то в лог, тем самым создав такой файл).

Распишем решение этой проблемы:

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;
}
  1. Мы заблокировали приход всех сигналов в главный процесс.

  2. Вызвали fork() и установили обработчики по умолчанию для дочернего процесса.

  3. Вернули сохраненные обработчики для материнского процесса.

В данном примере мы опустили защиту от одновременного доступа к g_sig_blocked, а ведь если одновременно стартуют два процесса - то произойдёт состояние гонки. Вам необходимо самостоятельно добавить защиту от этого.

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

Совет #5:

Блокируйте приход сигналов перед вызовом fork() и выставляйте обработчики по умолчанию для дочернего процесса, иначе вы рискуете нарушить ход работы программы.

Как пользоваться вилкой когда сломалась ручка?

Как кушать вилкой - 9

Чаще всего существование дочернего процесса не имеет смысла, когда родительский процесс совершил судоку.

Но как дочерний процесс должен понять, что наступила его очередь совершать хачапури?

Постоянно отслеживать 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));

Она позволяет установить три обработчика для данной нити, каждый из которых будет вызван во время соответствующего ему события:

  1. prepare - Во время вызова fork(), до создания нового процесса, со стороны родителя.

  2. parent - Во время вызова fork(), после создания и инициализации нового процесса, со стороны родителя.

  3. child - Во время вызова fork(), после создания и инициализации нового процесса, со стороны дочернего процесса.

По факту, это очень специфичная функция, которая была призвана решить проблему того, что дочерний процесс не может (согласно стандарту POSIX.1) вызывать не async-signal-safe функции до вызова exec, в то время как pthread_mutex_lock и pthread_mutex_unlock как раз-таки являются не async-signal-safe функциями.

С этой функцией может быть много проблем, так как вы не имеете возможности проверить, был ли этот mutex инициализирован или освобожден, доступен ли сам ресурс и так далее. Да, вы можете добавлять функции в LIFO-порядке последовательными вызовами pthread_atfork, но не имеете возможности их оттуда удалить. Моя рекомендация - не использовать в дочернем процессе эти ресурсы, чтобы в корне избежать проблем с синхронизацией. Однако ваша архитектура приложения может этого не позволить и тогда pthread_atfork будет вашей единственной возможностью адекватно переинициализировать блокировки.

Как поцарапать окно вилкой?

Как кушать вилкой - 10

А что же 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, который вообще никак не утверждает её потокобезопасность.

SunOS:

       +-----------------------------+-----------------------------+
       |      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("Пришёл сигнал %dn", 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 с наивысшим рейтингом.

Особая благодарность в подготовке статьи следующим людям:

  • Моему коллеге, Алексею Ш. за ревью и подсказки.

  • Моему коллеге, Вячеславу Р. за подсказки.

  • Пользователю LOR wandrien за исследование system

  • Моей жене за изображения к статье.

Автор:
staticmain

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js