Модуль: Core Go · Уровень: Senior
TL;DR#
defer откладывает вызов функции до момента выхода из окружающей функции (return, паника или нормальное завершение). Аргументы отложенного вызова вычисляются в момент выполнения оператора defer, а сам вызов — позже, в порядке LIFO. Отложенные функции могут читать и модифицировать именованные возвращаемые значения, что лежит в основе паттерна recover. С Go 1.14 для статически известного и небольшого (≤8) числа defer без циклов компилятор применяет open-coded defer — почти нулевые накладные расходы; иначе используется _defer-запись в runtime.
Теория#
Что такое defer и когда он выполняется#
defer F(args) регистрирует вызов F с заранее вычисленными args. Вызов произойдёт при выходе из функции, в которой написан defer, причём ровно в трёх случаях:
- Достижение
return(включая «голый» return). - Достижение конца тела функции.
- Раскрутка стека из-за паники (
panic), проходящей через эту функцию.
Важно: defer привязан к функции, а не к лексическому блоку ({}) или итерации цикла. Это ключевой источник ошибок (см. утечки в циклах).
Вычисление аргументов в момент регистрации#
Аргументы откладываемой функции вычисляются немедленно — когда исполнение доходит до оператора defer, а не когда отложенный вызов реально срабатывает.
func main() {
i := 0
defer fmt.Println("deferred:", i) // i скопировано прямо сейчас -> 0
i = 42
fmt.Println("normal:", i) // 42
}
// normal: 42
// deferred: 0То же касается получателя метода (receiver) и значения функции-выражения: они «снимаются» в момент defer.
type T struct{ v int }
func (t T) Show() { fmt.Println(t.v) }
func f() {
t := T{v: 1}
defer t.Show() // копия t (v=1) зафиксирована сейчас
t.v = 2
}
// печатает 1, потому что receiver-копия снята в момент deferЕсли же нужен «поздний» снимок состояния — оборачивают в замыкание без аргументов (см. ниже про замыкания vs аргументы).
Порядок LIFO#
Отложенные вызовы складываются в стек и исполняются в обратном порядке регистрации.
func f() {
for i := 0; i < 3; i++ {
defer fmt.Print(i, " ")
}
}
// 2 1 0Это естественно для парных захват/освобождение ресурсов: первым захватили — последним освободим (mutex.Lock/Unlock, открытие/закрытие, начало/завершение трейсинга).
defer в цикле: накопление и классическая утечка#
Поскольку defer привязан к функции, а не к итерации, в длинном цикле отложенные вызовы накапливаются и не выполняются до выхода из функции. Самый частый баг — отложенный Close в цикле:
// ПЛОХО: файлы и дескрипторы не закрываются до конца readAll
func readAll(paths []string) error {
for _, p := range paths {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // накапливается! при тысячах файлов -> исчерпание FD
// ... работа с f
}
return nil
}Правильно — ограничить область видимости defer телом итерации, вынеся её в отдельную функцию (или замыкание), которая возвращает управление на каждой итерации:
func readAll(paths []string) error {
for _, p := range paths {
if err := func() error {
f, err := os.Open(p)
if err != nil {
return err
}
defer f.Close() // закроется в конце КАЖДОЙ итерации
// ... работа с f
return nil
}(); err != nil {
return err
}
}
return nil
}Дополнительный нюанс: накопление defer в цикле также отключает open-coded оптимизацию (см. ниже) — defer в цикле всегда тяжелее.
Изменение именованных возвращаемых значений из defer#
Если функция объявляет именованные возвращаемые значения, отложенная функция видит их как обычные переменные и может изменять — изменения отразятся на фактически возвращаемом значении. Это работает потому, что return x компилируется в «присвоить именованным результатам, затем выполнить defer’ы, затем фактически вернуть».
func inc() (result int) {
defer func() { result++ }()
return 5 // result = 5, затем defer делает result = 6
}
// возвращает 6Сравните с анонимными результатами — там defer не имеет доступа к возвращаемому значению и может влиять лишь через побочные эффекты:
func inc2() int {
result := 5
defer func() { result++ }() // меняет локальную переменную, не возврат
return result // вернёт 5
}Главное практическое применение — оборачивание ошибок и восстановление после паники:
func do() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
defer func() {
if err != nil {
err = fmt.Errorf("do failed: %w", err)
}
}()
// ...
return nil
}defer + recover и паника#
recover имеет смысл только внутри отложенной функции. Вне defer он возвращает nil. Когда возникает паника, runtime начинает раскрутку стека, по дороге выполняя зарегистрированные defer’ы; если какой-то из них вызывает recover, паника гасится, и функция, чей defer восстановил выполнение, возвращается нормально (со значениями именованных результатов, какими они стали).
func safeDiv(a, b int) (q int, err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("panic: %v", r)
}
}()
return a / b, nil // при b==0 паника -> recover -> err установлен
}Тонкости:
recover()должен быть вызван непосредственно отложенной функцией, а не функцией, которую она вызывает.defer helper()гдеhelperвнутри зовётrecover()— не сработает (в Go это вызов на «глубину 1»; runtime проверяет, что recover вызван прямо из defer’нутого фрейма).- Если в defer происходит новая паника, она заменяет текущую (в трейсе остаётся след предыдущей).
- defer’ы выполняются даже при панике — поэтому
mutex.Unlock, закрытие соединений и т. п. в defer безопасны при панике.
Стоимость defer и эволюция реализации#
Под капотом отложенный вызов в общем случае представляется структурой runtime._defer (в runtime/runtime2.go), которая хранит указатель на функцию, размер аргументов, указатель на стек/SP, ссылку на следующий _defer в связанном списке горутины (g._defer) и др. поля. Эволюция:
| Версия | Реализация | Характеристика |
|---|---|---|
| до Go 1.13 | heap-allocated defer | каждый defer аллоцировал _defer в куче (через deferproc), вызывался deferreturn; дорого, давило на GC |
| Go 1.13 | stack-allocated defer | _defer размещается на стеке (если не «убегает»); ~30% быстрее, меньше нагрузка на кучу, но всё ещё через список и deferproc/deferreturn |
| Go 1.14 | open-coded defer | компилятор инлайнит отложенные вызовы прямо в код функции; накладные расходы близки к прямому вызову — defer перестал быть «дорогим» |
Как работает open-coded defer#
Компилятор применяет open-coding, когда выполнены условия:
- число операторов
deferв функции статически известно и ≤ 8; defer‘ы не находятся в цикле (число срабатываний ограничено и известно);- функция не слишком «осложнена» (есть пороги по числу defer × выходов).
При open-coding компилятор:
- Не создаёт
_defer-записи в общем пути; вместо этого вычисляет аргументы и сохраняет их в локальных переменных стека. - Заводит defer bit mask (битовую маску) — по биту на каждый defer, выставляемую в момент исполнения соответствующего
defer. Это нужно, потому что не каждый defer гарантированно выполнится (например, ранний return до второгоdefer). - На каждом пути выхода вставляет инлайн-код, который по маске вызывает нужные отложенные функции в порядке LIFO.
- Для пути паники компилятор генерирует
deferreturn-подобный хвост и регистрирует информацию черезfuncdata/stackmap, чтобы runtime вgopanicмог найти и выполнить open-coded defer’ы при раскрутке стека (механизм с фреймовым «открытым» defer и_defer{openDefer: true}).
Практический вывод: «defer медленный» — устаревшее знание. Сегодня в горячем пути дорогим становится defer только если он в цикле или их > 8 (откат к heap/stack _defer через deferproc), либо если функция вызывает recover так, что компилятор вынужден откатиться к классической схеме.
defer с замыканиями vs с аргументами#
Два способа передать данные в отложенный вызов дают разную семантику снимка состояния:
// Аргументы: значение фиксируется в момент defer
defer fmt.Println(x) // печатает x КАКИМ ОНО БЫЛО на строке defer
// Замыкание без аргументов: значение читается в момент ВЫПОЛНЕНИЯ
defer func() { fmt.Println(x) }() // печатает АКТУАЛЬНОЕ x на выходе из функцииfunc demo() {
x := 1
defer fmt.Println("arg:", x) // arg: 1
defer func() { fmt.Println("clo:", x) }() // clo: 3
x = 2
x = 3
}
// LIFO: clo: 3, затем arg: 1Производительность: замыкание захватывает переменные, что может вынудить их «убежать» в кучу (escape analysis). Передача аргументами иногда дешевле и яснее по семантике (поздний снимок не нужен). Выбор: нужен снимок «сейчас» — аргументы; нужно «актуальное на выходе» (типично для err, метрик длительности) — замыкание.
Подводные камни / gotchas#
- Аргументы вычисляются сразу.
defer log(time.Since(start))зафиксирует длительность ≈ 0. Нужноdefer func(){ log(time.Since(start)) }(). - defer в длинном цикле = утечка/накопление. Дескрипторы, мьютексы, память живут до конца функции. Лечится выносом тела в функцию/замыкание.
- defer внутри
if/for/блока всё равно срабатывает на выходе из функции, а не из блока — defer не блочный. recoverвне отложенной функции бесполезен (вернёт nil), и не сработает, если вызван не напрямую из defer’нутого фрейма.- Анонимные результаты не меняются из defer. Чтобы defer влиял на возврат — объявите именованные результаты.
- defer на
nil-функции/значении паникует при выполнении, а не при регистрации. defer rows.Close()без проверки ошибок маскирует ошибки закрытия; для записи (Close файла, в который писали) ошибку нужно ловить:defer func(){ if cerr := f.Close(); cerr != nil && err == nil { err = cerr } }().- defer + горутины: defer в
go func(){...}()относится к этой горутине, а не к породившей функции. os.Exitне выполняет defer’ы. Иlog.Fatal(вызываетos.Exit) тоже — отложенные Close не отработают.- defer добавляет фрейм при панике-трейсе и слегка усложняет инлайнинг функции (исторически функции с defer плохо инлайнились; современный компилятор это улучшил, но эффект остаётся).
Вопросы на собеседовании#
В: Когда вычисляются аргументы отложенной функции?
О: В момент исполнения оператора defer, а не в момент фактического вызова. Значения аргументов, receiver метода и само значение функции копируются сразу и сохраняются (на стеке/в _defer). Поэтому defer f(x) зафиксирует текущее x. Чтобы отложить чтение до выхода, нужно замыкание без аргументов: defer func(){ f(x) }().
В: В каком порядке выполняются несколько defer?
О: LIFO — последний зарегистрированный выполняется первым. Реализуется как стек/связанный список _defer в g._defer, куда новые записи добавляются в голову. Это удобно для парных операций lock/unlock, open/close.
В: Как изменить возвращаемое значение из defer?
О: Объявить именованные возвращаемые значения. return x присваивает их, затем выполняются defer’ы, которые видят и могут менять эти именованные переменные; изменённое значение и будет возвращено. С анонимными результатами это невозможно — defer влияет только через внешние побочные эффекты.
В: Почему defer в цикле часто приводит к багу и как чинить? О: defer привязан к функции, не к итерации, поэтому в цикле вызовы накапливаются и не срабатывают до выхода из функции — типично это незакрытые файлы/соединения (исчерпание дескрипторов). Решение — обернуть тело итерации в функцию/замыкание, тогда defer отработает на каждой итерации. Бонус: defer в цикле ещё и отключает open-coded оптимизацию.
В: Дорог ли defer? Как менялась его стоимость?
О: До Go 1.13 — heap-allocation _defer (дорого, нагрузка на GC). В 1.13 — stack-allocation (~30% быстрее). В 1.14 — open-coded defer: компилятор инлайнит отложенные вызовы при статически известном числе ≤8 и отсутствии циклов, стоимость близка к прямому вызову. Сегодня defer «дорог» только в цикле, при >8 defer или когда компилятор откатывается к классической схеме.
В: Что такое open-coded defer и какие условия его включают?
О: Это компиляторная оптимизация, при которой вместо _defer-записей в runtime код отложенных вызовов вставляется напрямую в каждый путь выхода функции, а управление — через стековые переменные с аргументами и битовую маску (какие defer успели зарегистрироваться). Условия: число defer статически известно и ≤8, defer не в цикле, функция не превышает порогов сложности. Для пути паники компилятор регистрирует funcdata, чтобы gopanic нашёл и выполнил эти defer’ы при раскрутке.
В: Где должен находиться вызов recover, чтобы он сработал?
О: Непосредственно внутри отложенной функции текущего фрейма. recover() в обычном коде или внутри функции, вызванной из defer (на ещё одну глубину), вернёт nil и не погасит панику. Runtime проверяет, что recover вызывается прямо из фрейма, чей defer сейчас исполняется во время паники.
В: В чём разница между defer f(x) и defer func(){ f(x) }()?
О: Первый снимает x в момент defer (ранний снимок), второй читает x в момент выполнения (актуальное значение на выходе). Замыкание также может захватывать и менять именованные результаты/ошибки. Минус замыкания — возможный escape переменных в кучу и чуть большие накладные расходы.
В: Выполняются ли defer при панике и при os.Exit?
О: При панике — да: runtime раскручивает стек, исполняя defer’ы (что и даёт шанс recover и корректному освобождению ресурсов). При os.Exit (и log.Fatal) — нет: процесс завершается немедленно, defer’ы пропускаются.
На что копают на senior+#
- Точная семантика
return+ defer + named results. Senior должен описать, чтоreturn xэто не атомарная операция: присваивание результатов → выполнение defer’ов → реальный возврат. Follow-up: почемуdefer func(){ recover() }()восстанавливает функцию именно с текущими значениями именованных результатов. - Внутреннее устройство
_deferиg._defer. Ожидают, что кандидат назовёт связанный список defer’ов на горутине, поля (fn, sp/pc, link, openDefer/heap-флаги), и разницу путейdeferproc/deferreturnпротив open-coded. - Когда оптимизация откатывается. Глубокое понимание: defer в цикле, >8 defer, defer в горячем пути с recover — что именно заставляет компилятор вернуться к stack/heap
_defer. Умение это проверить (go build -gcflags=-d=defer, анализ через бенчмарки/go tool objdump). - Escape analysis и замыкания в defer. Понимание, что захват переменных замыканием может вызвать их размещение в куче, и как это видно в
-gcflags=-m. - Корректная обработка ошибок Close. Особенно для writable-ресурсов: senior проверяет ошибку
Closeи аккуратно пробрасывает её в именованныйerr, не затирая основную ошибку. - Поведение recover на «глубине». Follow-up «почему вынесенный helper с recover не работает» отделяет тех, кто знает правило «recover только напрямую из defer’нутого фрейма».
- Влияние на инлайнинг и трейсы паник. Senior знает исторические ограничения инлайнинга функций с defer и как defer-фреймы выглядят в стеке паники.
- Альтернативы defer в сверхгорячем пути. Когда ради производительности (например, в аллокаторах, парсерах) defer сознательно заменяют ручным освобождением — и как обосновать это бенчмарками, а не догадками.