Про 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.
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 deferif 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 deferfor 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 := 10defer 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 := 10defer 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 |
нет |
✗ |
✗ |
максимальная |
|
Stack |
|
✓ |
✗ |
всё ещё быстро |
|
Heap |
|
✓ |
✓ |
медленнее |
Что стоит запомнить:
-
Главный враг —
deferв цикле. Он не только самый дорогой, но и откладывает освобождение ресурсов до конца функции. Выносите тело цикла в отдельную функцию. -
Один цикл с
deferухудшает всю функцию. Остальныеdeferв ней уже не будут open-coded. -
Условия,
switch, рекурсия, горутины и замыкания сами по себе не страшны. Компилятор справляется с ними бесплатно.
Так что мысль «defer — это дорого» давно устарела. Дорого не «использовать defer», а использовать его в циклах и в функциях со сложным потоком управления. Всё остальное компилятор Go оптимизирует лучше, чем вы сделали бы это вручную.
ссылка на оригинал статьи https://habr.com/ru/articles/1101252/