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 ...
Самые часто используемые сигналы и что значат:
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
Что произошло:
sigaction(SIGTERM, ...)— регистрирует handler. Когда kernel доставит SIGTERM, выполнитсяhandler().- В обычном цикле программа спит. Когда приходит сигнал, kernel прерывает sleep, вызывает handler.
- Handler устанавливает flag
got_term = 1. - 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 на сокет.
В Python signal handlers вызываются ТОЛЬКО между Python-инструкциями, не посреди C-кода. Поэтому печать в handler относительно безопасна — но если код висит на C-extension call (например, requests.get), сигнал придёт только после возврата. Поэтому Python иногда ‘не реагирует на Ctrl+C’ во время блокирующих syscalls. Лекарство: использовать asyncio или явный signal.set_wakeup_fd.
Pattern: graceful shutdown
Типичный паттерн для веб-сервера или демона:
В 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