Модуль: 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, причём ровно в трёх случаях:

  1. Достижение return (включая «голый» return).
  2. Достижение конца тела функции.
  3. Раскрутка стека из-за паники (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._deferruntime/runtime2.go), которая хранит указатель на функцию, размер аргументов, указатель на стек/SP, ссылку на следующий _defer в связанном списке горутины (g._defer) и др. поля. Эволюция:

ВерсияРеализацияХарактеристика
до Go 1.13heap-allocated deferкаждый defer аллоцировал _defer в куче (через deferproc), вызывался deferreturn; дорого, давило на GC
Go 1.13stack-allocated defer_defer размещается на стеке (если не «убегает»); ~30% быстрее, меньше нагрузка на кучу, но всё ещё через список и deferproc/deferreturn
Go 1.14open-coded deferкомпилятор инлайнит отложенные вызовы прямо в код функции; накладные расходы близки к прямому вызову — defer перестал быть «дорогим»

Как работает open-coded defer#

Компилятор применяет open-coding, когда выполнены условия:

  • число операторов defer в функции статически известно и ≤ 8;
  • defer‘ы не находятся в цикле (число срабатываний ограничено и известно);
  • функция не слишком «осложнена» (есть пороги по числу defer × выходов).

При open-coding компилятор:

  1. Не создаёт _defer-записи в общем пути; вместо этого вычисляет аргументы и сохраняет их в локальных переменных стека.
  2. Заводит defer bit mask (битовую маску) — по биту на каждый defer, выставляемую в момент исполнения соответствующего defer. Это нужно, потому что не каждый defer гарантированно выполнится (например, ранний return до второго defer).
  3. На каждом пути выхода вставляет инлайн-код, который по маске вызывает нужные отложенные функции в порядке LIFO.
  4. Для пути паники компилятор генерирует 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.Exitlog.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 сознательно заменяют ручным освобождением — и как обосновать это бенчмарками, а не догадками.