Python: print() в обработчике сигналов может уронить программу

Автор технического блога разбирает частный, но показательный вопрос про сигналы в Python: безопасно ли вызывать print() внутри обработчика сигнала. Отправная точка, устройство CPython. В языке C обработчик сигнала выполняется прямо в момент прерывания, внутри низкоуровневого обработчика, и поэтому обязан подчиняться жёстким ограничениям на то, какие функции вообще можно вызывать. CPython устроен иначе: пользовательский Python-обработчик не вызывается внутри низкоуровневого C-обработчика сигнала, где эти ограничения как раз действуют, а откладывается на более поздний момент, когда интерпретатор находится в согласованном состоянии, поэтому большинство C-шных ограничений на «сигнал-безопасные» функции к Python-обработчикам обычно не применяется.

Но у самой Python-модели есть собственная особенность: её обработчики сигналов неожиданно реентерабельны. Если новый сигнал приходит, пока обработчик уже выполняется, тот же обработчик может быть вызван повторно прямо внутри ещё не завершившегося первого вызова, вызовы вкладываются друг в друга. Чтобы проверить, что это значит для print(), автор ставит прямой эксперимент: обработчик сигнала SIGUSR1 состоит из единственной строки, print("signal received"); из того же процесса запускается подпроцесс, который командой for x in {1..50}; do kill -USR1 <pid>; done посылает этому же процессу 50 сигналов SIGUSR1 подряд, один за другим.

На машине автора это привело к падению программы. В трассировке трижды подряд повторяется один и тот же кадр, вызов print("signal received") изнутри sighandler, после чего Python сообщает, что предыдущая строка повторилась ещё 2 раза, и завершает выполнение ошибкой: RuntimeError: reentrant call inside <_io.BufferedWriter name=''>. Иными словами, собственная защита Python от повторного входа в буферизованный объект stdout сработала и поймала вложенный вызов обработчика, попытавшийся писать в поток в тот момент, когда предыдущая запись ещё не завершилась.

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

Ключевые факты

  • Автор блога проверяет, безопасно ли вызывать print() внутри Python-обработчика сигналов, и напоминает: CPython откладывает вызов пользовательского обработчика до момента, когда интерпретатор в согласованном состоянии, поэтому строгие C-шные ограничения на «сигнал-безопасные» функции к Python обычно не применяются.
  • При этом Python-обработчики сигналов неожиданно реентерабельны: если новый сигнал приходит, пока обработчик уже выполняется, тот же обработчик может быть вызван повторно прямо внутри ещё не завершившегося первого вызова.
  • В демонстрационном коде обработчик сигнала SIGUSR1 состоит из одной строки, print("signal received"); отдельный подпроцесс командой for x in {1..50}; do kill -USR1 <pid>; done посылает этому же процессу 50 сигналов SIGUSR1 подряд.
  • На машине автора это привело к падению: трассировка показывает трижды повторённый кадр вызова print внутри sighandler, отметку, что предыдущая строка повторилась ещё 2 раза, и итоговую ошибку RuntimeError: reentrant call inside <_io.BufferedWriter name=''>.
  • Автор считает это крайним случаем, а не практической проблемой: реальная программа вряд ли столкнётся с такими условиями, а падение с исключением, более приемлемый исход, чем возможные последствия небезопасных обработчиков в C (взаимная блокировка, повреждение данных, тихие сбои); тем не менее автор по-прежнему советует не делать в обработчиках сигналов ничего сложного.

Почему это важно

Python подаёт сигналы иначе, чем C: пользовательский Python-обработчик выполняется не внутри низкоуровневого C-обработчика, где действуют жёсткие ограничения на вызываемые функции, а откладывается до момента, когда интерпретатор в согласованном состоянии. Из-за этого у Python-разработчиков может сложиться впечатление, что писать что угодно внутри обработчика сигнала, в том числе print(), полностью безопасно. Материал уточняет эту картину: у самой Python-модели есть собственная особенность, обработчики неожиданно реентерабельны, то есть при шквале сигналов один и тот же обработчик может быть вызван повторно ещё до завершения предыдущего вызова, и это может столкнуться с внутренней защитой Python от повторного входа в буферизованный поток stdout.

Кому это важно

В первую очередь, разработчикам на Python, которые пишут собственные обработчики сигналов и вызывают print() или другую запись в поток изнутри такого обработчика. Также это интересно тем, кто строит демоны и долгоживущие сервисы, способные получать сигналы очень часто и подряд, то есть именно ту ситуацию, которую в материале воспроизводит шквал из 50 сигналов SIGUSR1, и всем, кто хочет точнее понимать разницу между сигнал-безопасностью в C и моделью обработки сигналов в CPython.

Как это применить

Автор не даёт пошаговой инструкции, но обозначает практический вывод: если обработчик сигнала может быть вызван повторно во время шквала сигналов, а внутри него стоит print() или похожая операция с буферизованным потоком, теоретически возможна аварийная остановка программы ошибкой RuntimeError. Общий совет автора, не делать внутри обработчика сигнала ничего нетривиального; эту рекомендацию автор подтверждает на конкретном примере. В тексте не указано, на какой версии Python или операционной системе ставился эксперимент, поэтому вывод стоит воспринимать как качественное свойство модели сигналов CPython, а не как результат, привязанный к конкретному релизу.

Можно ли доверять

Источник, личный технический блог с прямым воспроизводимым примером кода: автор приводит ровно тот скрипт на Python и ту команду в shell, которыми получил падение программы, так что результат можно проверить самостоятельно. При этом в тексте нет имени автора, версии Python или операционной системы, на которой ставился эксперимент, оценить квалификацию автора или воспроизводимость на других конфигурациях по одному материалу нельзя. Само устройство CPython, при котором вызов пользовательского обработчика откладывается за пределы низкоуровневого C-обработчика, в тексте подаётся как известная особенность языка; экспериментальная часть про реентерабельность и RuntimeError, самостоятельная проверка автора, представленная как воспроизводимый код, но без внешней проверки или рецензирования.

Риски и подводные камни

Сам автор ограничивает масштаб находки: для падения нужны экстремальные условия, шквал из полусотни сигналов подряд, попадающий точно в момент ещё не завершившегося вызова print, и, по словам автора, маловероятно, что обычная программа вообще в них попадёт. Даже тогда аварийная остановка с понятным исключением, по словам автора, более приемлемый исход, чем то, к чему приводит небезопасная работа с сигналами в C: взаимная блокировка, повреждение структур данных или тихие сбои без единого сообщения об ошибке. В тексте не названы никакие другие функции, кроме print(), как небезопасные или безопасные для вызова из обработчика сигнала, поэтому распространять вывод на другой код без собственной проверки не стоит. Данных о том, как часто такая ситуация вообще возникает в реальных программах, источник не приводит.

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

— автор материала на iafisher.com