Сигналы

Предпосылки: процессы (PID, состояния, zombie), файловые дескрипторы (fd, epoll-совместимость).

Lock-free структуры | Отображение памяти

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

Процесс работает в user space и взаимодействует с ядром через системные вызовы — но всегда по своей инициативе. Что происходит, когда инициатива на стороне ядра? Пользователь нажал Ctrl+C, потомок завершился, процесс обратился по невалидному адресу. Ядру нужно уведомить процесс — причём немедленно, а не когда процесс спросит. Поллинг (периодическая проверка) решил бы задачу, но ценой постоянных syscall: процесс тратил бы CPU, чтобы раз за разом спрашивать «что-нибудь случилось?», и почти всегда получать ответ «нет».

Аналогия из аппаратного мира: устройство (диск, сетевая карта) не ждёт, пока CPU опросит его — оно генерирует аппаратное прерывание (hardware interrupt), прерывая текущую инструкцию. Сигнал (signal) — то же самое, только на уровне выше: ядро прерывает нормальный поток выполнения процесса и заставляет его отреагировать.

Три источника сигналов

Сигнал может прийти из трёх мест.

Ядро процесс. Ядро генерирует сигнал, когда обнаруживает ситуацию, о которой процесс не знает. Обращение к незамапленной памяти — SIGSEGV. Запись в pipe, у которого нет читателя — SIGPIPE. Деление на ноль — SIGFPE. Завершение дочернего процесса — SIGCHLD. Во всех случаях ядро создаёт сигнал без участия каких-либо user-space процессов.

Процесс процесс. Системный вызов kill() позволяет одному процессу послать сигнал другому (при наличии прав):

kill(1500, SIGTERM);   /* попросить PID 1500 завершиться */
kill(1500, SIGKILL);   /* принудительно убить PID 1500 */

Несмотря на имя, kill() — универсальный способ отправки любого сигнала, не только смертельного. Командная утилита kill — обёртка над этим вызовом: kill -TERM 1500.

Терминал процесс. Драйвер терминала (tty) преобразует управляющие последовательности клавиш в сигналы. Ctrl+C генерирует SIGINT — запрос на прерывание. Ctrl+Z генерирует SIGTSTP — приостановка процесса. Ctrl+\ генерирует SIGQUIT — завершение с core dump. Сигнал получает не один процесс, а вся foreground-группа процессов текущего терминала. Откуда у терминала берётся foreground-группа и почему tty вообще управляет процессами, подробнее разобрано в терминалах.

Действия по умолчанию

Каждый сигнал имеет действие, которое ядро выполняет, если процесс не установил собственный обработчик.

Завершение (terminate). SIGTERM (15) — запрос на штатное завершение, процесс может перехватить и отработать graceful shutdown (штатное завершение). SIGINT (2) — прерывание от Ctrl+C, тоже можно перехватить. SIGPIPE (13) — запись в закрытый pipe, по умолчанию убивает процесс. SIGKILL (9) — безусловное уничтожение, перехватить невозможно.

Core dump (дамп памяти). SIGSEGV (11) — обращение к невалидной памяти. SIGABRT (6) — процесс сам вызвал abort(). SIGQUIT (3) — Ctrl+. Ядро записывает образ памяти процесса на диск (файл core или по шаблону /proc/sys/kernel/core_pattern), после чего завершает процесс. Core dump позволяет вскрыть процесс post mortem через gdb и увидеть стек вызовов, значения переменных, состояние кучи.

Игнорирование. SIGCHLD — по умолчанию ядро ничего не делает. Родитель может установить обработчик для сбора зомби, но если не установил — сигнал просто отбрасывается (зомби при этом всё равно накапливаются, пока родитель не вызовет waitpid()). SIGURG — поступили out-of-band данные на сокет, большинству приложений это неинтересно.

Остановка. SIGSTOP (19) — безусловная приостановка процесса (нельзя перехватить). SIGTSTP (20) — Ctrl+Z, можно перехватить. SIGCONT (18) — возобновление остановленного процесса.

Два сигнала абсолютны: SIGKILL и SIGSTOP не перехватываются, не блокируются, не игнорируются. Ядро обрабатывает их до того, как процесс получит шанс на реакцию. Это последнее средство администратора — возможность, которую процесс не может отнять.

Установка обработчика: sigaction()

Веб-сервер записывает обработанные запросы в буфер, периодически сбрасывая его на диск. Если пользователь нажмёт Ctrl+C, действие по умолчанию — немедленное завершение: буфер потеряется. Нужно перехватить SIGINT, сбросить буфер и только потом завершиться.

Для установки обработчика существует два вызова: signal() и sigaction(). Устаревший signal() имеет неопределённое поведение между платформами, реализующими POSIX (Portable Operating System Interface): на одних после срабатывания обработчик сбрасывается на SIG_DFL, на других — нет. sigaction() предсказуем:

#include <signal.h>
#include <stdio.h>
#include <unistd.h>
 
static volatile sig_atomic_t shutdown_requested = 0;
 
static void handle_sigint(int sig) {
    (void)sig;
    shutdown_requested = 1;      /* только установка флага */
}
 
int main(void) {
    struct sigaction sa;
    sa.sa_handler = handle_sigint;
    sigemptyset(&sa.sa_mask);    /* не блокировать другие сигналы */
    sa.sa_flags = 0;
    sigaction(SIGINT, &sa, NULL);
 
    while (!shutdown_requested) {
        /* основной цикл: обработка запросов */
        pause();  /* ожидание любого сигнала */
    }
 
    /* Ctrl+C получен: сброс буферов, закрытие соединений */
    printf("shutting down gracefully\n");
    return 0;
}

Обработчик handle_sigint не делает ничего, кроме установки флага. Это принципиально — и причина кроется в том, как именно ядро доставляет сигнал.

Доставка сигнала: что происходит внутри

Когда ядро решает доставить сигнал процессу, оно устанавливает бит в поле pending (ожидающие доставки) структуры task_struct. Фактическая доставка происходит не в произвольный момент, а при возврате из kernel space в user space — после системного вызова, после обработки прерывания, после переключения контекста. Ядро проверяет поле pending и, если есть сигнал с установленным обработчиком, модифицирует стек процесса: подкладывает на вершину фрейм, указывающий на функцию-обработчик. Когда процесс возобновляет выполнение в user space, он «попадает» в обработчик. После возврата из обработчика специальный системный вызов rt_sigreturn() восстанавливает оригинальный стек, и процесс продолжает с того места, где был прерван.

 main()                        ядро                      обработчик
   |                             |                           |
   |  read(fd, buf, n)          |                           |
   |  ---- syscall --------->   |                           |
   |                         получен SIGINT                 |
   |                         pending |= (1 << SIGINT)       |
   |                         модификация стека user space    |
   |  <-- возврат из ядра --    |                           |
   |                             |                           |
   |  (стек указывает на обработчик)                        |
   |  ----- переход --------------------------------->      |
   |                                                  shutdown_requested = 1
   |                                                  return
   |  <-- rt_sigreturn() -----------------------------------|
   |                             |                           |
   |  (продолжение с прерванного места)                     |

Обработчик прерывает нормальный поток выполнения процесса. Код main() мог находиться в середине любой инструкции, любой функции, любого критического участка. Это порождает фундаментальную проблему.

Async-signal-safety: почему printf() в обработчике — deadlock

Допустим, основной код вызвал printf(). Внутри glibc printf() захватывает мьютекс stdout-буфера, формирует строку, записывает её в буфер и освобождает мьютекс. Если сигнал прибыл, пока мьютекс удерживается, обработчик начнёт выполняться при захваченном замке. Если обработчик тоже вызовет printf(), он попытается захватить тот же мьютекс — и заблокируется навсегда. Процесс повис.

main()                              обработчик
  |                                     |
  printf("request processed")           |
    |                                   |
    lock(stdout_mutex)      <-- мьютекс захвачен
    |                                   |
    format string...                    |
    |                    --- SIGINT --- |
    |                                   |
    |                     printf("shutting down")
    |                       |
    |                       lock(stdout_mutex)  <-- deadlock:
    |                       |                       мьютекс уже захвачен
    |                       | (ждёт бесконечно)     main() не продолжится,
    |                       |                       пока обработчик не вернётся

Та же проблема касается malloc() / free() — они защищают внутренние структуры аллокатора мьютексами. Вызвать malloc() из обработчика, пока основной код находится внутри malloc() — повреждение кучи или deadlock.

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

Безопасный паттерн для обработчика: установить флаг типа volatile sig_atomic_t и немедленно вернуться. Тип sig_atomic_t гарантирует атомарность записи — компилятор не разобьёт её на несколько инструкций. Квалификатор volatile запрещает компилятору оптимизировать проверку флага в while (!shutdown_requested): без volatile компилятор может решить, что переменная не меняется в теле цикла, и вынести проверку за его пределы.

Маска сигналов: sigprocmask()

Иногда нужно гарантировать, что определённый участок кода не будет прерван сигналом. Например, обновление связного списка в основном коде: если обработчик обратится к полуобновлённому списку, он увидит неконсистентное состояние.

sigprocmask() управляет маской сигналов (signal mask) — битовой маской, определяющей, какие сигналы заблокированы для доставки:

sigset_t mask, old_mask;
sigemptyset(&mask);
sigaddset(&mask, SIGINT);
sigaddset(&mask, SIGTERM);
 
/* заблокировать SIGINT и SIGTERM */
sigprocmask(SIG_BLOCK, &mask, &old_mask);
 
/* критический участок: сигналы не будут доставлены */
update_shared_structure();
 
/* восстановить старую маску */
sigprocmask(SIG_SETMASK, &old_mask, NULL);
/* если за время блокировки пришёл SIGINT — он доставлен сейчас */

Заблокированные сигналы не теряются — они становятся ожидающими (pending). Как только маска снимается, ядро немедленно доставляет их. Но стандартные сигналы (1-31) не образуют очередь: если за время блокировки пришло пять SIGINT, доставлен будет только один. Информация о количестве теряется.

signalfd(): сигналы как файловые дескрипторы

Async-signal-safety ограничивает обработчики до минимума: установить флаг и выйти. Основной цикл должен периодически проверять флаг — это ненадёжно и усложняет архитектуру. Серверные приложения строят событийный цикл вокруг epoll() — механизма ядра, который отслеживает готовность файловых дескрипторов: процесс регистрирует дескрипторы один раз и блокируется, пока хотя бы один не станет готов, вместо опроса каждого по очереди (мультиплексирование ввода-вывода). Сокеты, таймеры, inotify — всё через файловые дескрипторы. Сигналы выбиваются из этой модели.

signalfd() (Linux 2.6.22+) превращает сигналы в файловый дескриптор. Вместо асинхронного прерывания процесс читает сигналы синхронно через read(), как данные из сокета:

#include <sys/signalfd.h>
#include <signal.h>
#include <sys/epoll.h>
#include <unistd.h>
 
int main(void) {
    sigset_t mask;
    sigemptyset(&mask);
    sigaddset(&mask, SIGINT);
    sigaddset(&mask, SIGTERM);
 
    /* 1. Заблокировать сигналы через маску — иначе ядро
          доставит их через обработчик, а не через fd */
    sigprocmask(SIG_BLOCK, &mask, NULL);
 
    /* 2. Создать signalfd */
    int sfd = signalfd(-1, &mask, SFD_NONBLOCK | SFD_CLOEXEC);
 
    /* 3. Добавить signalfd в epoll рядом с сокетами */
    int epfd = epoll_create1(EPOLL_CLOEXEC);
    struct epoll_event ev = { .events = EPOLLIN, .data.fd = sfd };
    epoll_ctl(epfd, EPOLL_CTL_ADD, sfd, &ev);
 
    /* добавить сюда же серверный сокет, таймеры и т.д. */
 
    struct epoll_event events[16];
    for (;;) {
        int n = epoll_wait(epfd, events, 16, -1);
        for (int i = 0; i < n; i++) {
            if (events[i].data.fd == sfd) {
                struct signalfd_siginfo info;
                read(sfd, &info, sizeof(info));
 
                if (info.ssi_signo == SIGTERM || info.ssi_signo == SIGINT) {
                    /* graceful shutdown в основном потоке */
                    close(epfd);
                    return 0;
                }
            }
            /* обработка сокетов, таймеров... */
        }
    }
}

Ключевой момент — шаг 1. Без sigprocmask(SIG_BLOCK, ...) ядро доставит сигнал традиционным путём (через обработчик или действие по умолчанию) и signalfd ничего не увидит. Блокировка перенаправляет поток: вместо асинхронного прерывания сигнал попадает в очередь signalfd и ждёт read().

Результат: обработка сигнала происходит в основном потоке, внутри epoll_wait() цикла. Никаких ограничений async-signal-safety — можно вызывать printf(), malloc(), обращаться к любым структурам данных. Сигнал стал обычным событием, как поступление данных на сокет.

nginx использует этот подход: сигналы SIGHUP (перезагрузка конфигурации), SIGTERM (штатное завершение), SIGUSR1 (ротация логов) обрабатываются в event loop мастер-процесса, а не в асинхронных обработчиках.

Сигналы реального времени

Стандартные сигналы (1-31) имеют два ограничения. Во-первых, при блокировке множественные экземпляры одного сигнала схлопываются в один — pending хранит только бит «пришёл/не пришёл». Во-вторых, сигнал не несёт данных: SIGUSR1 говорит «что-то произошло», но не что именно.

Сигналы реального времени (real-time signals, SIGRTMIN..SIGRTMAX) снимают оба ограничения. Их номера начинаются с SIGRTMIN (обычно 34) и заканчиваются SIGRTMAX (обычно 64), давая 31 пользовательский сигнал. В отличие от стандартных, они образуют очередь: три посланных сигнала — три доставленных, ни один не теряется. Кроме того, каждый сигнал может нести целочисленную полезную нагрузку, передаваемую через sigqueue():

/* отправитель */
union sigval val;
val.sival_int = 42;     /* произвольные данные */
sigqueue(target_pid, SIGRTMIN + 0, val);
 
/* получатель: обработчик с SA_SIGINFO */
static void handler(int sig, siginfo_t *info, void *ctx) {
    (void)sig; (void)ctx;
    int payload = info->si_value.sival_int;  /* 42 */
    /* ... */
}
 
struct sigaction sa;
sa.sa_sigaction = handler;
sa.sa_flags = SA_SIGINFO;   /* использовать расширенный обработчик */
sigemptyset(&sa.sa_mask);
sigaction(SIGRTMIN + 0, &sa, NULL);

Флаг SA_SIGINFO переключает sigaction на расширенный прототип обработчика: вместо void handler(int sig) используется void handler(int sig, siginfo_t *info, void *ctx). Структура siginfo_t содержит PID отправителя (si_pid), UID (si_uid) и переданное значение (si_value).

На практике сигналы реального времени используются редко. Они полезны в специфических сценариях IPC (Inter-Process Communication, межпроцессное взаимодействие), где нужна гарантия доставки каждого экземпляра, но для большинства задач межпроцессное взаимодействие через сокеты или pipe надёжнее и выразительнее.

Практические паттерны

Graceful shutdown. Серверный процесс (PostgreSQL, Redis, nginx) при получении SIGTERM должен перестать принимать новые соединения, дождаться завершения текущих запросов и освободить ресурсы. Обработчик устанавливает атомарный флаг, основной цикл проверяет его перед каждой итерацией и инициирует завершение. Типичная последовательность при остановке systemd unit: сначала SIGTERM, затем, если процесс не завершился за TimeoutStopSec (по умолчанию 90 секунд), — SIGKILL.

Сбор зомби через SIGCHLD. Серверы, порождающие дочерние процессы, устанавливают обработчик SIGCHLD, который вызывает waitpid(-1, NULL, WNOHANG) в цикле. WNOHANG делает вызов неблокирующим: если завершившихся потомков нет, waitpid() возвращает 0 вместо ожидания. Цикл нужен потому, что между моментом получения SIGCHLD и вызовом waitpid() могут завершиться несколько потомков, а стандартный сигнал доставляется лишь один раз. Альтернатива — signalfd() для SIGCHLD: читать из fd в event loop и вызывать waitpid() синхронно.

Перезагрузка конфигурации через SIGHUP. Исторически SIGHUP (hangup) означал разрыв связи с терминалом. Демоны не привязаны к терминалу, поэтому SIGHUP переназначен по соглашению: при получении демон перечитывает конфигурационный файл без перезапуска. kill -HUP $(pidof nginx) заставляет nginx перечитать nginx.conf. Сам процесс не перезапускается — PID, сокеты, текущие соединения сохраняются.

SIGPIPE и сетевые серверы. По умолчанию write() в закрытый сокет генерирует SIGPIPE и убивает процесс. Для сервера, обслуживающего тысячи клиентов, потеря одного соединения не должна быть фатальной. Стандартное решение — игнорировать SIGPIPE (signal(SIGPIPE, SIG_IGN)) и проверять код возврата write(): при закрытом сокете он вернёт -1 с errno = EPIPE. Так делают практически все серверные фреймворки.

Sources


Lock-free структуры | Отображение памяти