Обработка сигналов в Python: что ревьюить в коде
Обработчик сигнала, который лезет в лог или дёргает asyncio-loop, — частая причина зависшего процесса при деплое. Разбираем, на что смотреть в ревью кода с signal.

Сервис получает SIGTERM при выкатке, но не завершается 30 секунд и его добивает SIGKILL — незакрытые соединения, потерянные задачи, шум в мониторинге. В половине случаев виноват не оркестратор, а обработчик сигнала, который написан так, будто это обычный колбэк. В Python обработчик сигнала работает по особым правилам, и ревьюер, который их не держит в голове, пропускает дефекты, воспроизводящиеся раз в неделю на проде.
Материал — про ревью кода, который вызывает signal.signal, signal.SIGTERM, KeyboardInterrupt и graceful shutdown. Речь про CPython 3.11–3.12 на Linux. Windows и потоки сигналов не поддерживают в том же объёме — если код кроссплатформенный, это отдельная проверка, здесь её не разворачиваем.
Главное правило, из которого растут остальные
Обработчик сигнала в CPython вызывается не в произвольный момент. Интерпретатор ставит флаг и выполняет Python-обработчик между байт-кодами основного потока, в момент проверки pending calls. Отсюда два следствия, которые надо держать в голове на ревью:
- обработчик всегда исполняется в главном потоке, даже если сигнал пришёл во время работы другого;
- между приходом сигнала и вызовом обработчика проходит время — если главный поток застрял в долгом C-вызове (например,
time.sleepв C или блокирующийrecv), обработчик отложится.
Обработчик сигнала — это не «прервать и сделать». Это «поставить флажок и надеяться, что интерпретатор скоро на него посмотрит». Всё, что вы пишете внутри, должно исходить из этого.
Что искать в теле обработчика
1. Небезопасные вызовы внутри handler
Классическая ошибка — логирование прямо из обработчика. logging берёт внутренний лок. Если сигнал пришёл в момент, когда основной поток уже держит этот лок (писал в тот же логгер), возможен дедлок или повреждённая запись.
# Плохо: вызов logging из обработчика сигнала
import signal, logging
logger = logging.getLogger("app")
def handler(signum, frame):
logger.info("got signal %s", signum) # берёт лок, риск дедлока
shutdown()
signal.signal(signal.SIGTERM, handler)В ревью замечание формулируйте конкретно: «Обработчик вызывает logger.info. Логирование берёт внутренний лок, при повторном входе возможен дедлок. Внутри обработчика оставьте только выставление флага».
# Хорошо: обработчик только выставляет флаг
import signal
_should_stop = False
def handler(signum, frame):
global _should_stop
_should_stop = True # присваивание атомарно на уровне байт-кода
signal.signal(signal.SIGTERM, handler)
signal.signal(signal.SIGINT, handler)
# основной цикл
while not _should_stop:
do_one_batch()
# сюда доходим после сигнала — здесь уже можно логировать и закрывать
logger.info("stopping gracefully")
cleanup()2. Что вообще можно делать внутри
Безопасный минимум внутри обработчика:
- присвоить значение переменной (флагу);
- вызвать
set()у объекта изthreading.Event— но с оговоркой, см. ниже; - вызвать
os.writeв заранее открытый пайп (приём self-pipe /signal.set_wakeup_fd).
Опасное: работа с logging, аллокация больших структур, print в буферизованный поток, любые сетевые вызовы, захват локов, вызовы, которые сами могут заблокироваться.
3. Флаг как обычная переменная против re-entrancy
Присваивание _should_stop = True само по себе атомарно. Но если обработчик читает и меняет структуру (например, инкрементит счётчик или дописывает в список), между чтением и записью может прийти второй сигнал и вклиниться. На ревью смотрите: обработчик только пишет простое значение — ок; обработчик делает read-modify-write — повод для вопроса.
Асинхронный код: отдельный набор граблей
С asyncio прямой signal.signal почти всегда ошибка. Обработчик выполнится в главном потоке, но event loop может стоять в epoll_wait, и колбэк не разбудит его сам по себе. Правильный инструмент — loop.add_signal_handler: он использует set_wakeup_fd и гарантированно будит цикл.
# asyncio, Python 3.11+
import asyncio
import signal
async def main():
loop = asyncio.get_running_loop()
stop = asyncio.Event()
# колбэк выполняется в контексте loop, не в signal-контексте
loop.add_signal_handler(signal.SIGTERM, stop.set)
loop.add_signal_handler(signal.SIGINT, stop.set)
server_task = asyncio.create_task(run_server())
await stop.wait() # ждём сигнала
server_task.cancel()
try:
await server_task
except asyncio.CancelledError:
pass
asyncio.run(main())В ревью асинхронного кода флаг тревоги — прямой signal.signal рядом с asyncio.run. Спросите, почему не add_signal_handler. Исключение — Windows, где add_signal_handler не реализован для proactor-цикла; там придётся городить signal.signal плюс пробуждение цикла руками, и это должно быть в комментарии.
Регистрация: где и когда
Только главный поток
signal.signal вызывается из главного потока. Вызов из другого потока даёт ValueError: signal only works in main thread. На ревью проверьте, что регистрация не спрятана в коде, который может выполниться в воркере, в колбэке пула или в импортируемом модуле, который кто-то дёрнет из потока.
Не в импорте модуля
Регистрация сигналов на уровне модуля (в момент import) — плохая практика для библиотек. Библиотека, которая молча перевешивает SIGTERM при импорте, ломает поведение приложения, которое её подключило. Обработчики ставит приложение в точке входа, не библиотека.
Graceful shutdown: что должно быть в коде помимо обработчика
Сам по себе флаг ничего не завершает. Ревьюер проверяет всю цепочку:
- Флаг выставляется — обработчик простой.
- Основной цикл его проверяет — и в разумном интервале, а не раз в минуту.
- Блокирующие ожидания прерываются. Если поток висит в
queue.get()без таймаута или вsock.recv(), флаг он увидит только после разблокировки. Нужен таймаут или пробуждение через self-pipe. - Есть дедлайн. Оркестратор даёт ограниченное окно между SIGTERM и SIGKILL (в Kubernetes по умолчанию
terminationGracePeriodSeconds= 30 c). Код должен уложиться и не ждать вечно закрытия соединений. - Очистка идемпотентна. Два SIGTERM подряд не должны дважды закрывать один пул или дважды коммитить.
Ловушка с blocking I/O
# Проблема: recv блокирует, флаг не проверяется
while not _should_stop:
data = sock.recv(4096) # висим здесь, SIGTERM не разбудит
handle(data)После SIGTERM CPython прерывает системный вызов, и в 3.5+ по умолчанию его автоматически повторяет (PEP 475), если обработчик не бросил исключение. То есть recv сам по себе после сигнала продолжит ждать. Чтобы выйти, обработчик должен либо возбудить исключение, либо цикл должен использовать таймаут/select с проверкой флага. Это тонкий момент — на ревью явно спрашивайте, как код выходит из блокирующего вызова.
KeyboardInterrupt и SIGINT
По умолчанию SIGINT превращается в KeyboardInterrupt в главном потоке. Если код ловит except Exception широким блоком, KeyboardInterrupt туда не попадёт (он наследуется от BaseException, не от Exception) — это как раз хорошо. Но except BaseException или голый except: проглотит Ctrl+C, и процесс перестанет завершаться по нажатию. Ищите такие места отдельно.
Чек-лист ревью
| Что смотреть | Красный флаг |
|---|---|
| Тело обработчика | logging, сеть, аллокации, локи, read-modify-write |
| Поток регистрации | signal.signal вне главного потока |
| Место регистрации | на уровне импорта, особенно в библиотеке |
| asyncio | signal.signal вместо loop.add_signal_handler |
| Блокирующие вызовы | get()/recv() без таймаута в основном цикле |
| Выход из shutdown | нет дедлайна, ожидание закрытия без ограничения |
| Перехват исключений | голый except: или except BaseException |
| Повторный сигнал | очистка не идемпотентна |
Что делать дальше
Проверьте поведение на живом окружении, а не только по коду: пошлите процессу SIGTERM (kill -TERM PID) под нагрузкой и замерьте, за сколько он завершается и что успевает закрыть. Если укладывается в окно оркестратора с запасом — обработка сигналов рабочая. Дальше стоит прочитать раздел документации signal про ограничения обработчиков и PEP 475 про повтор прерванных системных вызовов — это два места, где ломается большинство неочевидных случаев.
AI-стилист и фотограф: Замена фона, ретушь, аватарки, фото для резюме.
Спросить за 20 ₽Источники: Python docs — signal — Set handlers for asynchronous events, PEP 475 — Retry system calls failing with EINTR
Частые вопросы
А если процесс всё равно не завершается по SIGTERM?
У меня Python 3.9, что-то будет отличаться?
Можно ли внутри обработчика вызывать threading.Event().set()?
Почему нельзя логировать прямо из обработчика?
Работает ли всё это на Windows?
SIGKILL можно перехватить обработчиком?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.