Долгоживущий процесс редко умирает своей смертью. Чаще его останавливают: оркестратор выкатывает новую версию, администратор перезапускает сервис, система уходит в перезагрузку. И в этот момент решается судьба того, что демон держал в руках. Незаписанный буфер, открытое соединение с клиентом, наполовину выполненная транзакция. Если процесс просто исчезнет, всё это превратится в потерянные данные и оборванные сессии. Если же он успеет аккуратно свернуться, остановка пройдёт незаметно для всех. Разница между этими двумя исходами умещается в одном системном вызове и понимании того, как ядро доставляет сигналы.

Механизм называется graceful shutdown, корректное завершение. Суть в том, чтобы перехватить сигнал об остановке, прекратить приём новой работы, доделать уже начатую и только потом выйти. Звучит просто, но именно здесь скрыта целая россыпь ловушек, связанных с тем, что обработчик сигнала выполняется в особых, крайне ограниченных условиях.

Чем отличается SIGTERM от SIGKILL и почему это определяет всё

Сигналов завершения в Unix несколько, и путать их нельзя. Команда kill по умолчанию посылает именно SIGTERM, сигнал с номером 15. Он катаемый: процесс вправе установить на него свой обработчик и решить, что делать перед выходом. Это вежливая просьба завершиться, дающая шанс прибраться за собой.

Совсем иначе ведёт себя SIGKILL, девятый сигнал, тот самый kill -9. Его невозможно перехватить, заблокировать или проигнорировать, процесс умирает мгновенно по решению ядра. Сделать SIGKILL безопасным нельзя в принципе, и если корректное завершение для сервиса важно, опираться следует исключительно на SIGTERM. То же касается SIGSTOP, который тоже не перехватывается. Из терминала нажатие Ctrl+C шлёт SIGINT, второй сигнал, который тоже катаемый и обычно обрабатывается заодно с SIGTERM.

Эта иерархия объясняет типичную схему остановки в современных средах. Когда systemd выполняет команду остановки сервиса, он посылает процессу SIGTERM. Если обработчик не установлен, код завершается немедленно. Если же обработчик есть, демон успевает выполнить последние задачи перед выходом: записать и закрыть файл, отправить уведомление, разорвать соединения по-хорошему. Контейнерные среды действуют так же. При остановке контейнера сначала прилетает SIGTERM, затем выдерживается льготный период ожидания, по умолчанию около десяти секунд, и только после него ядро добивает процесс через SIGKILL, который перехватить уже нельзя.

Вывод из этой механики жёсткий. Льготный период конечен. Если демон не уложился в отведённое время, его всё равно прибьют насильно, со всеми последствиями. Поэтому корректное завершение обязано быть ограниченным по времени, а не бесконечным ожиданием идеального момента.

Почему обработчик сигнала нельзя писать как обычную функцию

Здесь кроется главная и наименее очевидная опасность. Обработчик сигнала выполняется асинхронно, прерывая основной поток в произвольной точке. Процесс мог находиться в середине вызова malloc, printf или любой другой функции, удерживающей внутреннюю блокировку. Если обработчик вызовет ту же функцию повторно, он попытается захватить уже захваченную блокировку и навсегда зависнет. Это называется проблемой реентерабельности, и она убила немало демонов тихими взаимоблокировками.

Поэтому существует строгий список так называемых async-signal-safe функций, единственных, которые разрешено вызывать внутри обработчика. В нём нет ни printf, ни malloc, ни большинства привычных вещей. Зато есть write, низкоуровневый системный вызов. Именно поэтому грамотный обработчик не печатает диагностику через printf, а пишет напрямую через write.

Второе правило касается переменной-флага. Обработчик должен делать минимум: выставить флаг и немедленно вернуться. Сам флаг обязан иметь специальный тип, который гарантирует атомарность операции и запрещает компилятору кешировать значение в регистре. Без этого основной цикл может попросту не заметить изменения флага. Вот канонический безопасный обработчик SIGTERM на C:

#include <signal.h>
#include <unistd.h>
#include <string.h>

static volatile sig_atomic_t shutdown_requested = 0;

static void sig_term_handler(int signum) {
    shutdown_requested = 1;
    const char msg[] = "SIGTERM received, shutting down.\n";
    write(STDERR_FILENO, msg, sizeof(msg) - 1);  /* write безопасен в обработчике */
}

Тип volatile sig_atomic_t тут не прихоть, а необходимость. Слово volatile запрещает оптимизатору выбросить чтение флага из основного цикла, а тип sig_atomic_t гарантирует, что запись и чтение происходят целиком, без расщепления на полуоперации.

Чем sigaction надёжнее устаревшего вызова signal

Установить обработчик можно двумя способами, и выбор между ними не вопрос вкуса. Старая функция signal ведёт себя по-разному на разных платформах: где-то после первого срабатывания обработчик сбрасывается на стандартный, где-то нет, поведение прерванных системных вызовов тоже разнится. Современный и переносимый путь только один: системный вызов sigaction. Он даёт полный контроль над флагами и маской блокируемых сигналов. Установка обработчика выглядит так:

#include <signal.h>
#include <stdlib.h>
#include <stdio.h>

static void install_handlers(void) {
    struct sigaction sa;
    memset(&sa, 0, sizeof(sa));
    sa.sa_handler = sig_term_handler;
    sigemptyset(&sa.sa_mask);
    sa.sa_flags = SA_RESTART;          /* перезапускать прерванные вызовы */

    if (sigaction(SIGTERM, &sa, NULL) == -1) { perror("sigaction"); exit(1); }
    if (sigaction(SIGINT,  &sa, NULL) == -1) { perror("sigaction"); exit(1); }
}

Флаг SA_RESTART заслуживает отдельного слова. Когда сигнал прерывает медленный системный вызов вроде чтения из сокета, тот возвращает ошибку с кодом EINTR. С флагом SA_RESTART ядро автоматически перезапускает прерванный вызов, и приложению не нужно ловить EINTR вручную. Иногда, впрочем, поведение без этого флага полезно: код EINTR на блокирующем accept становится сигналом проверить флаг завершения. Оба подхода рабочие, важно лишь сознательно выбрать один.

Как устроен основной цикл, который видит запрос на остановку

Сам по себе флаг бесполезен, если основной цикл его не проверяет. Простейший рабочий каркас демона крутится в цикле до тех пор, пока флаг не поднят, после чего выходит на процедуру уборки:

int main(void) {
    install_handlers();
    printf("Daemon running (PID: %d).\n", getpid());

    while (!shutdown_requested) {
        do_one_work_iteration();   /* обслужить очередную единицу работы */
    }

    cleanup_resources();           /* закрыть файлы, разорвать соединения */
    printf("Graceful shutdown complete.\n");
    return 0;
}

Тонкость в том, что между проверкой флага и засыпанием процесса есть зазор. Если демон спит в блокирующем системном вызове, например ждёт соединение на accept, поднятый флаг он заметит только после пробуждения. Сигнал прервёт этот вызов и вернёт EINTR, после чего цикл проверит флаг и выйдет, что и нужно. Но в сложных событийных циклах с epoll возникает гонка: сигнал может прийти ровно между проверкой флага и входом в ожидание, и тогда процесс уснёт до следующего события, проспав остановку.

Лекарство от этой гонки известно давно и называется трюком с самопроводом, self-pipe trick. Идея элегантна. Программа заводит обычный канал pipe. Обработчик сигнала вместо возни с флагом просто пишет один байт в пишущий конец канала, а это безопасный вызов write. Читающий конец канала добавляется в тот же набор дескрипторов, что слушает основной цикл через epoll. Теперь сигнал превращается в обычное событие готовности дескриптора, и событийный цикл просыпается на нём наравне с сетевыми событиями, без всякой гонки. Этот приём используют зрелые библиотеки событийных циклов именно для того, чтобы обработка сигналов протекала в нормальном потоке цикла, а не в опасном асинхронном контексте.

Из каких шагов складывается само корректное завершение

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

Второй шаг это ожидание завершения работы, которая уже в полёте. Здесь и проявляется ограниченность льготного периода. Ждать незавершённые операции нужно, но с таймаутом. Грамотная схема выглядит как гонка между двумя условиями: либо вся текущая работа доделана, либо истёк отведённый бюджет времени, скажем двадцать пять секунд при тридцатисекундном льготном периоде контейнера. Что наступит первым, то и обрывает ожидание. Запас в несколько секунд между внутренним таймаутом и внешним льготным периодом оставляют намеренно, чтобы успеть выйти самостоятельно до прихода SIGKILL.

Третий шаг это освобождение ресурсов в правильном порядке. Сбросить на диск буферы записи, закрыть файлы, завершить транзакции в базе, разорвать соединения с отправкой признака конца передачи, чтобы клиент понял, что данные кончились штатно, а не оборвались. В средах с уведомлением о состоянии демон дополнительно сообщает менеджеру сервисов, что начал останавливаться, чтобы тот не считал процесс зависшим и не торопился с принудительным убийством.

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

Распространённая ошибка проектирования заключается в том, чтобы запихнуть всю эту логику прямо в обработчик сигнала. Это нарушает правило async-signal-safe и приводит к взаимоблокировкам. Правильный путь обратный: обработчик лишь поднимает флаг или пишет байт в самопровод, а вся реальная уборка происходит в основном потоке после выхода из цикла, в обычном безопасном контексте, где доступны любые функции.

Какие подводные камни всплывают в контейнерах и под init-системами

Контейнерное окружение добавляет коварный слой, о котором легко забыть. Процесс, запущенный как точка входа контейнера, становится процессом с идентификатором 1, то есть init-процессом этого пространства имён. У такого процесса особый статус: ядро не доставляет ему сигналы по умолчанию, если на них не установлен явный обработчик. Демон без обработчика SIGTERM, ставший первым процессом, просто проигнорирует команду остановки и будет насильно убит по истечении льготного периода.

Вторая контейнерная ловушка связана с пробросом сигналов через оболочку. Если контейнер запускает приложение через промежуточный процесс, например через оболочку или менеджер пакетов, сигнал прилетает этому промежуточному звену, а не самому приложению. Оболочка часто не пробрасывает сигнал дочернему процессу, и приложение остановки не видит вовсе. Решение в том, чтобы запускать целевой процесс напрямую как точку входа, минуя оболочку, либо использовать форму запуска, при которой приложение получает сигналы первой рукой.

Связка всех правил в единую картину выглядит так. Перехватывать только катаемый SIGTERM и заодно SIGINT через надёжный sigaction. Держать обработчик предельно коротким, вызывая в нём лишь async-signal-safe функции и поднимая флаг типа volatile sig_atomic_t либо пиша байт в самопровод. Основной цикл проверяет флаг и при необходимости лечится самопроводом от гонки. Уборка живёт в основном потоке после цикла и идёт строго по шагам: прекратить приём, дождаться текущего с таймаутом, освободить ресурсы. И всегда помнить про конечность льготного периода и про особый статус первого процесса в контейнере. Тогда команда системы на остановку перестаёт быть ударом и становится спокойным завершением рабочего дня.