📱 Подписаться
IT и цифровая трансформация

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

📰 Habr 👁️ 0 просмотров

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 — ежедневные материалы о бизнес-процессах, операционном управлении и повышении эффективности

💬 Подписаться на канал