Программирование GoОбработка ошибокGo-разработчик серверных приложений

В сервисе на Go 1.20 обработчик считает nil от recover признаком отсутствия паники: что произойдёт после pa...

В сервисе на Go 1.20 обработчик считает nil от recover признаком отсутствия паники: что произойдёт после panic(nil)?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

В Go 1.20 и более ранних версиях recover после panic(nil) возвращает nil, но саму панику останавливает. Поэтому обработчик может ошибочно решить, что паники не было, хотя выполнение функции уже прервано и управление перейдёт к вызывающему коду.

Начиная с Go 1.21 по умолчанию recover возвращает ненулевое значение типа *runtime.PanicNilError, что устраняет эту неоднозначность.

Исторический контекст

Изначально panic(nil) сохраняла переданное значение nil, а recover возвращала его без изменений. Это создавало проблему: результат nil был неотличим от ситуации, когда текущая горутина вообще не находится в состоянии паники.

В Go 1.21 поведение изменили для устранения этой неоднозначности: при panic(nil) runtime подставляет специальное ненулевое значение. Для временной совместимости со старым поведением предусмотрен режим GODEBUG=panicnil=1.

Постановка проблемы

Обычно обработчик паники проверяет результат recover, чтобы решить, нужно ли логировать сбой, возвращать ошибку или завершать запрос с ошибкой. В старой версии Go проверка только на nil не позволяла отличить panic(nil) от отсутствия паники.

Это опасно не тем, что паника продолжится: при корректном вызове recover она будет остановлена. Риск заключается в неверной диагностике и ошибочном успешном сценарии обработки, например в отсутствии записи в журнале или метрики.

Подробное решение

Если deferred-функция непосредственно вызывает recover во время раскрутки стека, runtime прекращает распространение паники. В Go 1.20 результатом для panic(nil) является nil, поэтому такой код не может по одному результату определить, была ли восстановлена именно эта паника:

package main import "fmt" func run() { defer func() { if r := recover(); r == nil { fmt.Println("паники не было") } }() panic(nil) }

В Go 1.20 программа напечатает паники не было, хотя panic(nil) была вызвана и остановлена. В Go 1.21 и новее при стандартной настройке будет получено ненулевое значение *runtime.PanicNilError, поэтому проверка r == nil распознает факт паники.

Нельзя безоговорочно полагаться на конкретное текстовое представление этого значения. Если обработчику важен сам факт восстановления, достаточно проверить ненулевой результат; если нужно классифицировать причину, следует проектировать собственные типы паники или явно документировать допустимые значения.

Практическое ограничение сохраняется независимо от версии: recover работает только при прямом вызове из deferred-функции той же горутины, которая паникует. Перенос вызова в обычную вспомогательную функцию или другую горутину не делает его универсальным перехватчиком.

Ситуация из практики

В HTTP-сервисе на Go 1.20 middleware перехватывал паники и отправлял метрику только при ненулевом результате recover. Один обработчик использовал panic(nil) как сигнал аварийного завершения внутренней операции. Middleware останавливал панику, но не фиксировал инцидент и мог оставить вызывающий код без ожидаемого сообщения об ошибке.

Рассматривались три варианта. Первый — запретить panic(nil) соглашением команды: это просто и совместимо со старыми версиями, но не защищает от стороннего или уже существующего кода. Второй — перейти на Go 1.21: это устраняет неоднозначность, но требует проверки совместимости окружения и сборочного конвейера. Третий — использовать собственное ненулевое значение для паники: это работает в старых версиях, но требует дисциплины и не исправляет случаи произвольного panic(nil).

Выбранный вариант — обновление до Go 1.21 и одновременный запрет panic(nil) в прикладном коде. Middleware стал отличать восстановленную панику от отсутствия паники, а явные типы причин сделали диагностику предсказуемой.

Что кандидаты часто упускают

  1. Останавливает ли recover, возвращающий nil, панику в Go 1.20?

Да. Важен не только результат, но и контекст вызова: если recover вызван непосредственно из deferred-функции во время паники, он прекращает раскрутку стека даже тогда, когда возвращает nil. Ошибка заключается именно в неверной интерпретации результата, а не в продолжении паники.

  1. Можно ли считать panic(nil) безопасной заменой возврату error?

Нет. panic предназначена для аварийного нарушения нормального управления или внутренних инвариантов, а не для обычного распространения ожидаемых отказов. panic(nil) дополнительно усложняет диагностику и поведение зависит от версии Go, поэтому для штатных ошибок следует возвращать error.

  1. Что изменится при GODEBUG=panicnil=1 в Go 1.21 и новее?

Будет восстановлено старое поведение: recover после panic(nil) снова вернёт nil. Код, который считает ненулевой результат единственным признаком паники, в таком режиме снова станет неоднозначным. Поэтому этот параметр следует рассматривать как средство совместимости, а не как основу нового дизайна обработки паник.