Defer, Panic, Recover: внутренности
Defer-panic-recover — это один из самых нетривиальных механизмов Go. На уровне Junior достаточно знать, что defer выполняется в LIFO порядке, а recover ловит panic. На Middle 2 от тебя ждут понимания: как именно работает open-coded defer (Go 1.14+), сколько ns overhead у defer на разных версиях, как именно происходит размотка стека при panic, в каких случаях recover не помогает. На собеседованиях это часто “трюковые” вопросы из категории “что напечатает код?”. Авито любит вопросы про порядок вычисления аргументов defer. Тинькофф — про panic в горутине. Яндекс — про open-coded defer optimization.
Содержание
Заголовок раздела «Содержание»- Базовая концепция defer/panic/recover (для разогрева)
- Глубокое погружение: old defer, open-coded defer, panic internals
- Подводные камни
- Производительность и реальные кейсы
- Вопросы на собесе Middle 2
- Practice
- Источники
1. Базовая концепция (разогрев)
Заголовок раздела «1. Базовая концепция (разогрев)»defer откладывает вызов функции до выхода из текущей функции. Гарантирует выполнение даже при panic.
func foo() { defer fmt.Println("first") defer fmt.Println("second") return}// печатает: second, first (LIFO)panic — управляемая ошибка. Поднимается через стек, размывая текущую функцию. Каждый defer на пути выполняется. Если нет recover — программа падает.
recover — функция, которая в defer ловит panic. Возвращает значение, переданное в panic(). Если panic нет — recover возвращает nil.
func safe() { defer func() { if r := recover(); r != nil { log.Printf("recovered: %v", r) } }() panic("boom")}Чем интересна реализация на Middle 2:
- До Go 1.14 defer был дорогим (~50 ns), даже один defer — заметный overhead.
- С Go 1.14 для “простых” defer’ов компилятор использует open-coded оптимизацию — стало ~1 ns.
- Panic/recover остался сложным механизмом с _panic структурой и размоткой через _defer-список.
2. Глубокое погружение
Заголовок раздела «2. Глубокое погружение»2.1. Старый defer (до Go 1.14): linked list на heap
Заголовок раздела «2.1. Старый defer (до Go 1.14): linked list на heap»До 1.14 каждый defer foo(arg1, arg2) генерировал runtime-вызов:
// для каждого defer:d := newdefer(siz) // аллокация _defer structd.fn = food.sp = getsp()d.pc = getpc()// копирование arguments в dd.link = g._deferg._defer = d // вставляем в начало linked list
// при выходе из функции:deferreturn() // вызывает все defer'ы LIFOСтруктура _defer (runtime/runtime2.go):
type _defer struct { started bool heap bool openDefer bool sp uintptr // sp функции, в которой defer определён pc uintptr fn func() _panic *_panic link *_defer fd unsafe.Pointer // funcdata varp uintptr // value of varp for the stack frame framepc uintptr}g._defer — голова intrusive linked list для текущей горутины. При выходе из функции deferreturn обходит этот список, выполняет и удаляет элементы.
⚠️ Каждый defer = аллокация _defer struct + копирование аргументов. Overhead ~30-50 ns даже для простого case. В hot path это было заметно.
2.2. Pool для _defer
Заголовок раздела «2.2. Pool для _defer»Чтобы сократить аллокации, runtime держит pool в каждой P:
type p struct { ... deferpool []*_defer deferpoolbuf [32]*_defer}При newdefer сначала смотрим в deferpool. При исчерпании — берём из global pool с lock’ом. При создании совсем нового — heap allocation.
Это снизило overhead до ~20 ns в типичном случае.
2.3. Open-coded defer (Go 1.14+)
Заголовок раздела «2.3. Open-coded defer (Go 1.14+)»Главная оптимизация: превратить defer в обычные function calls на этапе компиляции.
Условия применимости (все должны выполняться):
- Не более 8 defer’ов в функции.
- Defer не в цикле (
for). - Defer не в условии (внутри
if/switch/select) — это смягчили в более поздних версиях, теперь работает с условием.
Если условия выполнены — компилятор не использует runtime._defer вообще! Вместо этого:
- Аллоцирует на стеке bitmask (1 байт): какие defer’ы “активны” (выполнены ли уже).
- После defer-statement устанавливает бит “этот defer должен выполниться”.
- На
returnилиpanicобходит биты и вызывает соответствующие функции.
Псевдокод после компиляции:
func foo() { var deferred uint8 = 0
// defer call1() deferred |= 1 arg1_call1 := ... // аргументы вычисляются СЕЙЧАС, кладутся на стек
// defer call2(x, y) deferred |= 2 arg1_call2 := x arg2_call2 := y
// ... тело функции ...
// на return: if deferred & 2 != 0 { call2(arg1_call2, arg2_call2) } if deferred & 1 != 0 { call1(arg1_call1) }}Overhead — близкий к нулю (~1 ns на defer). Просто bitwise OR при defer-statement + проверки на return.
2.4. Когда open-coded не применяется
Заголовок раздела «2.4. Когда open-coded не применяется»// 1. В циклеfor _, file := range files { f, _ := os.Open(file) defer f.Close() // ⚠️ classic defer}
// 2. Более 8 defer'овdefer a()defer b()defer c()...defer i() // 9-й — все идут в classic mode
// 3. Несколько разных типов в зависимости от branching (старые версии)// (в новых — обычно работает)Чтобы проверить, использует ли компилятор open-coded:
go build -gcflags="-d=defer" main.go# илиgo build -gcflags="-m=2" main.go 2>&1 | grep defer2.5. ASCII-схема defer stack
Заголовок раздела «2.5. ASCII-схема defer stack»Old defer (linked list):
g._defer ─►┌────────┐ ─►┌────────┐ ─►┌────────┐ ─► nil │ fn=baz │ │ fn=bar │ │ fn=foo │ │ sp=... │ │ sp=... │ │ sp=... │ │ link──►│ │ link──►│ │ link │ └────────┘ └────────┘ └────────┘ (LIFO: baz выполнится первым)Open-coded (на стеке функции):
stack frame foo:┌──────────────────┐│ local vars │├──────────────────┤│ defer args │ ← аргументы defer'ов хранятся как локальные│ call1: arg_a ││ call2: arg_b,c ││ call3: arg_d │├──────────────────┤│ deferred (1 byte)│ ← bitmask│ = 0b00000111 │ (3 defer'а активны)└──────────────────┘2.6. Порядок выполнения
Заголовок раздела «2.6. Порядок выполнения»LIFO (Last In, First Out):
func main() { defer fmt.Println("1") defer fmt.Println("2") defer fmt.Println("3")}// вывод: 3, 2, 1Это потому, что новый defer становится головой linked list (или ставит бит в bitmask). Выход из функции обходит сверху вниз.
2.7. Вычисление аргументов defer: ПРИ defer, а НЕ при выходе
Заголовок раздела «2.7. Вычисление аргументов defer: ПРИ defer, а НЕ при выходе»ОЧЕНЬ ВАЖНОЕ ПРАВИЛО. Любимый вопрос на собесе.
func foo() { x := 1 defer fmt.Println(x) // напечатает 1, не 10! x = 10}Аргумент x вычисляется в момент defer, копируется на стек (или сохраняется в _defer struct). Изменения x после этого defer не видит.
Чтобы defer видел “текущее” значение — использовать closure:
func foo() { x := 1 defer func() { fmt.Println(x) // напечатает 10 (closure capture'ит x по ссылке) }() x = 10}⚠️ Для метода defer вычисляет receiver сразу:
func foo() { s := "hello" defer fmt.Println(s + " world") // печатает "hello world" s = "goodbye" // s изменилась, но defer вычислил аргумент раньше}2.8. panic() internals
Заголовок раздела «2.8. panic() internals»func mypanic(arg interface{}) { gp := getg()
var p _panic p.arg = arg p.link = gp._panic gp._panic = &p
// обход defer-цепочки for { d := gp._defer if d == nil { break }
if d.started { // ранее запущенный defer, который сам panic'нул — пропускаем ... }
d.started = true d._panic = &p
reflectcall(d.fn, ...) // вызываем defer-функцию
if p.recovered { // recover() сработал внутри defer gp._panic = p.link mcall(recovery) // прыжок назад на функцию, в которой был defer }
gp._defer = d.link freedefer(d) }
// если ни один defer не recover'нул: fatalpanic(&p) // печатает stack, exit}Структура _panic:
type _panic struct { argp unsafe.Pointer arg any // значение, переданное в panic() link *_panic // предыдущий panic (вложенные) pc uintptr sp unsafe.Pointer recovered bool aborted bool goexit bool}2.9. Что делает recover()?
Заголовок раздела «2.9. Что делает recover()?»func recover() interface{} { gp := getg() p := gp._panic if p == nil || p.goexit || p.aborted { return nil // не в defer / уже отозван / Goexit }
p.recovered = true return p.arg}Recover работает ТОЛЬКО внутри defer (через проверку gp._defer != nil && gp._defer.started). Вне defer возвращает nil.
После recover():
p.recovered = true.- В panic loop: проверка после
reflectcall(d.fn). Еслиp.recovered— прыжок назад на функцию черезruntime.recovery. recoveryвосстанавливает PC/SP изd.sp/d.pc, продолжает выполнение функции после оператора defer.
2.10. Recover внутри defer-функции (но не в самом её stmt)
Заголовок раздела «2.10. Recover внутри defer-функции (но не в самом её stmt)»⚠️ Подводный камень:
func foo() { defer recover() // НЕ РАБОТАЕТ panic("boom")}Почему? Потому что recover это функция, вызванная через defer. Когда runtime обходит defer, он вызывает её, но это не “внутри defer” в смысле проверки. Recover проверяет: “был ли я вызван прямо из defer-функции? Тот ли это PC?”. Если recover напрямую как deferred — он вызван runtime’ом из reflectcall, не из user code.
Правильно:
func foo() { defer func() { if r := recover(); r != nil { log.Println("recovered:", r) } }() panic("boom")}Здесь recover() вызывается в user-функции, которая deferred. Runtime видит её на стеке выше recover’а и знает, что мы в “deferred context”.
2.11. Panic из горутины
Заголовок раздела «2.11. Panic из горутины»go func() { panic("boom") // паника в отдельной горутине}()
defer func() { recover() // ⚠️ НЕ ПОЙМАЕТ}()time.Sleep(time.Second)Recover ловит panic в текущей горутине. Panic в другой goroutine = весь процесс умирает.
Правильно для goroutine:
go func() { defer func() { if r := recover(); r != nil { log.Println("goroutine panic:", r) } }() doRiskyWork()}()Это стандартная практика для long-running background workers — каждая горутина должна сама себя recover’ить.
2.12. nil panic (Go 1.21+)
Заголовок раздела «2.12. nil panic (Go 1.21+)»До Go 1.21:
panic(nil) // panic с nil-значениемdefer func() { if r := recover(); r != nil { // r == nil, не сработает! }}()Это была долгая баг: recover() возвращает nil, и не различить — был ли panic с nil или вообще не было.
С Go 1.21:
// panic(nil) теперь паникует с *runtime.PanicNilError, не nildefer func() { if r := recover(); r != nil { // теперь r будет *runtime.PanicNilError }}()Для backward compatibility: можно вернуть старое поведение через GODEBUG=panicnil=1.
2.13. Performance: open-coded vs old defer
Заголовок раздела «2.13. Performance: open-coded vs old defer»Bench (на amd64, Go 1.22):
BenchmarkOldDefer-8 50000000 35.0 ns/opBenchmarkOpenCodedDefer-8 1000000000 0.9 ns/opВ 30-40 раз быстрее! Это значит, что в hot path с deferом overhead уже не критичен.
Когда переходим в old mode (defer в цикле, > 8) — overhead возвращается.
2.14. Когда defer всё-таки overhead?
Заголовок раздела «2.14. Когда defer всё-таки overhead?»func processItems(items []Item) { for _, item := range items { f, err := os.Open(item.Path) if err != nil { continue } defer f.Close() // ⚠️ накапливается defer'ы для всех файлов! // обрабатываем } // все файлы закрываются только в конце processItems}Проблема 1: file handles остаются открытыми до конца функции (leak ресурсов). Проблема 2: defer в цикле → classic mode → 30 ns × N items.
Решение:
func processItems(items []Item) { for _, item := range items { processOne(item) // отдельная функция, defer внутри неё }}func processOne(item Item) { f, err := os.Open(item.Path) if err != nil { return } defer f.Close() // open-coded, и closed после каждой итерации // ...}2.15. Многократный recover
Заголовок раздела «2.15. Многократный recover»defer func() { if r := recover(); r != nil { log.Println("first recovery:", r) panic(r) // снова panic }}()
defer func() { if r := recover(); r != nil { log.Println("second recovery:", r) // тоже сработает! }}()
panic("boom")После первого recover() — panic “снят”. Но мы сразу panic(r) снова → новый panic, который ловит второй defer. Это нормальный механизм, можно использовать для добавления контекста.
2.16. Panic во время defer
Заголовок раздела «2.16. Panic во время defer»defer func() { panic("from defer") // panic во время другого defer}()panic("original")Что произойдёт? Original panic поднимается, начинаем обходить defer-цепь. Defer выше вызывает panic(“from defer”). В runtime panic loop: новый panic не отменяет старый, оба связаны через _panic.link.
При fatal print будут оба:
panic: original [recovered] panic: from defer
goroutine ...То есть видна цепочка panic’ов.
2.17. runtime.Goexit и defer
Заголовок раздела «2.17. runtime.Goexit и defer»runtime.Goexit() завершает текущую goroutine, выполняя все её defer’ы.
Отличие от panic:
- Defer’ы выполняются (как panic).
- Recover не отменяет Goexit (даже если defer вызвал recover, горутина всё равно завершится).
- Не печатает stack.
go func() { defer func() { if r := recover(); r != nil { log.Println("recovered:", r) // не сработает } }() runtime.Goexit()}()В этом коде defer выполнится, но recover вернёт nil (нет panic). Goroutine всё равно умрёт.
2.18. Stack trace в panic
Заголовок раздела «2.18. Stack trace в panic»При fatal panic Go печатает stack trace:
panic: my error
goroutine 1 [running]:main.foo() /tmp/foo.go:10 +0x68main.main() /tmp/foo.go:14 +0x20exit status 2В горутинах: [chan receive], [sleep], [running] — текущее состояние.
При SIGQUIT (Ctrl+) Go печатает stack всех горутин — стандартная функция runtime’а.
2.19. recover() и defer-чейн в panic
Заголовок раздела «2.19. recover() и defer-чейн в panic»func f1() { defer fmt.Println("f1 defer") f2()}func f2() { defer fmt.Println("f2 defer") defer func() { recover() // ловит panic в f2 fmt.Println("f2 recovered") }() panic("from f2")}Что выведется?
f2 recoveredf2 deferf1 deferRecover в f2 ловит panic. f2 продолжает выполнение остальных defer’ов (fmt.Println("f2 defer")), потом нормально возвращается. f1 не паникует, выполняет свой defer.
⚠️ Recover остаётся в той функции, где defer его вызвал. F1 ничего не знает о panic’е.
2.20. Comparing с try/catch других языков
Заголовок раздела «2.20. Comparing с try/catch других языков»| Aspect | Go (defer/panic/recover) | Java/C# (try/catch) |
|---|---|---|
| Идиоматичность | Panic для exceptional, errors для ожидаемых | Exception для всего |
| Скорость catch | Очень дорого (размотка) | Дёшево (jump) |
| Скорость throw | ~µs (panic) | µs (throw + stack capture) |
| Scope | Только текущая goroutine | Threads |
| Cleanup | defer | finally |
| Cancellation | context.Context | Thread.Interrupt() |
Идиома Go: panic ТОЛЬКО для “невосстановимых” ситуаций (внутренние invariant’ы программы). Для обычных ошибок — return error.
2.21. recover() в HTTP middleware
Заголовок раздела «2.21. recover() в HTTP middleware»Стандартный паттерн:
func RecoveryMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if rec := recover(); rec != nil { stack := make([]byte, 4<<10) stack = stack[:runtime.Stack(stack, false)] log.Printf("panic in handler: %v\n%s", rec, stack) http.Error(w, "Internal Server Error", 500) } }() next.ServeHTTP(w, r) })}Это критично для production HTTP сервисов. Без recovery middleware один panic-handler уронит весь pod.
⚠️ Стандартный http.Server уже имеет встроенный recovery (печатает в log, отвечает 500). Но он не настраиваемый. Своё middleware обычно нужно для structured logging.
2.22. defer overhead на benchmark
Заголовок раздела «2.22. defer overhead на benchmark»func BenchmarkNoDefer(b *testing.B) { var m sync.Mutex for i := 0; i < b.N; i++ { m.Lock() m.Unlock() }}
func BenchmarkDeferMu(b *testing.B) { var m sync.Mutex for i := 0; i < b.N; i++ { m.Lock() defer m.Unlock() // ⚠️ defer'ы накапливаются для b.N итераций! }}Второй бенч сделает b.N defer’ов, все они выполнятся в конце функции. Это и баг (mutex держится), и overhead (heap-аллокация _defer на каждую итерацию).
Правильный бенч с defer:
func BenchmarkDeferMu(b *testing.B) { var m sync.Mutex for i := 0; i < b.N; i++ { func() { m.Lock() defer m.Unlock() // open-coded, locked/unlocked в каждой итерации }() }}2.23. Когда не использовать defer
Заголовок раздела «2.23. Когда не использовать defer»- В очень тёмных hot path (миллионы ops/sec) — даже open-coded ~1 ns × млн = 1 ms.
- Если простая операция (один Close) — иногда
obj.Close()в конце явно читабельнее. - В коротких функциях с гарантированным single exit path.
Но в большинстве случаев — defer лучше и читабельнее.
3. Подводные камни
Заголовок раздела «3. Подводные камни»3.1. ⚠️ Defer аргументы вычисляются сразу
Заголовок раздела «3.1. ⚠️ Defer аргументы вычисляются сразу»Самый известный gotcha:
defer fmt.Println("at start:", time.Now())// ... 5 секунд работы ...// напечатает время в момент defer, НЕ в момент returnДля late evaluation — closure:
defer func() { fmt.Println("at end:", time.Now()) }()3.2. ⚠️ Defer в цикле — leak ресурсов
Заголовок раздела «3.2. ⚠️ Defer в цикле — leak ресурсов»for _, f := range files { fd, _ := os.Open(f) defer fd.Close() // НЕ закрываются на каждой итерации!}Закроются ВСЕ в конце функции. Если файлов 10000 — открытыми будет 10000 fd → может ulimit.
Решение: extract в отдельную функцию или явный fd.Close() в конце итерации.
3.3. ⚠️ Recover в нестандартных местах
Заголовок раздела «3.3. ⚠️ Recover в нестандартных местах»func foo() { if recover() != nil { ... } // НЕ работает (не в defer)}Recover работает ТОЛЬКО внутри defer-функции. Вне — возвращает nil.
3.4. ⚠️ Recover в нестандартной форме
Заголовок раздела «3.4. ⚠️ Recover в нестандартной форме»defer recover() // НЕ работаетЭто запускает recover как deferred-функцию. Recover проверяет PC своего вызывателя, и runtime-context не подходит.
Правильно — wrap в closure:
defer func() { recover() }()3.5. ⚠️ Panic в горутине без recover = death
Заголовок раздела «3.5. ⚠️ Panic в горутине без recover = death»Любая необработанная panic в горутине = весь процесс умирает.
go func() { // если что-то паникнет здесь без defer/recover — RIP service}()Best practice: каждая background-goroutine начинается с recover-defer.
3.6. ⚠️ defer + return + named return value
Заголовок раздела «3.6. ⚠️ defer + return + named return value»func foo() (result int) { defer func() { result++ // изменяет возвращаемое значение! }() return 1}// returns 2Named return value + closure-defer = можно модифицировать return. Хитрый трюк, иногда используется в error wrapping:
func foo() (err error) { defer func() { if err != nil { err = fmt.Errorf("foo: %w", err) } }() return doStuff()}3.7. ⚠️ Panic при чтении nil map / nil pointer
Заголовок раздела «3.7. ⚠️ Panic при чтении nil map / nil pointer»var m map[string]intm["a"] = 1 // panic: assignment to entry in nil mapЭто runtime panic, не возвращается через error. Если хочется recoverить — обязательный defer + recover.
3.8. ⚠️ recover() возвращает any, нужно type assert
Заголовок раздела «3.8. ⚠️ recover() возвращает any, нужно type assert»defer func() { if r := recover(); r != nil { switch v := r.(type) { case error: log.Println("error:", v) case string: log.Println("string:", v) case runtime.Error: log.Println("runtime:", v) } }}()panic(value) принимает any. Recover тоже возвращает any.
3.9. ⚠️ Открытые defer’ы стоят CPU при panic
Заголовок раздела «3.9. ⚠️ Открытые defer’ы стоят CPU при panic»Panic обходит defer-список. Если у тебя 100 defer’ов в стеке — это 100 reflectcall’ов. На hot path это редко важно, но в exception-heavy code (что в Go ан inderior) можно заметить.
3.10. ⚠️ Goexit и cleanup
Заголовок раздела «3.10. ⚠️ Goexit и cleanup»go func() { defer cleanup() // выполнится runtime.Goexit() unreachable()}()Goexit отрабатывает defer’ы, но завершает горутину. Полезно: например, в test’е t.FailNow() использует Goexit, что выполняет cleanup defer’ы.
3.11. ⚠️ Defer в init()?
Заголовок раздела «3.11. ⚠️ Defer в init()?»func init() { defer fmt.Println("init done") // выполнится после init() setup()}Это работает, но обычно бессмысленно. init() вызывается один раз, перед main. Defer там добавляет 30 ns на инициализацию.
3.12. ⚠️ Циклическая ссылка _defer
Заголовок раздела «3.12. ⚠️ Циклическая ссылка _defer»Невозможна. _defer — это intrusive linked list, добавляем в начало. Циклы создать нельзя через нормальное defer-statement.
3.13. ⚠️ Recover не передаёт panic выше
Заголовок раздела «3.13. ⚠️ Recover не передаёт panic выше»defer func() { recover()}()panic("boom")// функция возвращает нормально, как будто panic не былоЕсли хочешь обработать и продолжить panic — recover() + panic(r):
defer func() { if r := recover(); r != nil { log.Println("logged:", r) panic(r) // продолжаем }}()3.14. ⚠️ Panic в init() = death
Заголовок раздела «3.14. ⚠️ Panic в init() = death»panic в init() функции = программа не запустится. Recovery не сработает (нет main-горутины ещё для catch).
3.15. ⚠️ Open-coded defer не применяется при defer-statement в go или defer
Заголовок раздела «3.15. ⚠️ Open-coded defer не применяется при defer-statement в go или defer»Если defer-statement сам нестабилен (например, depends on runtime condition), компилятор может сдаться и переключиться в classic mode. Проверка через -gcflags="-m=2".
4. Производительность и реальные кейсы
Заголовок раздела «4. Производительность и реальные кейсы»4.1. Open-coded defer revolution
Заголовок раздела «4.1. Open-coded defer revolution»Кейс (Yandex 2019, до 1.14): Profile показывал runtime.deferreturn в топ-5. Defer был “налогом” на каждую функцию. Команда обсуждала, не убрать ли defer из hot path вообще.
После апгрейда на Go 1.14 — overhead defer’а упал в 30x. Profile показал runtime.deferreturn < 0.1%. Дискуссия закрыта.
4.2. defer в цикле — leak
Заголовок раздела «4.2. defer в цикле — leak»Кейс (Ozon batch processor): процесс обрабатывал 100K файлов, каждый открывался + defer Close. Все 100K fd оставались открытыми. После 5К — too many open files.
Решение: вынесли обработку одного файла в helper-функцию.
4.3. recovery middleware спас сервис
Заголовок раздела «4.3. recovery middleware спас сервис»Кейс (Avito, продакшен): миграция на новую версию протокола привела к panic в новом codec’е. Без recovery middleware — каждый bad request убивал бы pod, OOM-killer перезагружал.
С recovery middleware: pod продолжал работать, ошибки шли в лог, alarm срабатывал.
4.4. recover в goroutine pool
Заголовок раздела «4.4. recover в goroutine pool»Кейс (gRPC worker pool): воркеры в pool получали jobs через chan. Один баг — job == nil — приводил к panic. Без recover — вся горутина-воркер умирала, pool деградировал.
Решение: каждый worker начинался с defer-recover, ошибка логировалась, worker продолжал работу.
4.5. named return + defer для error wrapping
Заголовок раздела «4.5. named return + defer для error wrapping»Кейс: микросервис добавлял контекст в каждую ошибку через паттерн:
func (s *Service) DoWork(ctx context.Context) (err error) { defer func() { if err != nil { err = fmt.Errorf("DoWork: %w", err) } }() return s.internal(ctx)}Это автоматизирует error wrapping. Производительный кейс — open-coded defer + 30-40 ns на функцию.
4.6. panic(nil) баг
Заголовок раздела «4.6. panic(nil) баг»Кейс (legacy сервис на Go 1.20): panic(nil) где-то в codecs, recovery возвращал nil → программа считала “всё ок”, но в реальности был баг.
Решение: апгрейд на Go 1.21 (panicnil автоматически конвертится в *runtime.PanicNilError).
4.7. defer измерение latency
Заголовок раздела «4.7. defer измерение latency»Стандартный паттерн:
func handler(w, r) { start := time.Now() defer func() { log.Printf("handler took %v", time.Since(start)) }() // ...}Overhead — open-coded ~1 ns. Гораздо меньше, чем сам log.Printf (микросекунды).
5. Вопросы на собесе Middle 2
Заголовок раздела «5. Вопросы на собесе Middle 2»Q1. Что напечатает код?
func main() { x := 1 defer fmt.Println(x) x = 10}Напечатает 1. Аргумент defer’а вычисляется сразу — x = 1 сохраняется. Изменение x = 10 после этого не влияет.
Q2. А этот?
func main() { x := 1 defer func() { fmt.Println(x) }() x = 10}Напечатает 10. Closure capture’ит x по ссылке (на стек или escape), на момент выполнения defer’а x = 10.
Q3. Что такое open-coded defer?
Оптимизация Go 1.14+. Компилятор для функций с ≤ 8 defer’ов, не в цикле, не в условии — превращает defer в обычные function calls с bitmask на стеке. Overhead падает с ~30 ns до ~1 ns.
Q4. Когда open-coded не работает?
- defer внутри цикла.
-
8 defer’ов в функции.
- Очень сложный control flow (старые версии).
Q5. Опиши структуру _defer.
type _defer struct { started bool; sp, pc uintptr; fn func(); _panic *_panic; link *_defer; ... }. Intrusive linked list через link. Лежит на heap (или в pool) для classic mode, на стеке для open-coded.
Q6. Опиши, что происходит при panic.
- Создаётся
_panic struct, цепляется к g._panic. - Runtime обходит g._defer linked list (или open-coded bitmask) LIFO.
- Для каждого defer’а: выполняем функцию через reflectcall.
- Если defer вызвал recover() →
_panic.recovered = true, прыжок назад в исходную функцию. - Если ни один defer не recover’нул →
fatalpanic(print stack, exit).
Q7. Опиши, что делает recover().
Проверяет, есть ли активный panic в текущей горутине (gp._panic) и вызывается ли из контекста defer. Если да — устанавливает p.recovered = true, возвращает p.arg. Иначе — nil.
Q8. Почему defer recover() не работает?
recover() проверяет своего caller’а — он должен быть defer-функцией пользователя. Когда defer recover() — recover вызывается напрямую runtime’ом из reflectcall, не из user-функции. Поэтому recover видит, что он не “в defer context” и возвращает nil.
Q9. Что произойдёт, если defer вызовет panic?
Новый panic добавляется в цепочку через _panic.link. Старый не отменяется. При fatal print будут оба:
panic: original [recovered] panic: newQ10. Можно ли recover panic в другой горутине?
Нет. Recover работает только в текущей горутине. Panic в goroutine A не виден из defer-функции в goroutine B. Каждая goroutine должна сама себя recover’ить.
Q11. Что такое panic(nil) проблема и как её решает Go 1.21?
До 1.21: panic(nil) → recover() возвращает nil → невозможно отличить “был panic с nil” от “не было panic”. В 1.21 panic(nil) автоматически конвертится в *runtime.PanicNilError, recover вернёт это значение.
Q12. Что произойдёт при runtime.Goexit()?
Текущая goroutine завершается. Все её defer’ы выполняются. Recover внутри defer вернёт nil (нет panic) и не отменит Goexit. Если Goexit в main горутине — программа выходит как os.Exit(0).
Q13. Чем Goexit отличается от panic + recover?
| Goexit | panic + recover |
|---|---|
| Defer’ы выполняются | Выполняются |
| Recover не работает | Работает (отменяет panic) |
| Голос горутина умирает | Может продолжить (recover ловит) |
| Не печатает stack | Печатает (если не recover) |
Q14. Сколько ns overhead у defer на Go 1.22?
Open-coded (≤8 defer’ов, не в цикле): ~1 ns. Classic mode (в цикле или > 8): ~30-50 ns.
Q15. Что напечатает?
func foo() (result int) { defer func() { result++ }() return 1}fmt.Println(foo())Напечатает 2. Named return value + closure-defer. return 1 сначала записывает 1 в result, потом defer выполняется и инкрементит до 2.
Q16. Можно ли изменять не-named return value через defer?
Нет. Если return value не named, мы не знаем переменной, которую модифицировать. Defer может изменить только named return values.
Q17. Опиши паттерн error wrapping через defer.
func foo() (err error) { defer func() { if err != nil { err = fmt.Errorf("foo: %w", err) } }() return bar()}Использует named return + closure. После return bar() runtime записывает err, выполняет defer, который оборачивает err.
Q18. Что такое recovery middleware и зачем?
HTTP middleware с defer-recover, ловящий panic’и в handler’ах. Без него один panic убивает весь сервер. С ним — ошибка логируется, клиент получает 500.
Q19. Что произойдёт, если в HTTP handler сделать panic(“oops”)?
Стандартный http.Server имеет встроенный recovery: панику ловит, печатает в log (или server ErrorLog), отвечает клиенту “500 Internal Server Error”. Но без своего middleware ты не контролируешь формат логирования.
Q20. Где defer overhead важен?
Если в hot loop ~1M ops/sec — даже 1 ns defer × 1M = 1 ms (1% от секунды). На latency-критичных путях иногда убирают defer. Но для большинства кода — overhead незначим.
Q21. Какой порядок выполнения defer’ов?
LIFO (last in, first out). Последний defer выполняется первым.
Q22. Что вернёт recover() в нормальном коде (без panic)?
nil. Вне panic-context recover возвращает nil.
Q23. Что произойдёт при panic в init()?
init() функция упадёт с panic, программа не запустится (main не вызывается). Recovery в main не поможет, потому что main ещё не стартовала.
Q24. Можно ли использовать defer для гарантии разблокировки mutex?
Да, это идиоматичный паттерн:
mu.Lock()defer mu.Unlock()Гарантия: даже при panic mutex разблокируется (выполнится defer).
Q25. Как defer взаимодействует с return statement?
return value это два действия:
- Запись value в named return (если есть) или временный слот.
- Запуск defer’ов.
- Выход из функции.
Defer’ы могут изменить named return value между шагом 1 и 3.
Q26. Что такое stack trace при panic?
При fatal panic Go печатает stack каждой горутины. Главная (где panic) — с маркером [running]. Это runtime.dopanic делает.
Q27. Что произойдёт при SIGQUIT (Ctrl+)?
Go runtime ловит SIGQUIT, печатает stack всех горутин (как при panic), завершает программу. Полезно для debug “что зависло”.
Q28. Сколько байт занимает _defer на heap?
~64 байта на amd64 (struct fields). Плюс аргументы — копируются в дополнительной памяти.
Q29. Что такое cooperative panic?
Не Go-термин. В Go panic всегда “preemptive” в смысле — сразу размывает стек. Нет такого, что panic ждёт удобного момента.
Q30. Что напечатает?
func main() { defer fmt.Println("a") defer fmt.Println("b") panic("boom")}bapanic: boomgoroutine 1 [running]:...Defer’ы LIFO, потом fatal panic print.
Q31. Что произойдёт, если recover вернёт значение, и мы его проигнорируем?
Если recover() != nil — panic считается обработанным. Программа продолжается после функции, в которой был defer. Игнорируем — значит логика просто не использует значение, но recovery всё равно сработала.
6. Practice
Заголовок раздела «6. Practice»Задача 1. Defer аргументы
Заголовок раздела «Задача 1. Defer аргументы»Напиши код:
func main() { for i := 0; i < 3; i++ { defer fmt.Println(i) }}Что напечатает? Объясни порядок.
(Ответ: 2, 1, 0 — LIFO, и каждый i вычислен при defer’е, значения 0, 1, 2 сохранены, выполняются в обратном порядке.)
Задача 2. Defer + closure
Заголовок раздела «Задача 2. Defer + closure»func main() { for i := 0; i < 3; i++ { defer func() { fmt.Println(i) }() }}Что напечатает в Go ≤ 1.21? А в Go 1.22+? Объясни.
(До 1.22 — три раза 3 (closure capture’ит i по ссылке, к моменту выполнения i = 3). С 1.22 каждая итерация for создаёт новую i (новая семантика loop variable), и closure capture’ит каждый по-своему — напечатает 2, 1, 0.)
Задача 3. Open-coded benchmark
Заголовок раздела «Задача 3. Open-coded benchmark»Напиши два бенчмарка:
- Функция с одним defer (внутри тела).
- Функция с тем же defer внутри for-loop.
Замерь ns/op. Объясни разницу (open-coded vs classic).
Задача 4. Recovery middleware
Заголовок раздела «Задача 4. Recovery middleware»Реализуй HTTP middleware:
- Recover panic.
- Log stack trace.
- Return 500.
- Set unique request-id в log.
Задача 5. Goroutine с recovery
Заголовок раздела «Задача 5. Goroutine с recovery»Напиши worker pool, где каждый worker имеет deferred recover. Покажи, что panic в одном job’е не убивает worker.
Задача 6. Error wrapping via defer
Заголовок раздела «Задача 6. Error wrapping via defer»Реализуй pattern:
func DoSomething(ctx context.Context) (err error) { defer func() { if err != nil { err = fmt.Errorf("DoSomething: %w", err) } }() // ...}Сравни с явным wrapping без defer.
Задача 7. Late evaluation paradox
Заголовок раздела «Задача 7. Late evaluation paradox»Напиши:
type Resource struct { Name string }func (r *Resource) Close() { fmt.Println("closing", r.Name) }
func main() { r := &Resource{Name: "first"} defer r.Close() r = &Resource{Name: "second"}}Что напечатает? Объясни, как обращение к receiver работает в defer.
(Напечатает “closing first”. Receiver вычисляется при defer’е — это указатель на первый Resource. Переприсваивание r создаёт новую переменную, defer держит старую.)
Задача 8. Panic chain
Заголовок раздела «Задача 8. Panic chain»Напиши код с двумя вложенными panic’ами (panic в defer во время другого panic). Покажи, что print показывает оба.
7. Источники
Заголовок раздела «7. Источники»src/runtime/panic.go— реализация panic, recover, defer.src/runtime/runtime2.go— структуры_defer,_panic.- Dan Scales, “Proposal: Low-cost defers through inline code, and extra funcdata to manage the panic case” — Go 1.14 design doc.
- Keith Randall, “Go 1.13 sync.Pool and defer optimization” — GopherCon 2019.
- Cherry Zhang, “Go 1.14: Async preempt + open-coded defer” — talk.
- “Defer optimization in Go 1.14” — Habr / Yandex Engineering Blog 2020.
- Go 1.21 release notes — про
panic(nil)→ PanicNilError. - “panic, recover and defer in Go” — Dave Cheney blog 2018.
- “Goroutine, panic, recovery: best practices” — Ardan Labs blog.
- “Effective Go” — официальный doc, секция про defer/panic/recover.
- Russ Cox, “Defer Statement” — design doc 2008.
- “Avoiding panic-driven design” — Brad Fitzpatrick talk, GopherCon EU 2018.