Системное программирование на C Процессы и сигналы: fork, exec, каналы, обработка сигналов
0%

Процессы и сигналы: fork, exec, каналы, обработка сигналов

Процессы и сигналы: fork, exec, каналы, обработка сигналов

Когда вы пишете subprocess.run(["ls", "-l"]) в Python, exec.Command("ls").Output() в Go или Process.Start() в .NET, под этим лежит одна и та же последовательность из трёх системных вызовов, придуманная в Unix около 1970 года: fork, exec, wait. Все высокоуровневые обёртки — это разной степени аккуратности реализации этой тройки плюс обвязка вокруг каналов и сигналов. Разобравшись здесь, вы перестанете удивляться, почему процесс-«зомби» не убивается kill -9, почему docker stop иногда ждёт ровно десять секунд, почему сообщение печатается дважды и почему printf в обработчике сигнала — бомба замедленного действия. Статья — про границу процесса как единицы изоляции и про сигналы как единственный встроенный в Unix способ асинхронно «постучаться» в чужой процесс. Мы опираемся на дескрипторы и буферизацию из https://courses.digitable.life/post/systems-programming/07-syscalls-and-io/ и на модель адресного пространства из https://courses.digitable.life/post/systems-programming/02-memory-and-pointers/. Устройство планировщика и структуры ядра разбираются в https://courses.digitable.life/post/operating-systems/03-processes-and-scheduling/ — здесь мы смотрим со стороны прикладного C-кода: какие вызовы делать, в каком порядке и где именно ждёт неопределённое поведение.

Что такое процесс с точки зрения C-программиста

Процесс — это не «запущенная программа». Программа (ELF-файл) — пассивные байты на диске. Процесс — контейнер ресурсов, который ядро создаёт и учитывает. У него есть четыре разных «пространства», и понимание того, что происходит с каждым при fork и exec, снимает 90% путаницы.

Пространство Что содержит При fork При exec
Адресное пространство код, данные, куча, стек, mmap копируется лениво, через COW уничтожается, грузится новый образ
Таблица дескрипторов номера fd → открытые описания файлов копируется, fd делят смещение сохраняется, кроме fd с FD_CLOEXEC
Контекст ядра pid, ppid, uid/gid, cwd, umask, лимиты наследуется, pid новый сохраняется полностью
Диспозиции сигналов что делать с каждым сигналом наследуется обработчики → SIG_DFL, игнор остаётся

Последняя строка — источник классических багов: после exec вашего обработчика больше нет, но если вы поставили SIG_IGN на SIGPIPE перед запуском потомка, он унаследует игнорирование, и gzip в конвейере не завершится вовремя. Идентичность процесса — pid_t. Важно: PID переиспользуются. Как только статус завершившегося процесса собран, ядро вправе выдать этот номер новому. Отсюда класс гонок «сохранили pid, подождали, послали kill — убили чужой процесс». Linux решает это через pidfd (см. ниже), переносимый способ — не отпускать потомка, то есть оставаться его родителем.

Буквы в скобках — колонка STAT в ps и поле State в /proc/PID/status. Состояние D (uninterruptible sleep) отдельно неприятно: процесс в нём не реагирует даже на SIGKILL, потому что застрял в непрерываемом коде драйвера — обычно это диск или сетевая ФС.

fork: один вызов, два возврата

fork() — единственный вызов, который возвращает управление дважды: в родителе со значением pid потомка, в потомке — с нулём.

#define _POSIX_C_SOURCE 200809L
#include <stdio.h>
#include <unistd.h>
#include <sys/wait.h>

int main(void) {
    pid_t pid = fork();
    if (pid < 0) { perror("fork"); return 1; }   /* EAGAIN, ENOMEM: ресурсы кончились */
    if (pid == 0) {                              /* мы в потомке */
        printf("потомок:  pid=%d ppid=%d\n", getpid(), getppid());
        _exit(0);                                /* именно _exit, а не exit — см. ниже */
    }
    printf("родитель: pid=%d потомок=%d\n", getpid(), pid);
    int status;
    if (waitpid(pid, &status, 0) < 0) { perror("waitpid"); return 1; }
    if (WIFEXITED(status)) printf("код выхода %d\n", WEXITSTATUS(status));
    return 0;
}

Сборка: gcc -std=c11 -Wall -Wextra -O2 -g -o forkdemo forkdemo.c. Порядок вывода строк не определён: кто первым получит процессор, решает планировщик. Тест, зависящий от порядка, флаки — и это первый пример того, почему «работает на моей машине» здесь особенно опасно: на одноядерной виртуалке в CI порядок бывает стабильным, на 32-ядерной машине разработчика — нет.

Copy-on-write: почему fork не копирует гигабайты

Наивная реализация копировала бы всё адресное пространство. Реально ядро копирует только таблицу страниц, помечая приватные страницы обоих процессов как «только для чтения». Первая же запись даёт исключение защиты, ядро выделяет новый физический кадр, копирует 4 КиБ и разрешает запись.

fork и copy-on-write: два адресных пространства на одних физических страницах

  • Стоимость fork — примерно O(число VMA + размер таблицы страниц), а не O(объём занятой памяти). Но у процесса с 20 ГБ анонимной памяти сама таблица страниц весит десятки мегабайт, и fork заметно тормозит.
  • Ленивая копия делает систему чувствительной к overcommit: формально сразу после fork нужно вдвое больше памяти. Redis, который форкается ради снапшота, поэтому просит vm.overcommit_memory=1.
  • RSS сразу после fork вводит в заблуждение: общие страницы учитываются обоим процессам. Смотрите PSS в /proc/PID/smaps_rollup; метрики памяти разобраны в https://courses.digitable.life/post/performance/04-memory/.

Буферизация stdio и удвоенный вывод

Копируется вся память — включая буферы libc:

int main(void) {
    printf("привет");   /* без \n: при выводе в канал остаётся в буфере */
    fork();
    return 0;           /* каждый процесс сбросит СВОЮ копию буфера */
}
/* $ ./dup | cat   ->   приветпривет : буфер скопировался вместе с памятью */

Правило: fflush(NULL) перед fork, а в потомке после неудачного exec_exit(), а не exit(). Разница принципиальна: exit() выполняет обработчики atexit и сбрасывает буферы stdio, то есть выводит данные, которые уже вывел или ещё выведет родитель. _exit() идёт прямо в системный вызов.

Отдельная опасность — fork в многопоточной программе. POSIX говорит жёстко: в потомке существует только один поток — тот, что вызвал fork. Остальные исчезают вместе с состоянием, которое держали. Если другой поток удерживал внутренний мьютекс malloc, в потомке этот мьютекс останется заблокированным навсегда, и первый же malloc (в том числе внутри printf) повиснет. Поэтому между fork и exec разрешены только async-signal-safe функции (man 7 signal-safety) — тот же список, что и для обработчиков сигналов. Практический вывод: в многопоточной программе fork допустим только вплотную к exec, а лучше — posix_spawn. Потоки продолжает https://courses.digitable.life/post/systems-programming/09-threads-and-sync/.

exec: замена образа программы

exec не создаёт процесс. Он выбрасывает текущее адресное пространство и загружает на его место новый исполняемый файл, сохраняя pid, дескрипторы и контекст ядра. Успешный exec не возвращается.

/* Буквы: l — список аргументов, v — вектор, p — поиск по PATH, e — явный envp */
execl("/bin/ls", "ls", "-l", (char *)NULL);
execvp("ls", argv);
execve("/bin/ls", argv, envp);   /* единственный настоящий системный вызов */
perror("exec");                  /* сюда попадаем ТОЛЬКО при ошибке */
_exit(127);                      /* 127 — соглашение shell: команда не найдена */
  1. argv[0] задаёте вы сами, он не обязан совпадать с путём. На этом работают мультивызовные бинарники вроде busybox — и на этом же строится маскировка вредоносного ПО.
  2. Массивы обязаны заканчиваться (char *)NULL. Голый NULL в вариадической функции на некоторых платформах — это int 0, а не указатель нужной ширины: UB, которое молча работает на x86-64 и ломается там, где sizeof(int) != sizeof(void *).
  3. Дескрипторы переживают exec. Это фича (так работает перенаправление) и утечка одновременно: потомок получает все ваши сокеты и файлы. Открывайте всё с O_CLOEXEC — это не оптимизация, а требование безопасности, см. https://courses.digitable.life/post/security/14-secure-coding/.

Между fork и exec находится «окно настройки» — единственное место, где потомок ещё ваш и ему можно поменять дескрипторы, каталог, приоритет, пользователя. Ради этого окна Unix и разделил создание процесса и загрузку программы, тогда как Windows делает всё одним CreateProcess с двумя десятками параметров. Критику fork как интерфейса стоит прочитать в A. Baumann et al. «A fork() in the road» (HotOS 2019) — https://www.microsoft.com/en-us/research/publication/a-fork-in-the-road/.

Как узнать, что exec не удался

Родитель не видит ошибок потомка: при ENOENT потомок просто выйдет с кодом 127, но так же он может выйти и по другой причине. Промышленное решение (его используют и Go в os/exec, и Python в subprocess) — канал с CLOEXEC: при успешном exec он закроется сам, и родитель увидит EOF; при ошибке потомок успеет записать в него errno.

/* нужны _GNU_SOURCE, errno.h, fcntl.h, unistd.h, sys/wait.h */
pid_t spawn(char *const argv[]) {          /* pid или -1 с корректным errno */
    int ep[2];
    if (pipe2(ep, O_CLOEXEC) < 0) return -1;
    pid_t pid = fork();
    if (pid < 0) { close(ep[0]); close(ep[1]); return -1; }
    if (pid == 0) {                        /* потомок: только безопасные вызовы */
        close(ep[0]);
        execvp(argv[0], argv);
        int e = errno;
        ssize_t ign = write(ep[1], &e, sizeof e); (void)ign;
        _exit(127);
    }
    close(ep[1]);                          /* критично: иначе read не даст EOF */
    int child_errno = 0;
    ssize_t n = read(ep[0], &child_errno, sizeof child_errno);  close(ep[0]);
    if (n == (ssize_t)sizeof child_errno) {          /* exec провалился */
        int st;
        while (waitpid(pid, &st, 0) < 0 && errno == EINTR) continue;
        errno = child_errno;
        return -1;
    }
    return pid;                            /* n == 0 значит EOF, exec удался */
}

Альтернативы fork + exec. Для сценария «просто запустить программу» копирование таблицы страниц — чистые потери. Альтернативы: posix_spawn — стандартный POSIX-интерфейс, описывающий действия над дескрипторами декларативно (posix_spawn_file_actions_t); в glibc реализован через clone(CLONE_VM | CLONE_VFORK), то есть без копирования таблицы страниц, и это предпочтительный вариант в многопоточных и в больших процессах. vfork — исторический хак: потомок делит АП с родителем, родитель заморожен до exec или _exit; быстро, но любое другое действие потомка — UB. clone (Linux) — сырой вызов, из которого сделаны и fork, и потоки; позволяет выборочно разделять пространства имён и на нём построены контейнеры, см. https://courses.digitable.life/post/operating-systems/18-virtualization-and-containers/.

wait: сбор статусов и зомби

Когда процесс завершается, ядро освобождает его память и дескрипторы, но не удаляет запись в таблице процессов: там лежит код возврата, нужный родителю. Такой процесс — зомби (Z в ps). Он не занимает ни памяти, ни CPU, но занимает слот и PID. kill -9 на него не действует: убивать нечего.

int st;
pid_t p = waitpid(pid, &st, 0);
if (p < 0 && errno == ECHILD) { /* потомков нет вообще */ }
if (WIFEXITED(st))        printf("код выхода %d\n", WEXITSTATUS(st));
else if (WIFSIGNALED(st)) printf("убит сигналом %d%s\n", WTERMSIG(st),
                                 WCOREDUMP(st) ? ", core dump сохранён" : "");
else if (WIFSTOPPED(st))  printf("остановлен %d\n", WSTOPSIG(st)); /* нужен WUNTRACED */

Никогда не сравнивайте status с числом: это упакованное битовое поле, а не код возврата. Соглашение shell — 128 + N для «убит сигналом N», 127 — команда не найдена, 126 — не исполняема.

Вызов Что делает
wait(&st) ждёт любого потомка, блокируется
waitpid(pid, &st, 0) ждёт конкретного; -1 — любого, 0 — из своей группы
waitpid(-1, &st, WNOHANG) не блокируется, вернёт 0, если никто не завершился
waitid(P_PID, pid, &info, WEXITED | WNOWAIT) подробный siginfo_t, можно «подсмотреть» статус, не собирая

Осиротевшие процессы. Если родитель умер раньше потомка, потомок усыновляется процессом с PID 1 или ближайшим subreaper (тем, кто вызвал prctl(PR_SET_CHILD_SUBREAPER, 1)). Поэтому init обязан вызывать wait в цикле — иначе зомби копятся до исчерпания лимита PID. В контейнере ваш процесс часто и есть PID 1, и обязанность собирать сирот ложится на вас; отсюда рекомендация запускать контейнеры с --init или через tini, см. https://courses.digitable.life/post/devops/05-containers-and-registries/.

$ ./make_zombie &                     # делает fork и не вызывает wait
$ ps -o pid,ppid,stat,comm
14232 14231 Z    make_zombie <defunct>     # вот он

Каналы: байтовый поток между процессами

pipe(int fd[2]) создаёт в ядре безымянный объект с кольцевым буфером и два дескриптора: fd[0] — чтение, fd[1] — запись (мнемоника: 0 как stdin, 1 как stdout). Канал однонаправлен и не сохраняет границ сообщений — это чистый байтовый поток.

Таблицы дескрипторов и объект канала в ядре при выполнении ls | wc -l

Полная реализация ls | wc -l — ровно то, что делает shell:

int main(void) {
    int fd[2];
    if (pipe(fd) < 0) { perror("pipe"); return 1; }
    pid_t left = fork();
    if (left == 0) {
        dup2(fd[1], STDOUT_FILENO);   /* стандартный вывод -> конец записи */
        close(fd[0]); close(fd[1]);   /* оригиналы больше не нужны */
        execlp("ls", "ls", (char *)NULL);
        perror("execlp ls"); _exit(127);
    }
    pid_t right = fork();
    if (right == 0) {
        dup2(fd[0], STDIN_FILENO);    /* стандартный ввод <- конец чтения */
        close(fd[0]); close(fd[1]);
        execlp("wc", "wc", "-l", (char *)NULL);
        perror("execlp wc"); _exit(127);
    }
    close(fd[0]); close(fd[1]);       /* САМОЕ ВАЖНОЕ: родитель закрывает ОБА конца */
    waitpid(left, NULL, 0); waitpid(right, NULL, 0);
    return 0;
}

Если родитель не закроет fd[1], wc зависнет навсегда. EOF на чтении наступает только когда счётчик открытых пишущих концов упадёт до нуля, а забытый дескриптор в родителе держит его на единице. Это ошибка номер один при работе с каналами, и она не воспроизводится в тестах, где родитель быстро завершается сам. dup2(old, new) атомарно закрывает new и делает его копией old, указывающей на то же открытое описание файла. Копия разделяет с оригиналом смещение и флаги статуса, но имеет собственный FD_CLOEXEC, и dup2 его всегда снимает — что здесь и нужно.

  • Ёмкость буфера в Linux по умолчанию 64 КиБ (fcntl(fd, F_SETPIPE_SZ, n) меняет, /proc/sys/fs/pipe-max-size ограничивает). Запись в полный канал блокируется, чтение из пустого — тоже.
  • Атомарность. Запись до PIPE_BUF (POSIX гарантирует 512 байт, Linux даёт 4096) не перемежается с чужими записями. Больше — может разорваться: если несколько процессов пишут в один канал строки длиннее 4 КиБ, строки перемешаются.
  • SIGPIPE. Запись в канал без читателей посылает пишущему SIGPIPE, действие по умолчанию — немедленное завершение. Поэтому yes | head -1 не крутится вечно. В сервере это катастрофа: клиент отвалился — процесс умер. Решение: игнорировать SIGPIPE (тогда write вернёт -1/EPIPE) или слать через send с MSG_NOSIGNAL.
  • Направление одно. Для двустороннего обмена нужны два канала или socketpair(AF_UNIX, SOCK_STREAM, 0, sv) — полнодуплексная пара, умеющая передавать дескрипторы через SCM_RIGHTS.

Именованные каналы (mkfifo) — то же самое, но с именем в файловой системе: их открывают неродственные процессы. Разделяемая память, очереди сообщений и семафоры разобраны в https://courses.digitable.life/post/operating-systems/07-syscalls-and-ipc/ — они появляются, когда пропускной способности канала не хватает и нужно избежать копирования через ядро. Обёртка popen/pclose делает fork + exec /bin/sh -c + канал одной строкой: удобно для скриптов и опасно в проде, потому что строка уходит в shell и любые пользовательские данные превращаются в инъекцию команд.

Сигналы: асинхронные уведомления

Сигнал — программное прерывание: число, доставляемое процессу асинхронно, без канала передачи данных (у классических сигналов нет полезной нагрузки, кроме номера). Источники: терминал (Ctrl+CSIGINT), другой процесс (kill), ядро при аппаратной ошибке (SIGSEGV, SIGFPE), сам процесс (abort, raise), таймер (SIGALRM).

SIGKILL (9) и SIGSTOP (19) нельзя ни перехватить, ни заблокировать, ни проигнорировать — это гарантия того, что администратор всегда остановит процесс. Всё остальное — предмет договорённости между вами и ядром.

Как сигнал доставляется

Ядро хранит для процесса три маски: pending (пришедшие, но не доставленные), blocked (временно отложенные) и таблицу диспозиций. Классические сигналы не образуют очередь: если SIGUSR1 пришёл пять раз, пока был заблокирован, бит установится один раз и обработчик вызовется однократно. Очередь и полезную нагрузку дают только сигналы реального времени (SIGRTMIN..SIGRTMAX, отправка через sigqueue).

Ключевой момент: доставка происходит не в момент отправки, а при возврате процесса из ядра в пользовательский режим. Поэтому сигнал, посланный спящему в read() процессу, прерывает этот read — и вы получаете EINTR.

sigaction, а не signal

У функции signal() исторически разъезжающаяся семантика: в System V диспозиция сбрасывалась в SIG_DFL перед вызовом обработчика (гонка: второй сигнал убьёт процесс), в BSD — нет, и системные вызовы перезапускались. Стандарт C оставляет разницу на усмотрение реализации. Вывод: signal() непереносим, используйте sigaction().

static volatile sig_atomic_t g_should_stop = 0;   /* ЕДИНСТВЕННЫЙ безопасный тип */

static void on_term(int sig) {
    (void)sig;
    g_should_stop = 1;             /* больше в обработчике не делаем НИЧЕГО */
}

int install_handlers(void) {
    struct sigaction sa;
    memset(&sa, 0, sizeof sa);
    sa.sa_handler = on_term;
    sigemptyset(&sa.sa_mask);      /* что блокировать НА ВРЕМЯ обработчика */
    sa.sa_flags = SA_RESTART;      /* перезапускать прерванные вызовы */
    if (sigaction(SIGTERM, &sa, NULL) < 0) return -1;
    if (sigaction(SIGINT,  &sa, NULL) < 0) return -1;
    sa.sa_handler = SIG_IGN;       /* SIGPIPE игнорируем: пусть write даёт EPIPE */
    return sigaction(SIGPIPE, &sa, NULL);
}

SA_RESTART спасает не всегда: poll, select, nanosleep и read с таймаутом не перезапускаются даже с ним. Правильный код всегда готов к EINTR:

ssize_t r;
do { r = read(fd, buf, len); } while (r < 0 && errno == EINTR);

Что можно делать в обработчике

Обработчик выполняется внутри прерванного потока, в произвольной точке кода. Если поток был посреди malloc, а обработчик тоже вызывает malloc, получается рекурсивный вход в аллокатор с захваченным мьютексом: дедлок или порча кучи. Поэтому POSIX определяет список async-signal-safe функций (man 7 signal-safety): write, read, close, _exit, kill, sigprocmask и ещё около сотни. printf, malloc, free, syslog в этот список не входят.

static void on_usr1(int sig) {
    int saved_errno = errno;                 /* обработчик обязан не портить errno */
    static const char msg[] = "получен SIGUSR1\n";
    ssize_t ign = write(STDERR_FILENO, msg, sizeof msg - 1);
    (void)ign; (void)sig;
    errno = saved_errno;
}

Стандарт C строже: обработчик может обращаться только к объектам типа volatile sig_atomic_t и к lock-free атомикам. Чтение обычной int из обработчика — формально UB, потому что компилятор вправе держать её в регистре или разорвать доступ. volatile означает «перечитывай из памяти», но не даёт атомарности и не заменяет синхронизацию между потоками — см. https://courses.digitable.life/post/systems-programming/09-threads-and-sync/ и https://courses.digitable.life/post/systems-programming/11-undefined-behavior/.

Сбор потомков через SIGCHLD

static void on_sigchld(int sig) {
    (void)sig;
    int saved_errno = errno;
    /* Цикл обязателен: сигналы не копятся, пять потомков дадут один SIGCHLD */
    while (waitpid(-1, NULL, WNOHANG) > 0)
        continue;
    errno = saved_errno;
}

Если статусы потомков не нужны, можно поставить SIG_IGN на SIGCHLD (или флаг SA_NOCLDWAIT) — ядро само собирает зомби. Ограничение: wait после этого вернёт ECHILD, и коды возврата вы уже не получите.

Гонка «проверил флаг — уснул»

Наивный цикл ожидания содержит окно, в котором сигнал теряется:

while (!g_should_stop)
    pause();        /* ОШИБКА: сигнал может прийти между проверкой и pause */

Если SIGTERM доставлен сразу после проверки условия, но до входа в pause, обработчик выставит флаг, а pause начнёт ждать следующего сигнала — процесс зависнет. Правильный шаблон блокирует сигнал заранее и атомарно снимает блокировку внутри sigsuspend:

sigset_t mask, orig;
sigemptyset(&mask);
sigaddset(&mask, SIGTERM); sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, &orig);   /* с этого момента сигнал только копится */

while (!g_should_stop)
    sigsuspend(&orig);                  /* атомарно: маска = orig, ждать, вернуть маску */

sigprocmask(SIG_SETMASK, &orig, NULL);

Это тот же класс ошибок, что «проверил условие — вызвал wait» у условных переменных: атомарность «отпустить и уснуть» должно обеспечивать ядро.

Современные способы: self-pipe, signalfd, pidfd

Асинхронный обработчик плохо стыкуется с событийным циклом на poll/epoll. Три способа превратить сигнал в обычное событие дескриптора.

Self-pipe trick (переносимо, придуман Д. Дж. Бернстайном): обработчик пишет один байт в неблокирующий канал, а его читающий конец добавляется в poll наравне с сокетами.

static int g_pipe[2];                          /* оба конца: O_NONBLOCK | O_CLOEXEC */

static void handler(int sig) {
    int saved = errno;
    unsigned char byte = (unsigned char)sig;
    ssize_t ign = write(g_pipe[1], &byte, 1);  /* write — async-signal-safe */
    (void)ign; errno = saved;
}

signalfd (Linux) отдаёт сигналы как обычные данные, без ограничений async-signal-safety — в обработке можно и логировать, и аллоцировать:

sigset_t mask;                         /* нужен sys/signalfd.h */
sigemptyset(&mask);
sigaddset(&mask, SIGTERM); sigaddset(&mask, SIGINT);
sigprocmask(SIG_BLOCK, &mask, NULL);   /* ОБЯЗАТЕЛЬНО: иначе сработает SIG_DFL */
int sfd = signalfd(-1, &mask, SFD_CLOEXEC | SFD_NONBLOCK);
struct signalfd_siginfo si;
if (read(sfd, &si, sizeof si) == sizeof si)
    printf("сигнал %u от pid %u\n", si.ssi_signo, si.ssi_pid);

pidfd (Linux 5.3+) закрывает гонку с переиспользованием PID: pidfd_open(pid, 0) даёт дескриптор, который всегда ссылается на конкретный процесс, а не на номер. Его можно ждать через poll (готовность = процесс завершился) и слать сигнал через pidfd_send_signal. Для супервизоров это правильный современный инструмент — https://lwn.net/Articles/794707/.

Неопределённое поведение и «работает на моей машине»

Здесь недетерминизм встроен в саму модель, поэтому баги плохо воспроизводятся. Что формально является UB или неспецифицированным поведением:

  • Не-async-signal-safe функция в обработчике. printf в обработчике работает годами и падает в день, когда сигнал придёт ровно во время другого printf. Симптом — зависший процесс с двумя кадрами malloc в бэктрейсе.
  • Обращение из обработчика к объекту не типа volatile sig_atomic_t и не lock-free атомику — UB по C11 7.14.1.1.
  • longjmp из обработчика (разрешён только siglongjmp в паре с sigsetjmp(env, 1), иначе маска не восстановится) и порча errno: обработчик, вызвавший write, меняет errno посреди его проверки в основном коде.
  • fork в многопоточной программе с последующими небезопасными вызовами — чаще всего дедлок в malloc у потомка.
  • Опора на порядок выполнения родителя и потомка после fork: порядок не определён, синхронизируйтесь явно (тот же канал в роли барьера).
  • Возврат из обработчика SIGSEGV. Инструкция, вызвавшая ошибку, будет выполнена снова — бесконечный цикл. Корректно только записать диагностику и вызвать _exit, либо вернуть SIG_DFL и сделать raise того же сигнала ради нормального core dump.
  • Гонка «проверил флаг — уснул» — не UB, но недетерминированный дедлок, который на быстрой машине не воспроизводится месяцами. Полезная привычка: программу с сигналами и потомками прогонять под нагрузкой с искусственной подачей сигналов (while true; do kill -USR1 $PID; done) и под санитайзерами. Для сигналов пригодятся ASAN_OPTIONS=handle_sigill=1:handle_abort=1; инструментарий разобран в https://courses.digitable.life/post/systems-programming/06-debugging/.

Практика: как это выглядит в проде

Graceful shutdown демона. Контракт: SIGTERM — перестать принимать новые запросы, дождаться текущих, выйти с кодом 0. SIGKILL придёт следом по таймауту: Kubernetes даёт terminationGracePeriodSeconds (по умолчанию 30 с), docker stop — 10 с, systemd — TimeoutStopSec.

int main(void) {
    install_handlers();
    while (!g_should_stop) {
        int n = poll(fds, nfds, 1000);
        if (n < 0 && errno == EINTR) continue;   /* сигнал прервал ожидание */
        serve_ready(fds, nfds);
    }
    drain_inflight_requests();   /* здесь уже можно всё: это не обработчик */
    close_listeners();  return 0;
}

Разделение принципиально: обработчик только выставляет флаг, вся работа — в основном цикле. Тот же шаблон у перечитывания конфигурации по SIGHUP (исторически «терминал отвалился», у демонов переиспользован под reload — так работают nginx, sshd, rsyslog).

PID 1 в контейнере. Ядро не применяет действия по умолчанию к процессу с PID 1: без установленного обработчика SIGTERM будет просто проигнорирован, docker stop подождёт 10 секунд и прибьёт контейнер SIGKILL. Отсюда две обязанности PID 1: ставить обработчики и собирать осиротевших зомби. Второй частый источник проблем — ENTRYPOINT в shell-форме: PID 1 занимает /bin/sh, который не пробрасывает сигналы вашему процессу; используйте exec-форму ENTRYPOINT ["./app"].

Диагностика. Что смотреть, когда «процесс не реагирует на сигнал»:

strace -f -e trace=process,signal ./app     # -f обязательно: следовать за fork
grep -E 'Sig|State' /proc/$PID/status
# SigBlk: 0000000000010000  заблокированные (бит N-1 = сигнал N; бит 16 -> 17 = SIGCHLD)
# SigIgn: 0000000000001000  игнорируемые (бит 12 -> сигнал 13 = SIGPIPE)
# SigCgt: 0000000180004003  перехватываемые своим обработчиком
ls -l /proc/$PID/fd                          # не забыт ли конец канала; kill -l — номера

В gdb отладчик по умолчанию перехватывает сигналы раньше программы; чтобы они доходили до вашего обработчика: handle SIGUSR1 nostop noprint pass.

Типичные ошибки: чек-лист

  1. Не закрыт лишний конец канала → читатель никогда не получает EOF и висит в read.
  2. exit() вместо _exit() в потомке после неудачного exec → двойной сброс буферов и повторный запуск atexit-обработчиков.
  3. Нет fflush(NULL) перед fork → удвоенные строки в логе при перенаправлении в файл.
  4. Обработчик SIGCHLD без цикла waitpid(..., WNOHANG) → зомби при пачке одновременных завершений.
  5. Игнорируется EINTR → случайные ложные ошибки там, где сигналы приходят регулярно.
  6. printf или malloc в обработчике → редкие невоспроизводимые зависания.
  7. signal() вместо sigaction() → разное поведение на разных системах и libc; дескрипторы без O_CLOEXEC → утечка сокетов в потомков и классическое «порт занят» после перезапуска.
  8. Сохранённый pid без владения процессом → kill по переиспользованному номеру.
  9. Проверка status как числа вместо макросов WIFEXITED/WEXITSTATUS; system() и popen() со строками, куда попадают данные пользователя.

Итог

  • Процесс — контейнер ресурсов: адресное пространство, таблица дескрипторов, контекст ядра, диспозиции сигналов. fork копирует всё, exec заменяет только адресное пространство.
  • fork дёшев благодаря copy-on-write, но платит тот, кто пишет; в многопоточной программе между fork и exec допустимы только async-signal-safe вызовы, а лучше сразу posix_spawn.
  • wait — это не «подождать», а «собрать статус»: несобранный потомок остаётся зомби, осиротевшие уходят к PID 1 или subreaper (в контейнере эту роль часто играете вы), а канал отдаёт EOF, только когда закрыты все пишущие концы.
  • Сигнал — программное прерывание без очереди и без данных; доставка происходит при возврате в user mode, классические сигналы схлопываются.
  • Правильный шаблон обработчика: sigaction + флаг volatile sig_atomic_t + вся работа в основном цикле; всё остальное — либо UB, либо гонка. Недетерминизм встроен в модель, поэтому «работает на моей машине» проверяется только нагрузкой, санитайзерами и явной подачей сигналов в тестах.

Источники

Что дальше

Процессы изолированы: у каждого своё адресное пространство, и обмен данными стоит копирования через ядро. Иногда изоляция — именно то, что нужно: упавший потомок не утащит родителя. Но когда задачам нужна общая память и дешёвое переключение, на сцену выходят потоки — а вместе с ними совершенно другой класс ошибок: гонки данных, дедлоки и модель памяти, в которой интуиция «инструкции выполняются по порядку» перестаёт работать.

Потоки и синхронизация: pthreads, мьютексы, условные переменные, атомарность

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов