Learning Platform
Глоссарий Troubleshooting
Урок 09.02 · 24 мин
Начальный
IPCSignalskillSIGTERMSIGKILLLinux

Signals — kill, SIGTERM/SIGKILL и async-signal-safety

kill -9 nginx. Ctrl+C в терминале прерывает программу. systemctl restart сначала шлёт SIGTERM, потом SIGKILL если не отвечает. Cron-jobs закрываются через SIGHUP. Это всё signals — старейший Unix-механизм асинхронного уведомления процессов. Сигнал прилетает «извне» — от kernel, от другого процесса, от пользователя — и заставляет процесс реагировать.

Signals — маленький, древний и неожиданно тонкий механизм. Его легко использовать неправильно: shell-скрипт игнорирует Ctrl+C, демон не корректно завершается на SIGTERM, signal handler делает что-то небезопасное и dvbgs ломается на race-condition. Грамотная работа с сигналами — маркер senior-инженера.

В этом уроке: список реально используемых сигналов, что значит kill -N, как процесс может обработать сигнал или проигнорировать, async-signal-safety и почему printf в signal handler — плохая идея, паттерны для graceful shutdown.


Что такое сигнал и какие бывают

Сигнал — это небольшое целое число (обычно 1-31 для стандартных POSIX, 32-63 для real-time), которое kernel доставляет процессу. У каждого сигнала есть default action — что произойдёт, если процесс ничего не обработал:

  • Term — завершить процесс.
  • Core — завершить + сохранить core dump.
  • Ign — игнорировать.
  • Stop — приостановить процесс (но не убить).
  • Cont — продолжить остановленный процесс.

Полный список:

kill -l
# Типичный вывод:
#  1) SIGHUP   2) SIGINT   3) SIGQUIT  4) SIGILL   5) SIGTRAP
#  6) SIGABRT  7) SIGBUS   8) SIGFPE   9) SIGKILL 10) SIGUSR1
# 11) SIGSEGV 12) SIGUSR2 13) SIGPIPE 14) SIGALRM 15) SIGTERM
# 17) SIGCHLD 18) SIGCONT 19) SIGSTOP 20) SIGTSTP ...

Самые часто используемые сигналы и что значат:

Часто используемые сигналы -- что они значат
SIGTERM (15)Стандартный 'вежливо завершись'. Default action: Term. Процесс может перехватить и сделать cleanup (закрыть файлы, дописать данные, корректно отключиться). По умолчанию kill PID шлёт именно это
SIGKILL (9)Force kill. Нельзя перехватить, нельзя проигнорировать, kernel немедленно завершает процесс. Используйте только когда SIGTERM не помог. Файлы могут остаться открытыми, данные могут не сохраниться
SIGINT (2)Interrupt из терминала. Это что прилетает по Ctrl+C. Default: Term. Большинство программ перехватывают и делают graceful exit
SIGHUP (1)Hangup. Исторически: терминал закрылся. Сегодня используется как 'перечитай конфиг': nginx, sshd, syslog по SIGHUP перезагружают config без рестарта
SIGCHLD (17)Child changed state -- ребёнок процесса умер или приостановлен. Родитель должен сделать wait/waitpid, чтобы забрать exit status и не оставить зомби. Игнорируется по дефолту в современных системах
SIGPIPE (13)Write into closed pipe. Дефолт: Term. Часто игнорируется в долгоживущих сервисах (signal(SIGPIPE, SIG_IGN)), чтобы вместо смерти write вернул EPIPE
SIGUSR1/SIGUSR2User-defined: ваше приложение само решает, что делать. nginx использует SIGUSR1 для reopen log files, SIGUSR2 для upgrade без рестарта. Часто используется в скриптах для триггеров
SIGSEGV (11)Segmentation fault. Process trogal недоступную память. Default: Core. По дефолту убивает + создаёт core dump. Это значит баг в программе

kill — отправка сигнала процессу

Команда kill несмотря на имя — это не «убить», а «послать сигнал». По умолчанию шлёт SIGTERM.

# Базовое использование:
kill 12345              # SIGTERM процессу с PID 12345
kill -TERM 12345        # то же самое явно
kill -15 12345          # то же самое, по номеру

kill -9 12345           # SIGKILL -- безусловно убить
kill -SIGKILL 12345     # то же

kill -HUP $(pidof nginx)  # SIGHUP к nginx -- перечитать конфиг

# Несколько процессов разом:
pkill -SIGHUP nginx     # все процессы по имени
killall nginx           # старая команда, то же

# Группа процессов (negative PID = PGID):
kill -9 -1234           # убить всю process group 1234

kill 0 PID (с нулевым сигналом) — стандартный способ проверить, жив ли процесс, без отправки чего-либо:

if kill -0 12345 2>/dev/null; then
    echo "PID 12345 жив"
else
    echo "Уже умер"
fi

Сигналы могут посылаться:

  • От пользователя (kill команда, Ctrl+C, Ctrl+\, Ctrl+Z).
  • От другого процесса (kill() system call).
  • От kernel-а (SIGSEGV при segfault, SIGFPE при делении на 0, SIGCHLD от дочерних процессов).
  • От самого процесса (raise(SIGUSR1), abort() шлёт SIGABRT).

Права: послать сигнал можно только процессам того же пользователя (или root). Поэтому kill 1234 от обычного юзера к чужому процессу даст «Operation not permitted».


Signal handlers — реакция процесса

Процесс может зарегистрировать handler для конкретного сигнала — функцию, которая вызовется при получении.

cat > sig_handler.c << 'CEOF'
#include <signal.h>
#include <stdio.h>
#include <unistd.h>
#include <string.h>

volatile sig_atomic_t got_term = 0;

void handler(int sig) {
    const char msg[] = "Got signal, will exit gracefully\n";
    write(STDERR_FILENO, msg, sizeof(msg) - 1);  // async-signal-safe
    got_term = 1;
}

int main(void) {
    struct sigaction sa = {0};
    sa.sa_handler = handler;
    sigemptyset(&sa.sa_mask);
    sigaction(SIGTERM, &sa, NULL);
    sigaction(SIGINT, &sa, NULL);

    printf("PID %d, waiting for SIGTERM or Ctrl+C...\n", getpid());

    while (!got_term) {
        sleep(1);
    }

    printf("Cleanup done, exit\n");
    return 0;
}
CEOF

gcc sig_handler.c -o sig_handler

# Терминал 1:
./sig_handler
# PID 23456, waiting for SIGTERM or Ctrl+C...
# Got signal, will exit gracefully  <-- после kill из другого терминала
# Cleanup done, exit

# Терминал 2:
kill 23456

Что произошло:

  1. sigaction(SIGTERM, ...) — регистрирует handler. Когда kernel доставит SIGTERM, выполнится handler().
  2. В обычном цикле программа спит. Когда приходит сигнал, kernel прерывает sleep, вызывает handler.
  3. Handler устанавливает flag got_term = 1.
  4. Sleep возвращается с ошибкой EINTR (interrupted by signal). Цикл проверяет flag и выходит.

Тип sig_atomic_t важен — это гарантировано атомарный для записи. volatile нужен, чтобы компилятор не оптимизировал чтение в цикле (иначе он мог бы решить, что переменная не меняется внутри цикла, и закэшировать в регистре).


Async-signal-safety — главная ловушка

Handler — это не обычная функция. Она вызывается посреди произвольной инструкции прерванной программы. Если программа в момент сигнала была внутри malloc и держала lock в аллокаторе — handler не может вызвать malloc, он deadlock-нется на этом же lock-е.

Async-signal-safe функции — те, которые безопасно вызывать из signal handler. Их список фиксирован POSIX-ом и невелик. Из стандартных: write, read, _exit, kill, signal, sigaction, atomic операции, time, getpid.

НЕ async-signal-safe:

  • printf, fprintf, puts (они используют locked stdio buffers).
  • malloc, free (lock в аллокаторе).
  • Большинство higher-level библиотек.
# Демонстрация багa:
cat > bad_handler.c << 'CEOF'
#include <signal.h>
#include <stdio.h>
#include <unistd.h>

void handler(int sig) {
    printf("BAD: printf in handler\n");  // NOT safe!
}

int main(void) {
    signal(SIGINT, handler);
    while (1) {
        printf("main loop\n");  // конкурентно с handler
        usleep(100);
    }
    return 0;
}
CEOF

gcc bad_handler.c -o bad_handler
./bad_handler &
PID=$!
# Спамим SIGINT:
for i in {1..1000}; do kill -INT $PID; done
# Иногда деадлок или порча вывода
kill -9 $PID

Правильный паттерн: handler делает минимум, ставит flag, основной код реагирует:

volatile sig_atomic_t flag = 0;

void handler(int sig) {
    flag = 1;  // только запись в atomic
}

int main(void) {
    signal(SIGTERM, handler);
    while (!flag) {
        // обычная работа
    }
    // cleanup в основном коде
}

Альтернатива в Linux — signalfd или self-pipe trick: handler пишет один байт в pipe, основной event loop читает из этого pipe нормальным read-ом. Это превращает асинхронный сигнал в обычное event на сокет.

WARNING

В Python signal handlers вызываются ТОЛЬКО между Python-инструкциями, не посреди C-кода. Поэтому печать в handler относительно безопасна — но если код висит на C-extension call (например, requests.get), сигнал придёт только после возврата. Поэтому Python иногда ‘не реагирует на Ctrl+C’ во время блокирующих syscalls. Лекарство: использовать asyncio или явный signal.set_wakeup_fd.


Pattern: graceful shutdown

Типичный паттерн для веб-сервера или демона:

Graceful shutdown через SIGTERM
systemctl stop servicesystemd шлёт SIGTERM сервису. Дальше ждёт TimeoutStopSec (по умолчанию 90 сек) и только потом SIGKILL
Service: receive SIGTERMСервис получил SIGTERM. Handler выставил shutdown_flag. Listener thread прекращает accept-ить новые соединения
Drain existing requestsСуществующие запросы дорабатываются. Длинные операции либо завершают, либо отдают partial-результат. Никаких новых не принимаем
Close DB / flushЗакрыть pool соединений с БД. Сохранить state на диск. Дописать буферы лога. Отправить deregister в service registry
exit(0)Чистый выход. systemd видит exit code, удовлетворён. Если не успели за TimeoutStopSec -- получим SIGKILL и кошмар (зомби-локи, не сохранённые данные)

В Python pattern минимум:

import signal
import threading

shutdown = threading.Event()

def handler(sig, frame):
    print(f"Got signal {sig}, shutting down")
    shutdown.set()

signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGINT, handler)

while not shutdown.is_set():
    # обработка запроса с таймаутом
    work_with_timeout(timeout=1)

cleanup()  # закрыть БД и т.д.

Многие фреймворки (FastAPI, gunicorn, kafka-python) уже умеют graceful shutdown. Но если вы пишете кастомного worker-а — это паттерн на каждый день.


Сигналы и kill: практическая работа с SIGTERM, SIGKILL, SIGHUP

SIGCHLD и zombie processes

Когда дочерний процесс умирает, kernel шлёт родителю SIGCHLD. Родитель должен вызвать wait() или waitpid(), чтобы забрать exit status. Если он этого не делает — ребёнок остаётся zombie: процесса нет, но запись в process table висит (со статусом Z в ps).

# Симулируем zombie:
cat > zombie.c << 'CEOF'
#include <stdio.h>
#include <unistd.h>
int main(void) {
    if (fork() == 0) return 0;  // child exits
    printf("Parent sleeps 60 sec without wait()\n");
    sleep(60);  // не делаем wait
    return 0;
}
CEOF

gcc zombie.c -o zombie
./zombie &

# В другом терминале:
ps -ef | grep zombie
# myuser  12345  ...  ./zombie
# myuser  12346  12345 Z+  [zombie] <defunct>
# Видите defunct -- это zombie. Записи нужны kernel-у на случай wait

Лекарство:

// Опция 1: явно собирать через wait
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
    // обработка
}

// Опция 2: игнорировать SIGCHLD -- kernel автоматически уберёт zombie
signal(SIGCHLD, SIG_IGN);

// Опция 3: SA_NOCLDWAIT через sigaction -- то же самое более правильно

В долгоживущих демонах с fork-ами zombie-management обязателен. Это причина, почему nohup, setsid и double-fork паттерн — стандартные приёмы daemonization.


Попробуй сам

Поэкспериментируйте с разными сигналами:

# Запустить sleep в фоне:
sleep 1000 &
SLEEP_PID=$!

# Посмотреть, что в /proc:
cat /proc/$SLEEP_PID/status | grep -E 'State|SigBlk|SigCgt'

# Послать SIGSTOP -- приостановить:
kill -STOP $SLEEP_PID
ps -o pid,stat,cmd $SLEEP_PID
# pid stat  cmd
# 12345 T  sleep 1000  <-- T = stopped

# Продолжить:
kill -CONT $SLEEP_PID
ps -o pid,stat,cmd $SLEEP_PID
# 12345 S  sleep 1000  <-- S = sleeping (running)

# Корректное завершение:
kill $SLEEP_PID

# Что игнорирует процесс:
cat /proc/self/status | grep SigIgn
# SigIgn: 0000000000384004  <-- битовая маска игнорируемых сигналов

Напишите простой сервис с graceful shutdown:

cat > graceful.py << 'EOF'
import signal
import time
import sys

shutdown_requested = False

def handler(signum, frame):
    global shutdown_requested
    print(f"Got signal {signum} ({signal.Signals(signum).name})", flush=True)
    shutdown_requested = True

signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGINT, handler)
signal.signal(signal.SIGHUP, lambda s, f: print("Reloading config (placeholder)", flush=True))

print(f"PID {__import__('os').getpid()} started. Send SIGTERM, SIGINT, SIGHUP")

while not shutdown_requested:
    time.sleep(0.5)

print("Cleanup...", flush=True)
time.sleep(2)
print("Done", flush=True)
EOF

python3 graceful.py &
PID=$!
sleep 1
kill -HUP $PID    # перечитать конфиг (placeholder)
sleep 1
kill -TERM $PID   # graceful shutdown
wait $PID

Проверка знанийKnowledge check
Production-демон на Python обрабатывает Kafka-сообщения. После systemctl stop systemd ждёт 90 секунд и убивает через SIGKILL. Что пошло не так и какие подходы к графичному shutdown'у?
ОтветAnswer
Симптомы 90-секундного таймаута означают, что демон не реагирует на SIGTERM -- либо не зарегистрирован handler, либо он зависает где-то и не достигает exit. Гипотезы и решения: 1) Если используется kafka-python с group.consume() в blocking-режиме -- SIGTERM на C-уровне может прерывать syscall с EINTR, но Python-обёртка может его 'съесть' и продолжить ждать. Лекарство: использовать consumer.poll(timeout_ms=1000) в цикле, проверять shutdown_flag между poll-ами. 2) Если есть многопоточность -- handler может выставлять flag, но threading.Event без timeout-чтения никто не проверяет. Все work_threads должны периодически проверять threading.Event. 3) Если код где-то делает blocking-DB-вызов с очень большим timeout -- shutdown ждёт его завершения. Лекарство: cancel pending requests при shutdown_flag через connection.close() / interrupt. 4) Если процесс делает batch-commit Kafka offsets, и batch занимает > 90 секунд -- нужно увеличить TimeoutStopSec в unit-файле (например, 300) ИЛИ уменьшать batch size. 5) Стандартный паттерн для надёжного shutdown: register handler -> set Event -> везде в коде проверять Event и быстро выходить из циклов -> в финальном блоке flush kafka offset, close connections. 6) Если ничего не помогает и SIGKILL неизбежен -- в Kafka использовать auto.offset.commit с разумным интервалом, чтобы при kill потерять минимум сообщений (они переобработаются после рестарта, должны быть idempotent).

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 5. В чём принципиальное отличие SIGTERM от SIGKILL?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 4