Модуль: Core Go · Уровень: Senior
TL;DR#
panic начинает раскрутку стека текущей горутины, по пути выполняя зарегистрированные defer. recover останавливает раскрутку и возвращает значение паники, но работает только при прямом вызове из отложенной функции — иначе возвращает nil и эффекта не имеет. Паника в любой горутине, не пойманная её собственным defer/recover, убивает весь процесс — поймать её из другой горутины нельзя. Часть runtime-ошибок (nil deref, index out of range) — это обычные паники и их можно восстановить; часть (concurrent map writes, deadlock, нехватка памяти) — это fatal errors и они принципиально не recoverable.
Теория#
Что такое panic и recover на уровне семантики#
panic(v) — это не «бросок исключения» в классическом смысле. Это запуск процедуры аварийной раскрутки стека (stack unwinding) текущей горутины:
- Останавливается нормальное выполнение функции.
- Начинают по очереди (LIFO) исполняться все
defer, зарегистрированные в этой и вышестоящих функциях. - Если ни один из
deferне вызвалrecover, раскрутка доходит до вершины стека горутины, рантайм печатает сообщение паники + стектрейс и завершает процесс с кодом 2 (черезfatalpanic→exit(2)). - Если
deferвызвалrecover, паника считается «погашенной»: раскрутка останавливается, и выполнение продолжается в вызывающей функции того defer’а (как будто эта функция нормально вернулась).
recover() возвращает:
- значение, переданное в
panic(...), если в данный момент горутина паникует иrecoverвызван корректно; nil— если паники нет, ИЛИ еслиrecoverвызван не из defer’а напрямую, ИЛИ если вpanic(nil)передали nil (с Go 1.21 это превратилось в*runtime.PanicNilError, чтобы убрать неоднозначность — см. ниже).
recover работает ТОЛЬКО напрямую из defer#
Это ключевой и часто проваливаемый на собесе момент. recover имеет эффект, только когда:
- он вызван внутри функции, которая запущена механизмом
defer, и - вызван непосредственно в теле этой отложенной функции, а не во вложенном вызове.
func bad() {
defer func() {
helper() // recover внутри helper НЕ сработает
}()
panic("boom")
}
func helper() {
if r := recover(); r != nil { // r == nil, паника НЕ погашена
fmt.Println("recovered:", r)
}
}func good() (err error) {
defer func() {
if r := recover(); r != nil { // прямой вызов из defer — работает
err = fmt.Errorf("recovered: %v", r)
}
}()
panic("boom")
}Почему так? recover смотрит на runtime-структуры текущей горутины (g) и проверяет, что он вызван ровно из той функции, что зарегистрирована как defer для паникующего фрейма. Под капотом компилятор для recover передаёт «caller PC/SP», и рантайм (gorecover) сверяет, что вызывающий фрейм — это именно фрейм исполняемого сейчас _defer, у которого started == true, а его _panic ещё активна. Если вызов пришёл из произвольной вложенной функции — условие не выполняется, и возвращается nil.
Структуры _panic и _defer под капотом#
Каждая горутина (runtime.g) хранит два связных списка-стека:
g._defer— список отложенных вызовов (runtime._defer), LIFO.g._panic— список активных паник (runtime._panic), тоже стек (re-panic вложен в обработку предыдущей).
Упрощённо (актуально для современных версий рантайма):
type _defer struct {
started bool // defer уже начал исполняться (в ходе паники)
heap bool // аллоцирован в куче (open-coded defer — на стеке/в регистрах)
sp uintptr // стек-поинтер фрейма, который зарегистрировал defer
pc uintptr // адрес возврата
fn func() // сама отложенная функция
link *_defer // следующий в списке
}
type _panic struct {
arg any // аргумент panic(...)
link *_panic // предыдущая паника (для re-panic / вложенных)
recovered bool // была ли погашена recover'ом
aborted bool // прервана (например, Goexit поверх паники)
// + поля для goexit, sp/pc, reraise и т.д.
}Алгоритм раскрутки (runtime.gopanic):
- Создаётся
_panic, кладётся в головуg._panic. - В цикле берётся очередной
_deferизg._defer, помечаетсяstarted = true, в нём запоминается ссылка на текущую_panic. - Отложенная функция вызывается. Если внутри неё происходит
recover, он выставляетp.recovered = true. - После возврата из defer’а
gopanicпроверяетp.recovered. Если true — вызываетсяrecovery, которая «перематывает» стек на sp/pc того фрейма, что зарегистрировал defer, и возвращает управление туда (как нормальный return). Если false — переходим к следующему defer’у. - Если defer’ы кончились —
fatalpanic: печать иexit(2).
Про open-coded defers (с Go 1.14): для горячего пути компилятор не аллоцирует _defer в куче, а инлайнит логику defer’ов прямо в функцию с битовой маской «какие defer’ы активны». Но при панике рантайму всё равно нужно их найти — для этого в метаданных функции (funcdata) лежит информация, позволяющая gopanic восстановить список открыто-кодированных defer’ов. Это даёт ~near-zero стоимость defer в обычном потоке и сохраняет корректность при панике.
Раскрутка стека: что важно#
- defer’ы выполняются в порядке LIFO по мере раскрутки.
- Именованные возвращаемые значения можно поменять из defer’а — это единственный способ «вернуть error» после recover (см.
good()выше). - Раскрутка идёт только по текущей горутине. Фреймы других горутин не трогаются.
Паника и горутины#
Паника изолирована в рамках своей горутины, но её последствия — нет.
func main() {
go func() {
panic("in goroutine") // никто не recover'ит
}()
time.Sleep(time.Second)
// программа уже мертва: процесс завершён с кодом 2
}- Нельзя поймать панику чужой горутины из
mainили из любой другой горутины. У каждой горутины свой стек и свой список_defer/_panic. - Любая непойманная паника в любой горутине завершает весь процесс. Это сознательное проектное решение: невосстановленная паника = сломанный инвариант, продолжать опасно.
- Практический вывод: если вы запускаете горутину, которая может паниковать (особенно из недоверенного/плагинного кода), оборачивайте её тело в
defer recover()внутри самой горутины.
func safeGo(f func()) {
go func() {
defer func() {
if r := recover(); r != nil {
log.Printf("goroutine panic: %v\n%s", r, debug.Stack())
}
}()
f()
}()
}Когда panic уместен#
- Невосстановимые состояния: нарушен инвариант, при котором продолжать бессмысленно или опасно (повреждение данных).
- Программерские баги: «этого не может быть» — например
default:в switch по перечислению, которое должно быть исчерпывающим. Лучше упасть громко, чем тихо продолжить в неверном состоянии. - Инициализация: в
init()или при старте, когда без ресурса программа всё равно не сможет работать (regexp.MustCompile,template.Must, парсинг обязательного конфига). Падение на старте лучше, чем падение в проде через час. - Внутренний control-flow в пределах пакета (редко, осознанно) — см. ниже про парсеры.
Правило большого пальца: ошибки, которые вызывающий может разумно обработать → возвращаем error. Баги и невозможные состояния → panic.
Почему паники в библиотеках обычно зло#
Публичный API библиотеки не должен паниковать на «обычных» ошибках ввода — это нарушает контроль над потоком исполнения у пользователя и ломает композицию. Исключения, признанные сообществом нормальными:
Must*-функции (regexp.MustCompile,template.Must): паникуют только на аргументах, известных на этапе компиляции/инициализации, где ошибка = баг программиста.- Паника на полностью невалидном использовании API (например, отрицательная capacity у
make— это рантайм). - Внутренняя паника, которая не пересекает границу пакета: пакет паникует внутри себя для control-flow, но на публичной границе ловит её и конвертирует в
error(так делаетencoding/json).
Важно: json.Marshal/Unmarshal не паникует на пользовательских данных — он возвращает error. Внутри он использует панику как control-flow (jsonError), но recoverит её в marshal/unmarshal и отдаёт наружу как обычную ошибку. Это эталонный паттерн «паника внутри — error снаружи».
Re-panic#
В defer’е после recover можно перевозбудить панику — паникнуть снова. Применяется, когда вы хотите обработать только определённый класс паник, а остальные пробросить дальше.
defer func() {
r := recover()
if r == nil {
return
}
if e, ok := r.(MyExpectedError); ok {
handle(e)
return
}
panic(r) // не наша паника — пробрасываем дальше
}()Под капотом: новая panic создаёт новый _panic и связывает с предыдущим через link. В стектрейсе вы увидите оба: «panic: … [recovered]» и затем «panic: …».
Паника как control flow (антипаттерн, но встречается)#
Иногда панику используют как «длинный return» через много уровней рекурсии — типично в рекурсивных парсерах/интерпретаторах, где протаскивать error через каждый уровень громоздко. Канонический пример — парсер из «Go Programming Language» (Donovan/Kernighan) и внутренности encoding/json, text/template, go/....
Правила, если уж делаете так:
- паника должна иметь приватный тип (sentinel), чтобы не перехватить чужую;
- она никогда не должна покидать пакет — на границе обязательно
recoverи конвертация вerror; - любую «не свою» панику в этом recover нужно re-panic’нуть.
Runtime-паники: что можно и нельзя восстановить#
Runtime сам генерирует паники для ряда ошибок. Их тип — runtime.Error (интерфейс), конкретно часто *runtime.TypeAssertionError, runtime.boundsError и т.п.
Recoverable (обычные паники, тип runtime.Error):
| Ошибка | Тип/сообщение |
|---|---|
| Разыменование nil | invalid memory address or nil pointer dereference (runtime.Error, signal SIGSEGV перехватывается рантаймом) |
| Выход за границы | index out of range [i] with length n |
| Срез вне диапазона | slice bounds out of range |
| Неверное приведение типа | interface conversion: ... (*runtime.TypeAssertionError) |
| Деление на ноль (целые) | integer divide by zero |
| Закрытие закрытого канала / запись в закрытый | close of closed channel / send on closed channel |
| Отрицательная длина у make | runtime: ... makeslice: len out of range |
НЕ recoverable (fatal errors / throw — обходят механизм panic):
| Ситуация | Почему нельзя |
|---|---|
concurrent map writes | Рантайм детектит гонку на map и вызывает throw, а не panic. throw → fatalthrow, defer’ы НЕ выполняются, recover бессилен. |
concurrent map read and write | То же — throw. |
Deadlock (all goroutines are asleep) | Обнаруживается планировщиком, throw. |
| Out of memory | throw. |
| Stack overflow | throw. |
| Гонка, найденная race detector’ом | Завершает процесс, не паника. |
nil map assignment (assignment to entry in nil map) | Это, наоборот, обычная паника и recoverable — не путать с concurrent map. |
Принцип: всё, что рантайм считает «программа в принципе в неконсистентном/небезопасном состоянии и продолжать нельзя» → throw/fatal → не ловится. Всё, что «локальная логическая ошибка» → panic → ловится.
// concurrent map write — recover НЕ поможет
func main() {
defer func() { recover() }() // бесполезно
m := map[int]int{}
for i := 0; i < 8; i++ {
go func() { for { m[1] = 1 } }()
}
select {} // fatal error: concurrent map writes
}recover в HTTP middleware#
Классическое легитимное применение recover на верхнем уровне обработки запроса: одна паника в одном хендлере не должна ронять весь сервер.
func Recover(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
// ErrAbortHandler — спец-значение net/http, его пробрасываем
if rec == http.ErrAbortHandler {
panic(rec)
}
log.Printf("panic: %v\n%s", rec, debug.Stack())
http.Error(w, "internal server error", http.StatusInternalServerError)
}
}()
next.ServeHTTP(w, r)
})
}Нюансы:
net/httpсервер сам имеет recover на каждый запрос — паника в хендлере не уронит сервер по умолчанию, но соединение разрывается, а лог куцый. Свой middleware даёт нормальный ответ клиенту и полный стектрейс.http.ErrAbortHandler— специальная паника, которой хендлер сигнализирует «прервать без логирования»; её нужно пробрасывать (re-panic), а не глотать.- recover в middleware не поймает панику из горутины, которую хендлер запустил сам (
go ...) — она в другой горутине и уронит весь процесс. - Если уже начали писать тело ответа (
WriteHeader),http.Errorпосле паники не сможет поменять статус-код.
Подводные камни / gotchas#
- recover не из defer’а напрямую — самая частая ошибка.
recover()в обычном коде или во вложенном вызове внутри defer’а вернётnilи ничего не погасит. - Паника в горутине игнорирует чужие recover’ы.
defer recover()вmainне спасёт от паники вgo func(). Ловить надо внутри самой горутины. panic(nil): до Go 1.21 это приводило к тому, чтоrecover()возвращалnil, и нельзя было отличить «не паниковали» от «паниковали с nil». С Go 1.21panic(nil)превращается вpanic(&runtime.PanicNilError{}), иrecoverвернёт ненулевое значение (поведение управляетсяGODEBUG=panicnil=1для совместимости).- concurrent map writes не ловится — частый сюрприз: люди оборачивают всё в recover и удивляются, что процесс всё равно падает. Это
throw, неpanic. - recover «съедает» все паники, включая баги.
defer func(){ recover() }()без анализа значения маскирует реальные ошибки. Всегда логируйте и/или re-panic неожиданное. - Именованный return обязателен для возврата ошибки из recover. Без именованного результата defer не сможет повлиять на возвращаемое значение.
os.Exitне выполняет defer’ы — и, соответственно, никакой recover. Это не паника, это немедленный выход.- Goexit + panic:
runtime.Goexitтоже раскручивает стек и исполняет defer’ы; если в defer’е во время Goexit паникнуть и не восстановить — fatal. Взаимодействие тонкое, всплывает в тестах (t.FailNowиспользует Goexit). - debug.Stack() в recover даёт стек на момент вызова в defer’е (уже частично раскрученный), но обычно его достаточно; для точного места паники лучше
debug.PrintStackсразу или настроитьGOTRACEBACK. - panic печатает не error, а значение через его форматирование. Паника произвольным типом усложняет анализ; для control-flow используйте типизированную приватную обёртку.
Вопросы на собеседовании#
В: Почему recover нужно вызывать именно напрямую из defer-функции, а не из вложенного вызова?
О: Потому что gorecover в рантайме сверяет caller’а recover’а с фреймом текущего исполняемого _defer, у которого started=true и который связан с активной _panic. При прямом вызове из отложенной функции это совпадает, и recover помечает _panic.recovered=true. При вызове из вложенной функции фрейм не совпадает — рантайм считает, что recover вызван «не там», и возвращает nil, паника продолжает раскрутку.
В: Что происходит при панике в горутине без recover? Можно ли поймать её снаружи?
О: Раскручивается стек этой горутины, исполняются её defer’ы; если ни один не сделал recover, рантайм вызывает fatalpanic и завершает весь процесс с кодом 2. Поймать из другой горутины нельзя — у каждой горутины свой стек и свои _defer/_panic. Ловить нужно внутри самой горутины.
В: Какие runtime-ошибки можно восстановить через recover, а какие нет?
О: Восстанавливаются обычные паники типа runtime.Error: nil dereference, index/slice out of range, неудачное type assertion, деление на ноль, send/close на закрытом канале, assignment to nil map. НЕ восстанавливаются fatal errors, которые рантайм возбуждает через throw/fatalthrow: concurrent map writes/read-write, deadlock, OOM, stack overflow, находки race detector’а. throw не исполняет defer’ы и игнорирует recover, потому что состояние программы считается небезопасным.
В: Зачем encoding/json использует панику внутри, если публично возвращает error?
О: Внутри парсинга/кодирования удобно прервать глубокую рекурсию одной «длинной» паникой приватного типа (jsonError), не протаскивая error через каждый уровень. На границе пакета (marshal/unmarshal) стоит recover, который ловит только свой приватный тип, конвертирует в error, а любую чужую панику re-panic’ит. Это эталон: паника как внутренний control-flow, error — как внешний контракт.
В: Что вернёт recover() и в каких случаях nil?
О: Возвращает значение, переданное в panic, если горутина сейчас паникует и recover вызван корректно из defer’а. Возвращает nil, если: паники нет; recover вызван не из defer’а напрямую; либо (до Go 1.21) была panic(nil). С Go 1.21 panic(nil) даёт *runtime.PanicNilError, и recover вернёт его, а не nil.
В: Как корректно превратить панику в возвращаемую ошибку?
О: Использовать именованный возвращаемый параметр и в defer’е сделать if r := recover(); r != nil { err = ... }. Только именованный результат можно изменить из отложенной функции; обычный return-фрейм уже зафиксирован. Желательно различать runtime.Error, свои типы и неизвестные паники (последние лучше re-panic).
В: Как устроена связь _panic и _defer в рантайме при раскрутке?
О: У горутины два стека: g._defer и g._panic. gopanic кладёт новый _panic в голову, затем в цикле берёт defer’ы (LIFO), помечает started, исполняет. После каждого defer’а проверяет recovered: если true — вызывает recovery, перематывает SP/PC на фрейм, зарегистрировавший defer, и возвращает туда управление. Re-panic создаёт новый _panic, связанный через link. Если defer’ы исчерпаны без recover — fatalpanic.
В: В чём смысл recover-middleware в HTTP, если сервер сам ловит паники?
О: net/http действительно recover’ит панику на запрос, но отдаёт клиенту разорванное соединение и пишет скудный лог. Собственный middleware даёт контролируемый ответ (500), полный стектрейс через debug.Stack(), метрики/алерты, и корректно пробрасывает http.ErrAbortHandler. Важно помнить, что middleware не поймает панику из горутины, запущенной хендлером.
В: Чем panic/recover отличается от исключений в Java/C++?
О: Это не штатный механизм обработки ошибок и не предназначен для control-flow между модулями. Нет иерархии «классов исключений», нет catch по типу на уровне языка (только type switch внутри recover), recover ограничен defer’ом и текущей горутиной, а идиоматичный способ сообщать об ошибках — возвращаемый error. Паника зарезервирована для багов и невосстановимых состояний.
На что копают на senior+#
- Понимание
panicvsthrow. Senior чётко разделяет recoverable паники (runtime.Error) и fatal errors (fatalthrowбез defer’ов). Follow-up: «почему concurrent map write нельзя поймать, а nil map assignment можно?» — ответ про safety vs локальную логическую ошибку. - Open-coded defers. Знание, что с Go 1.14 defer в горячем пути почти бесплатен и как рантайм восстанавливает их при панике через
funcdata, отличает поверхностное знание от глубокого. - Стоимость и аллокации.
_deferв куче vs на стеке/в регистрах; влияние паники на инлайнинг; почему добавление recover может «расхолодить» функцию (мешает оптимизациям defer’ов). - Корректный re-panic и фильтрация. Senior никогда не делает «глухой»
recover(), который глотает всё; он типизирует свою панику и пробрасывает чужую. Follow-up: «что будет в стектрейсе после re-panic» ([recovered]+ новая паника, связанные черезlink). - Горутины и graceful degradation. Обсуждение worker-pool’ов: каждая worker-горутина должна иметь свой recover, иначе один плохой таск роняет весь сервис; как при этом не потерять задачу и залогировать.
panic(nil)и эволюция семантики (Go 1.21,GODEBUG=panicnil). Знание историй совместимости показывает, что человек следит за рантаймом.- Goexit/тесты. Тонкое взаимодействие
runtime.Goexit,t.FailNow, паник в defer’ах — всплывает при отладке зависающих/падающих тестов. - Граница пакета как контракт. Способность сформулировать правило «паника не пересекает публичную границу пакета, кроме осознанных Must*» и привести
encoding/jsonкак пример.