Как Go выбирает механизм defer: open-coded, stack и heap
- пятница, 30 октября 2026 г. в 00:00:20
Про defer в Go до сих пор ходит миф: «он медленный, в горячем коде лучше не использовать». Когда-то это было правдой. До Go 1.13 каждый defer означал аллокацию в куче. Сейчас, в большинстве случаев, он стоит почти ничего. Но «в большинстве» — это не «всегда», и граница проходит по довольно конкретным правилам.
Компилятор Go умеет реализовывать defer тремя способами:
open-coded — никакой runtime-структуры, вызов вставляется прямо в код функции;
stack defer — runtime используется, но запись о defer лежит на стеке;
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.
Самый быстрый вариант появился в Go 1.14, и его идея проста. Компилятор вообще не создаёт объект defer. Он переписывает функцию так, чтобы отложенные вызовы стояли прямо перед каждым return:
defer f() return
превращается примерно в
f() return
Порядок LIFO, разумеется, сохраняется: если defer было два, то в точке выхода они вызываются в обратном порядке.
Это упрощение, но принцип именно такой. Нет структуры, нет вызова runtime.deferproc, нет связного списка, а значит, нет ни аллокаций, ни обращений к runtime на обычном пути выполнения. Накладные расходы — это пара инструкций.
Фиксированное число 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.
Логично предположить, что вот такой код ломает схему: «вставим вызов перед 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 для всей функции, если встретил что-то из следующего.
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 |
|---|---|
Аллокации | ✗ |
Runtime | ✗ |
Самый быстрый | ✓ |
Когда open-coded не подошёл, следующим кандидатом становится stack defer.
Это промежуточный вариант, появившийся в Go 1.13. Runtime здесь уже участвует: вызывается runtime.deferprocStack, запись о defer встаёт в список отложенных вызовов горутины. Но сама структура _defer лежит на стеке функции, а не в куче.
Главное следствие: malloc не вызывается, сборщику мусора нечего убирать, а память освобождается сама, когда функция возвращается и её фрейм исчезает.
Условие, по сути, одно: компилятор может доказать, что конкретный оператор 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 } }
Часто думают, что 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 |
|---|---|
Runtime | ✓ |
Heap Alloc | ✗ |
Всё ещё быстро | ✓ |
Остаётся последний вариант, когда нет доказательства, что defer выполнится ограниченное число раз.
Это запасной вариант. Структура runtime._defer создаётся в куче и связывается в список отложенных вызовов горутины. Запись выделяется не каждый раз через «сырой» malloc: runtime держит для каждого P небольшой пул готовых _defer и обычно берёт запись оттуда. Но принципиально это всё равно куча. Запись живёт после выхода из функции, пока её не вернут в пул, и именно поэтому такой defer попадает в поле зрения GC.
На практике — почти исключительно в одном случае.
defer в цикле:
for range items { defer closeFile() }
Это главная причина heap-аллокаций. Компилятор не знает на этапе компиляции, сколько раз выполнится этот defer:
items может быть любой длины;
может быть даже бесконечным (например, chan, из которого читаем до закрытия).
Каждая итерация добавляет новую запись, и разместить их во фрейме функции фиксированного размера невозможно. Остаётся куча.
Помимо производительности у 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 |
|---|---|
malloc | ✓ |
GC | ✓ |
Быстро | ✗ |
Обратите внимание, что в этой таблице впервые появился GC. Различие в том, как освобождается память. При stack alloc она возвращается автоматически при выходе из функции, и сборщик мусора не участвует. При heap alloc она освобождается не сразу, а когда запись больше не нужна и её подберёт GC. Эти накладные расходы небольшие по отдельности, но на горячем пути они складываются.
Вариант | Когда | Runtime | Куча | Скорость |
|---|---|---|---|---|
Open-coded | нет | ✗ | ✗ | максимальная |
Stack |
| ✓ | ✗ | всё ещё быстро |
Heap |
| ✓ | ✓ | медленнее |
Что стоит запомнить:
Главный враг — defer в цикле. Он не только самый дорогой, но и откладывает освобождение ресурсов до конца функции. Выносите тело цикла в отдельную функцию.
Один цикл с defer ухудшает всю функцию. Остальные defer в ней уже не будут open-coded.
Условия, switch, рекурсия, горутины и замыкания сами по себе не страшны. Компилятор справляется с ними бесплатно.
Так что мысль «defer — это дорого» давно устарела. Дорого не «использовать defer», а использовать его в циклах и в функциях со сложным потоком управления. Всё остальное компилятор Go оптимизирует лучше, чем вы сделали бы это вручную.