golang

Как Go выбирает механизм defer: open-coded, stack и heap

  • пятница, 30 октября 2026 г. в 00:00:20
https://habr.com/ru/articles/1101252/

Про defer в Go до сих пор ходит миф: «он медленный, в горячем коде лучше не использовать». Когда-то это было правдой. До Go 1.13 каждый defer означал аллокацию в куче. Сейчас, в большинстве случаев, он стоит почти ничего. Но «в большинстве» — это не «всегда», и граница проходит по довольно конкретным правилам.

Компилятор Go умеет реализовывать defer тремя способами:

  1. open-coded — никакой runtime-структуры, вызов вставляется прямо в код функции;

  2. stack defer — runtime используется, но запись о defer лежит на стеке;

  3. heap defer — запись аллоцируется в куче.

Решение принимается по цепочке, от самого дешёвого варианта к самому дорогому:

1. Можно open-coded?
        ↓ нет
2. Можно stack defer?
        ↓ нет
3. heap defer (fallback)

Разберём каждый уровень: что он делает, когда включается и почему в какой-то момент компилятор сдаётся и откатывается на следующий.

Все примеры ниже проверены на Go 1.25. Чтобы увидеть решение компилятора самостоятельно, соберите пакет с флагом -gcflags=-d=defer. Для каждого defer компилятор напишет open-coded defer, stack-allocated defer или heap-allocated defer.

1. Open-coded defer

Идея

Самый быстрый вариант появился в Go 1.14, и его идея проста. Компилятор вообще не создаёт объект defer. Он переписывает функцию так, чтобы отложенные вызовы стояли прямо перед каждым return:

defer f()
return

превращается примерно в

f()
return

Порядок LIFO, разумеется, сохраняется: если defer было два, то в точке выхода они вызываются в обратном порядке.

Это упрощение, но принцип именно такой. Нет структуры, нет вызова runtime.deferproc, нет связного списка, а значит, нет ни аллокаций, ни обращений к runtime на обычном пути выполнения. Накладные расходы — это пара инструкций.

Когда open-coded сработает

Фиксированное число defer, известное на этапе компиляции:

func ok() {
	defer println("A")
	defer println("B")
}

Компилятор видит два defer и знает точный порядок их вызова. Runtime-структура здесь не нужна.

defer вне циклов:

func ok(x int) {
	if x > 0 {
		fmt.Println(x)
	}

	defer println("done")
}

Любое число return с одним и тем же набором defer:

func ok(x int) {
	defer println("cleanup")

	if x < 0 {
		return
	}
	return
}

Перед каждым return компилятор просто подставит один и тот же вызов cleanup.

А если defer стоит под условием?

Логично предположить, что вот такой код ломает схему: «вставим вызов перед return», а выполнялся ли defer — неизвестно.

func f(cond bool) {
	if cond {
		defer A()
	}
}

На деле компилятор справляется и с этим. Для функции заводится небольшая битовая маска (deferBits, один байт). Когда выполнение проходит через defer, в маске взводится соответствующий бит. В точке выхода код проверяет биты и вызывает только те функции, чьи defer реально «зарегистрировались».

Отсюда же растёт одно из ограничений: байт — это 8 бит, поэтому в функции может быть не больше 8 open-coded defer.

Остаётся вопрос, что будет при панике: вызовов перед return в этом пути нет. Тогда runtime идёт по стеку и с помощью специальных метаданных, которые компилятор оставил для функции, находит бит-маску и сам вызывает нужные отложенные функции. Именно этот механизм делает open-coded defer бесплатным на обычном пути, а дорогим на аварийном. Но паника и так редкость.

Когда open-coded не будет

Компилятор выключает open-coded для всей функции, если встретил что-то из следующего.

defer в цикле:

for i := 0; i < 10; i++ {
	defer println(i)
}

Количество исполнений неизвестно на этапе компиляции, а один бит в маске не может описать «сколько раз».

Больше 8 defer в функции. Битовая маска просто закончилась.

Слишком сложная конструкция:

defer_count × return_count > 15

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

Проверить это просто:

func manyRet(x int) int {
	defer A(); defer A(); defer A(); defer A() // 4 defer
	if x == 1 { return 1 }
	if x == 2 { return 2 }
	if x == 3 { return 3 }
	if x == 4 { return 4 }
	return 0                                   // 5 return
}

4 × 5 = 20 > 15, и компилятор честно сообщает: stack-allocated defer.

Итог по open-coded

Свойство

Open-Coded

Аллокации

✗

Runtime

✗

Самый быстрый

✓

Когда open-coded не подошёл, следующим кандидатом становится stack defer.

2. Stack defer

Идея

Это промежуточный вариант, появившийся в Go 1.13. Runtime здесь уже участвует: вызывается runtime.deferprocStack, запись о defer встаёт в список отложенных вызовов горутины. Но сама структура _defer лежит на стеке функции, а не в куче.

Главное следствие: malloc не вызывается, сборщику мусора нечего убирать, а память освобождается сама, когда функция возвращается и её фрейм исчезает.

Когда stack defer сработает

Условие, по сути, одно: компилятор может доказать, что конкретный оператор defer выполнится не более одного раза за вызов функции. Если он не в цикле, это так. Тогда структуру можно положить в фрейм: она там одна и точно поместится.

А open-coded при этом не применяется по другим причинам. Чаще всего вы встретите две.

Функция упёрлась в лимиты open-coded — слишком много defer или слишком большое произведение defer × return. Пример с manyRet выше именно такой. То же самое, если defer больше 8:

func nine() {
	defer A(); defer A(); defer A()
	defer A(); defer A(); defer A()
	defer A(); defer A(); defer A()   // девять — на один больше лимита
}

Каждый из них получит stack-allocated defer.

В функции есть цикл с defer, и рядом обычные defer вне цикла:

func mixed(n int) {
	defer A()                       // stack-allocated defer
	for i := 0; i < n; i++ {
		defer println(i)            // heap-allocated defer
	}
}

Это интересный случай. Цикл «отравляет» функцию целиком, и open-coded выключается для всех defer в ней. Но обычный defer A() в цикле не находится, поэтому он честно получает стек, а не кучу. Решение принимается на каждый оператор отдельно.

Отсюда практический вывод: если в функции нужен defer в цикле, вынесите тело цикла в отдельную функцию. Тогда в ней defer будет вне цикла, и компилятор сможет сделать его open-coded.

for _, f := range files {
	if err := process(f); err != nil { // внутри process — свой defer f.Close()
		return err
	}
}

Несколько if, switch и замыкания

Часто думают, что stack defer — это про ветвления: несколько if, switch, замыкания без захвата переменных. Но все эти случаи компилятор оптимизирует до open-coded:

func ok(a int) {
	if a == 1 { defer println("A") }
	if a == 2 { defer println("B") }
}

func sw(x int) {
	switch x {
	case 1:
		defer A()
	case 2:
		defer B()
	}
}

func closure() {
	x := 10
	defer func(v int) {
		println(v)
	}(x)
}

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

Итог по stack defer

Свойство

Stack

Runtime

✓

Heap Alloc

✗

Всё ещё быстро

✓

Остаётся последний вариант, когда нет доказательства, что defer выполнится ограниченное число раз.

3. Heap defer

Идея

Это запасной вариант. Структура runtime._defer создаётся в куче и связывается в список отложенных вызовов горутины. Запись выделяется не каждый раз через «сырой» malloc: runtime держит для каждого P небольшой пул готовых _defer и обычно берёт запись оттуда. Но принципиально это всё равно куча. Запись живёт после выхода из функции, пока её не вернут в пул, и именно поэтому такой defer попадает в поле зрения GC.

Когда возникает heap defer

На практике — почти исключительно в одном случае.

defer в цикле:

for range items {
	defer closeFile()
}

Это главная причина heap-аллокаций. Компилятор не знает на этапе компиляции, сколько раз выполнится этот defer:

  • items может быть любой длины;

  • может быть даже бесконечным (например, chan, из которого читаем до закрытия).

Каждая итерация добавляет новую запись, и разместить их во фрейме функции фиксированного размера невозможно. Остаётся куча.

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

Что не вызывает heap defer

Есть три ситуации, которые обычно считают причиной кучи. Разберёмся, почему на самом деле это не так.

Рекурсия:

func f(n int) {
	defer println(n)
	if n > 0 {
		f(n - 1)
	}
}

Интуиция подсказывает, что количество defer зависит от глубины рекурсии, а она известна только в runtime, значит, нужна куча. Но это не так. Каждый вызов f получает свой собственный фрейм, и в каждом фрейме ровно один defer. Для компилятора это функция с одним defer вне цикла. Результат: open-coded defer.

defer внутри горутины:

go func() {
	defer println("x")
}()

Здесь рассуждение тоже обманчиво: «горутина может пережить функцию, стек исчезнет, данные должны быть в куче». Но defer стоит внутри самой горутинной функции и привязан к её фрейму, а не к фрейму породившей. У горутины свой стек, и этот defer живёт ровно столько же, сколько сама горутина. Результат — тоже open-coded defer.

Замыкание с захватом переменной:

func f() {
	x := 10
	defer func() {
		println(x)
	}()
}

Замыкание захватывает x по ссылке, а не копирует его. Грубо говоря, оно устроено так:

closure {
	*x  // ссылка на x
}

Из этого легко сделать вывод: переменная «убегает» из функции, значит, escape analysis отправит её в кучу. Но отложенное замыкание не покидает функцию: оно вызывается до возврата. Escape analysis это видит, x остаётся на стеке, а сам defer — открытый. То же верно и для &x внутри замыкания.

Всё меняется, когда такое замыкание оказывается defer’ом в цикле: оно тогда лежит в heap-записи и переживает итерацию, а значит, захваченные им переменные escape analysis уже действительно вынесет в кучу. Это дополнительный штраф к цене самого defer в цикле, но причина в цикле, а не в замыкании.

Итог по heap defer

Свойство

Heap

malloc

✓

GC

✓

Быстро

✗

Обратите внимание, что в этой таблице впервые появился GC. Различие в том, как освобождается память. При stack alloc она возвращается автоматически при выходе из функции, и сборщик мусора не участвует. При heap alloc она освобождается не сразу, а когда запись больше не нужна и её подберёт GC. Эти накладные расходы небольшие по отдельности, но на горячем пути они складываются.

Что мы имеем в итоге

Вариант

Когда

Runtime

Куча

Скорость

Open-coded

нет defer в цикле, ≤ 8 defer, defer × return ≤ 15

✗

✗

максимальная

Stack

defer вне цикла, но open-coded невозможен (лимиты или цикл с defer в функции)

✓

✗

всё ещё быстро

Heap

defer внутри цикла

✓

✓

медленнее

Что стоит запомнить:

  1. Главный враг — defer в цикле. Он не только самый дорогой, но и откладывает освобождение ресурсов до конца функции. Выносите тело цикла в отдельную функцию.

  2. Один цикл с defer ухудшает всю функцию. Остальные defer в ней уже не будут open-coded.

  3. Условия, switch, рекурсия, горутины и замыкания сами по себе не страшны. Компилятор справляется с ними бесплатно.

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