Модуль: 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) текущей горутины:

  1. Останавливается нормальное выполнение функции.
  2. Начинают по очереди (LIFO) исполняться все defer, зарегистрированные в этой и вышестоящих функциях.
  3. Если ни один из defer не вызвал recover, раскрутка доходит до вершины стека горутины, рантайм печатает сообщение паники + стектрейс и завершает процесс с кодом 2 (через fatalpanicexit(2)).
  4. Если 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):

  1. Создаётся _panic, кладётся в голову g._panic.
  2. В цикле берётся очередной _defer из g._defer, помечается started = true, в нём запоминается ссылка на текущую _panic.
  3. Отложенная функция вызывается. Если внутри неё происходит recover, он выставляет p.recovered = true.
  4. После возврата из defer’а gopanic проверяет p.recovered. Если true — вызывается recovery, которая «перематывает» стек на sp/pc того фрейма, что зарегистрировал defer, и возвращает управление туда (как нормальный return). Если false — переходим к следующему defer’у.
  5. Если 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):

ОшибкаТип/сообщение
Разыменование nilinvalid 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
Отрицательная длина у makeruntime: ... makeslice: len out of range

НЕ recoverable (fatal errors / throw — обходят механизм panic):

СитуацияПочему нельзя
concurrent map writesРантайм детектит гонку на map и вызывает throw, а не panic. throwfatalthrow, defer’ы НЕ выполняются, recover бессилен.
concurrent map read and writeТо же — throw.
Deadlock (all goroutines are asleep)Обнаруживается планировщиком, throw.
Out of memorythrow.
Stack overflowthrow.
Гонка, найденная 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.21 panic(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+#

  • Понимание panic vs throw. 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 как пример.