Blckvia3 часа назадКраткое содержание статьи
Как Go выбирает механизм defer: open-coded, stack и heap
Уровень сложностиСреднийВремя на прочтение7 минОхват и читатели2.7KGo*Программирование*Компиляторы*Из песочницыПро 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)
}Количество исполнений неизвестно на этапе компиляции, а один бит в маске не может описать «сколько раз».
Больше 8deferв функции. Битовая маска просто закончилась.
Слишком сложная конструкция:
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
СвойствоStackRuntime✓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
СвойствоHeapmalloc✓GC✓Быстро✗Обратите внимание, что в этой таблице впервые появился GC. Различие в том, как освобождается память. При stack alloc она возвращается автоматически при выходе из функции, и сборщик мусора не участвует. При heap alloc она освобождается не сразу, а когда запись больше не нужна и её подберёт GC. Эти накладные расходы небольшие по отдельности, но на горячем пути они складываются.
Что мы имеем в итоге
ВариантКогдаRuntimeКучаСкоростьOpen-codedнет defer в цикле, ≤ 8 defer, defer × return ≤ 15✗✗максимальнаяStackdefer вне цикла, но open-coded невозможен (лимиты или цикл с defer в функции)✓✗всё ещё быстроHeapdefer внутри цикла✓✓медленнееЧто стоит запомнить:
• Главный враг —deferв цикле. Он не только самый дорогой, но и откладывает освобождение ресурсов до конца функции. Выносите тело цикла в отдельную функцию.
• Один цикл сdeferухудшает всю функцию. Остальные defer в ней уже не будут open-coded.
• Условия,switch, рекурсия, горутины и замыкания сами по себе не страшны. Компилятор справляется с ними бесплатно.Так что мысль «defer — это дорого» давно устарела. Дорого не «использовать defer», а использовать его в циклах и в функциях со сложным потоком управления. Всё остальное компилятор Go оптимизирует лучше, чем вы сделали бы это вручную.Теги:• Go
• golang
• defer
• escape analysis
• optimization
• perfomance
• compilerХабы:• Go
• Программирование
• Компиляторы
Получайте больше инсайтов о систематизации бизнеса
Подписывайтесь на Telegram-канал Business Operations — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности
💬 Подписаться на канал→ Оригинальная статья