defer: порядок выполнения

Разбираем, в каком порядке на самом деле выполняются несколько defer в одной функции — классический вопрос-ловушка на Go-собеседованиях.

defer — это оператор Go, который откладывает вызов функции до момента, когда окружающая функция закончит работу (вернёт результат, запаникует или просто дойдёт до конца тела).

Что выведет этот код?

Прежде чем читать дальше, попробуй самостоятельно предсказать порядок строк в выводе.

package main

import "fmt"

func main() {
	fmt.Println("Начало")

	defer fmt.Println("Первый defer")
	defer fmt.Println("Второй defer")
	defer fmt.Println("Третий defer")

	fmt.Println("Конец")
}

Интуиция подсказывает, что раз мы написали defer-вызовы по порядку — первый, второй, третий, — то и выполнятся они в этом же порядке. Но это неверно, и именно на этом чаще всего ловят кандидатов.

Вывод:

Начало
Конец
Третий defer
Второй defer
Первый defer

LIFO-порядок отложенных вызовов

Все отложенные вызовы в Go складываются в структуру данных, которая называется стек (LIFO — «last in, first out», то есть «последний зашёл — первый вышел»). Представь стопку тарелок: ты кладёшь тарелки одну на другую, а когда начинаешь их убирать, снимаешь сначала верхнюю — ту, что положил последней. Именно так работает и defer: последний объявленный defer выполнится первым, когда функция завершается.

В примере выше мы объявили defer-вызовы в порядке «Первый → Второй → Третий», а Go положил их в стек именно в этом порядке. Когда функция main дошла до конца тела, отложенные вызовы начали «сниматься» сверху стека — то есть в обратном порядке: сначала «Третий defer» (он лёг в стек последним), потом «Второй defer», и только в конце «Первый defer».

Как это работает под капотом

Технически, когда исполнение доходит до строки с defer, Go не выполняет вызов сразу — он вычисляет аргументы этого вызова прямо в этот момент (это важная деталь, к которой мы ещё вернёмся) и кладёт «отложенную задачу» в специальный список функции. Когда функция готовится завершиться — неважно, через обычный return, через провал в конец тела или даже через panic, — рантайм Go проходит по этому списку с конца и вызывает каждую отложенную функцию по очереди.

LIFO-порядок выбран не случайно: он отлично подходит для парной «открыл — закрыл» логики. Самый частый пример — работа с файлами или сетевыми соединениями.

func obrabotatDvaFayla() error {
	f1, err := openFile("a.txt")
	if err != nil {
		return err
	}
	defer f1.Close()

	f2, err := openFile("b.txt")
	if err != nil {
		return err
	}
	defer f2.Close()

	return sravnitFayly(f1, f2)
}

Здесь f2 открылся позже, чем f1 — а значит, ресурсы, от которых он может зависеть, ещё живы. LIFO-порядок закрытия гарантирует, что f2 закроется первым, а f1 — последним, то есть в обратном порядке открытия. Это естественная и предсказуемая логика: последнее открытое закрывается первым.

Частая ловушка с defer в цикле

Самая известная ловушка на собеседованиях связана с использованием defer внутри цикла. Посмотри на этот код.

func obrabotatMnogoFaylov(imena []string) {
	for _, imya := range imena {
		f, err := openFile(imya)
		if err != nil {
			continue
		}
		defer f.Close()

		obrabotat(f)
	}
}

На первый взгляд код выглядит аккуратно: открыли файл, отложили закрытие, обработали. Но здесь спрятана серьёзная проблема: defer откладывает вызов не до конца итерации цикла, а до конца всей функции obrabotatMnogoFaylov. Если файлов сто, все сто вызовов f.Close() накопятся в стеке и выполнятся только тогда, когда функция целиком закончит работу — то есть после обработки всех ста файлов подряд. Всё это время сто открытых файловых дескрипторов будут висеть незакрытыми, занимая системные ресурсы, которых на самом деле не бесконечно много.

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

func obrabotatMnogoFaylovVerno(imena []string) {
	for _, imya := range imena {
		obrabotatOdinFayl(imya)
	}
}

func obrabotatOdinFayl(imya string) {
	f, err := openFile(imya)
	if err != nil {
		return
	}
	defer f.Close()

	obrabotat(f)
}

Теперь у каждой итерации цикла — своя собственная функция obrabotatOdinFayl с собственным списком отложенных вызовов, и файл закрывается сразу после обработки, а не в самом конце.

Частые ошибки на собеседовании

Первая ошибка — не знать про LIFO и думать, что defer-вызовы идут в том же порядке, в котором написаны в коде. Вторая, куда более практичная ошибка — использовать defer внутри длинного цикла для закрытия ресурсов, из-за чего они копятся и не освобождаются вовремя, что на реальном сервере способно привести к исчерпанию файловых дескрипторов или соединений. Третья тонкость, которую любят спрашивать вдогонку, — аргументы у defer вычисляются в момент объявления defer, а не в момент его фактического выполнения: например, defer fmt.Println(i) в цикле напечатает то значение i, которое было на момент объявления каждого конкретного defer, а не финальное значение переменной после цикла.

Итоги

  • Несколько defer в одной функции выполняются в порядке LIFO — последний объявленный отрабатывает первым.
  • LIFO хорошо подходит для парной логики «открыл — закрыл», особенно при работе с несколькими ресурсами подряд.
  • defer внутри цикла откладывает вызов до конца всей функции, а не до конца итерации — при большом числе итераций ресурсы копятся неосвобождёнными.
  • Аргументы отложенного вызова вычисляются в момент объявления defer, а не в момент его срабатывания — частый источник путаницы на собеседованиях.
Проверьте себя
1. В каком порядке выполнятся несколько defer, объявленных подряд в одной функции?
AВ том же порядке, в котором были объявлены (FIFO)
BВ обратном порядке объявления (LIFO) — последний объявленный выполнится первым
CПорядок случайный и не гарантируется языком
DВсе defer выполняются одновременно
2. В чём главная проблема вызова defer f.Close() внутри цикла с большим числом итераций?
AТакой код вообще не скомпилируется
BЗакрытие всех ресурсов откладывается до конца всей функции, а не до конца итерации, и ресурсы копятся неосвобождёнными
Cdefer в цикле выполняется быстрее обычного вызова, поэтому это не проблема
DПорядок закрытия файлов станет случайным