defer в Go: порядок выполнения, ловушки и когда defer не срабатывает

defer откладывает вызов функции до момента выхода из той функции, где он записан. Это главный инструмент Go для освобождения ресурсов: закрыть файл, отпустить мьютекс, завершить транзакцию. Идея простая, но за ней прячется несколько неочевидных деталей. Когда именно вычисляются аргументы отложенного вызова, в каком порядке выполняются несколько defer, что происходит с defer в цикле и в каких случаях он вообще не сработает. Разберём по порядку с работающими примерами.

Это один из разборов в серии для начинающих. Общий маршрут изучения языка собран в статье «Go для начинающих: дорожная карта».

Зачем нужен defer

Типичная задача: открыли ресурс, поработали, закрыли. Без defer закрытие приходится дублировать на каждом пути выхода из функции, включая ранние return по ошибке. Легко забыть один из них.

func readConfig(path string) ([]byte, error) {
    f, err := os.Open(path)
    if err != nil {
        return nil, err
    }
    defer f.Close() // выполнится при любом выходе из функции

    return io.ReadAll(f)
}

defer f.Close() гарантирует, что файл закроется и при успешном return, и при ошибке внутри io.ReadAll, и при панике. Закрытие стоит рядом с открытием, а не где-то в конце функции. Это удобно читать и трудно забыть.

Правило 1: порядок LIFO

Несколько отложенных вызовов выполняются в порядке, обратном их объявлению. Последний записанный defer срабатывает первым. Это стек: last in, first out.

func main() {
    defer fmt.Println("1")
    defer fmt.Println("2")
    defer fmt.Println("3")
    fmt.Println("тело функции")
}
$ go run main.go
тело функции
3
2
1

Порядок логичен для освобождения ресурсов. Обычно зависимости открываются по очереди (соединение → транзакция → запрос), а закрывать их нужно в обратном порядке. LIFO делает это автоматически: что открыли последним, то и закроется первым.

Правило 2: аргументы вычисляются сразу

Это ловушка, на которой спотыкаются почти все. Откладывается вызов, но аргументы для него вычисляются в момент, когда исполнение доходит до строки с defer, а не в момент самого вызова.

func main() {
    i := 0
    defer fmt.Println("defer видит:", i) // i вычислен здесь, это 0
    i = 10
    fmt.Println("в конце i =", i)
}
$ go run main.go
в конце i = 10
defer видит: 0

defer запомнил i == 0 в момент объявления и напечатал именно это значение, хотя к концу функции i уже стал равен 10. Если вам нужно увидеть актуальное значение на момент выхода, оберните вызов в замыкание. Тело замыкания выполнится позже и прочитает переменную в её финальном состоянии:

func main() {
    i := 0
    defer func() {
        fmt.Println("defer видит:", i) // читается при выходе, это 10
    }()
    i = 10
}

Эта же разница объясняет частую ошибку с подсчётом времени выполнения. Запись defer fmt.Println(time.Since(start)) вычислит time.Since сразу и покажет почти нулевую длительность. Правильно завернуть в замыкание, чтобы time.Since посчитался при выходе.

Правило 3: defer может менять именованный возврат

Если у функции именованные возвращаемые значения, отложенная функция-замыкание видит их и может изменить уже после return. Дело в том, что return x не атомарен: сначала возвращаемой переменной присваивается значение, затем выполняются defer, и только потом управление уходит из функции.

func double() (result int) {
    defer func() {
        result *= 2 // меняем уже присвоенный return
    }()
    return 5
}

func main() {
    fmt.Println(double()) // 10, а не 5
}

return 5 записал result = 5, после чего отложенное замыкание удвоило его до 10. Это не трюк ради трюка: именно так в Go аккуратно оборачивают ошибки в одном месте. Например, добавить контекст к любой ошибке на выходе из функции:

func process() (err error) {
    defer func() {
        if err != nil {
            err = fmt.Errorf("process: %w", err)
        }
    }()
    // ... любой return с ошибкой получит префикс "process: "
    return doWork()
}

Про обёртывание ошибок через %w и работу с errors.Is/errors.As подробнее в разборе идиоматичной обработки ошибок в Go.

defer в цикле

Самая известная ловушка defer. Отложенный вызов привязан к выходу из функции, а не из блока цикла или из итерации. Поэтому такой код накапливает все defer и выполняет их разом только в конце функции:

func processAll(paths []string) error {
    for _, p := range paths {
        f, err := os.Open(p)
        if err != nil {
            return err
        }
        defer f.Close() // НЕ закрывается в конце итерации
        // работа с f
    }
    return nil // все файлы закроются только здесь
}

Если в paths тысячи файлов, все они останутся открытыми до конца функции. Получите исчерпание лимита файловых дескрипторов (too many open files). То же касается мьютексов, соединений и любых ресурсов с ограниченным числом.

Решение простое. Вынести тело итерации в отдельную функцию. Тогда defer сработает на каждом выходе из неё, то есть на каждой итерации:

func processAll(paths []string) error {
    for _, p := range paths {
        if err := processOne(p); err != nil {
            return err
        }
    }
    return nil
}

func processOne(p string) error {
    f, err := os.Open(p)
    if err != nil {
        return err
    }
    defer f.Close() // закрывается при выходе из processOne, на каждой итерации
    // работа с f
    return nil
}

Когда defer не срабатывает

defer выполняется при штатном выходе из функции и при разворачивании стека во время паники. Но есть случаи, когда отложенные вызовы пропускаются полностью. О них важно знать, потому что в этих ситуациях ваши Close и Unlock молча не выполнятся.

os.Exit

os.Exit завершает программу немедленно, не разворачивая стек. Ни один defer не выполнится:

func main() {
    defer fmt.Println("этот defer НЕ выполнится")
    fmt.Println("перед os.Exit")
    os.Exit(0)
}
$ go run main.go
перед os.Exit

То же относится к log.Fatal и log.Fatalln. Внутри они вызывают os.Exit(1). Поэтому log.Fatal уместен только в самом main или при инициализации, где терять отложенные вызовы не страшно. В библиотечном коде вместо него возвращают ошибку.

Аварийное завершение всей программы

Паника, которую никто не перехватил через recover, разворачивает стек и по пути выполняет defer. А вот фатальные ошибки рантайма, которые роняют процесс целиком, отложенные вызовы не выполняют. Самый частый пример это deadlock всех горутин. Рантайм печатает fatal error и убивает процесс, минуя defer.

Горутина, которая не завершилась

defer сработает при выходе из своей функции. Если функция-горутина «зависла» на приёме из канала, в который никто не отправит, она не завершится. А значит, и её defer не выполнится. Это одна из причин утечки ресурсов вместе с утечкой самой горутины. Подробнее про выход из горутин в разборе горутин и каналов.

defer и recover

Перехватить панику можно только из отложенной функции. recover имеет смысл лишь внутри defer: в обычном коде он всегда возвращает nil. Это штатный способ не дать панике в одной горутине уронить весь процесс.

func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("восстановлено после паники: %v", r)
        }
    }()
    return a / b, nil // при b == 0 будет паника, её перехватит defer
}

func main() {
    _, err := safeDivide(10, 0)
    fmt.Println(err) // восстановлено после паники: runtime error: integer divide by zero
}

Замыкание перехватывает панику и через именованный возврат превращает её в обычную ошибку (та самая комбинация из правила 3). Важно не злоупотреблять: recover нужен на границах (обработчик HTTP-запроса, воркер из пула), а не как замена нормальной обработке ошибок.

Стоит ли бояться оверхеда

Раньше defer заметно проигрывал прямому вызову, и в горячих циклах его иногда избегали. Начиная с Go 1.14 компилятор инлайнит большинство defer в открытый код, и накладные расходы в типичном случае близки к нулю. Поэтому правило простое. Используйте defer для освобождения ресурсов везде, где он уместен. Не занимайтесь преждевременной оптимизацией. Микрооптимизация ручным закрытием оправдана только если профайлер прямо указал на это место.

Заключение

defer это про предсказуемое освобождение ресурсов, но работает он по нескольким правилам, которые стоит держать в голове:

  1. Несколько defer выполняются в порядке LIFO: последний объявленный срабатывает первым.
  2. Аргументы отложенного вызова вычисляются сразу, в момент объявления. Чтобы прочитать актуальное значение на выходе, оборачивайте вызов в замыкание.
  3. Замыкание в defer видит и может менять именованные возвращаемые значения. На этом построены обёртывание ошибок и recover.
  4. defer привязан к выходу из функции, а не из итерации цикла. В цикле выносите тело в отдельную функцию.
  5. Отложенные вызовы пропускаются при os.Exit/log.Fatal, при фатальной ошибке рантайма и в незавершившейся горутине.

Освойте эти пять пунктов, и defer перестанет преподносить сюрпризы. Следующие шаги в изучении языка собраны в дорожной карте для начинающих.

FAQ

В каком порядке выполняются несколько defer?
В обратном порядку объявления (LIFO, стек): последний записанный defer выполняется первым. Это удобно для освобождения зависимых ресурсов: что открыли последним, закроется первым.
Когда вычисляются аргументы defer?
В момент, когда исполнение доходит до строки с defer, а не в момент самого отложенного вызова. Поэтому defer fmt.Println(i) запомнит текущее значение i. Чтобы прочитать значение на выходе из функции, заверните вызов в замыкание: defer func() { fmt.Println(i) }().
Почему defer в цикле не закрывает ресурс на каждой итерации?
Потому что defer привязан к выходу из функции, а не из блока цикла. Все отложенные вызовы накопятся и выполнятся разом в конце функции, что может привести к исчерпанию файловых дескрипторов. Решение: вынести тело итерации в отдельную функцию. Тогда defer сработает на каждом её вызове.
В каких случаях defer не выполняется?
При вызове os.Exit (а также log.Fatal, который вызывает os.Exit внутри), при фатальной ошибке рантайма (например, deadlock всех горутин) и если горутина так и не завершилась (зависла на приёме из канала). Во всех этих случаях стек не разворачивается штатно и отложенные вызовы пропускаются.
Может ли defer изменить возвращаемое значение функции?
Да, если возвращаемые значения именованные и defer это функция-замыкание. return x сначала присваивает значение именованной переменной, затем выполняет defer, и только потом выходит. Поэтому замыкание может изменить результат. На этом построены обёртывание ошибок и восстановление через recover.
Замедляет ли defer программу?
Начиная с Go 1.14 компилятор инлайнит большинство defer, и накладные расходы в типичном коде близки к нулю. Избегать defer в горячем цикле ради скорости стоит только если профайлер прямо указал на это место. В остальных случаях используйте defer свободно.

Теги: