сеньорчикОткрыть в Telegram
← вся теориятеория к собесу · Ошибки и рантайм Go

Escape analysis в Go

Escape analysis: стек или куча

Собери программу с флагом -gcflags=-m, и компилятор расскажет, куда он положил каждое значение. У меня в выводе рядом оказались две строки про один и тот же &T{}: в одном месте escapes to heap, в другом does not escape. Спрашивают, кто и по какому правилу это решает и безопасно ли возвращать адрес локальной переменной.

Стержень: решает компилятор при сборке, правило одно - переживёт ли значение свой кадр стека.

// Формулировки: «что такое escape analysis?», «безопасно ли вернуть &t?», «почему fmt.Println дорогой?»

Правило одно, и его видно глазами

Взял функцию func New() *T { t := T{a: 1}; return &t } и собрал с -gcflags=-m. Компилятор напечатал: moved to heap: t. Он увидел, что адрес уходит наружу и переживёт вызов, поэтому переложил значение в кучу.

Кадр стека - это кусок памяти под локальные переменные одного вызова: кончился вызов, кадр отброшен. Правило анализа ровно одно. Значение, которое свой кадр не переживёт, остаётся на стеке, потому что там выделение это сдвиг указателя, а освобождение бесплатное. Всё, что кадр переживает, уезжает в кучу под присмотр сборщика.

// Поэтому вернуть адрес локальной переменной в Go безопасно, в отличие от C. Повисшего указателя не будет: компилятор сам заметит и разместит значение так, чтобы оно жило. Конструктор func New() *T { return &T{} } - обычная идиома, а не грех.

кадр стека
память под локальные переменные одного вызова
moved to heap
строка компилятора: переменную переложили в кучу

Куда именно утекает, и почему инлайн всё переворачивает

Тот же прогон, четыре характерные строки. make([]int, 8) does not escape - размер известен при компиляции, влезло на стек. make([]int, n) escapes to heap - размер узнаём в рантайме, места на стеке заранее не отвести. moved to heap: x вместе с func literal escapes to heap - переменную замкнули в функции, живущей дольше кадра. И n escapes to heap на строке с fmt.Println.

Последнее и есть цена боксинга. Параметр fmt.Println имеет тип any, а укладка значения в интерфейс требует адреса, поэтому значение отправляется в кучу. В моём выводе так уехали даже строковые константы. Логирование в горячем цикле дороже, чем выглядит.

// Забавное место: тот же &T{} в одной строке escapes to heap, а в другой does not escape. Разница в инлайне - когда компилятор встроил New прямо в вызывающий код, кадр стал общим, и переживать стало нечего. Вывод -gcflags=-m читают вместе со строками can inline, иначе он кажется противоречивым.

боксинг
укладка значения в интерфейс, уводит его в кучу
escapes to heap
строка компилятора: значение размещено в куче

Когда за это вообще браться

Порядок работы такой. Сперва бенчмарк с -benchmem: он печатает allocs/op, число аллокаций на операцию. Потом профиль кучи: он говорит, сколько и откуда. И только затем -gcflags=-m, который объясняет, почему компилятор решил именно так.

Наоборот не работает. На приличном проекте вывод -gcflags=-m это тысячи строк, и без бенчмарка ты не знаешь, какая из них в горячем пути. Убранная аллокация вне горячего пути даёт ноль выигрыша и стоит читаемости.

// Что помогает, когда путь найден: задать вместимость слайсу заранее, не таскать крупные структуры через интерфейсы, вынести форматирование строк из цикла. Всё остальное - после замера.

allocs/op
аллокаций на одну операцию в бенчмарке
-gcflags=-m
печатает решения анализа при сборке

Как отвечать: «Что такое escape analysis и безопасно ли вернуть адрес локальной переменной?»

Escape analysis это анализ компилятора, который на этапе сборки решает, где разместить значение: на стеке или в куче. Правило одно - переживёт ли значение свой кадр стека. Не переживёт, значит остаётся на стеке, а это дёшево: выделение там сдвиг указателя, освобождение бесплатное. Переживёт, значит уезжает в кучу, и дальше за ним следит сборщик. Поэтому вернуть адрес локальной переменной в Go безопасно, в отличие от C: повисшего указателя не возникнет, а конструктор вида func New() *T { return &T{} } - обычная идиома. Проверяется решение флагом -gcflags=-m: я так и смотрел, на функции с локальной переменной компилятор печатает moved to heap, на укладке значения в интерфейс - escapes to heap. Вторая строка про аргументы fmt.Println, и это цена боксинга: параметр имеет тип any, ему нужен адрес, поэтому логирование в горячем цикле дороже, чем кажется. Есть тонкость: после инлайна вердикт может смениться на противоположный, кадр становится общим и переживать нечего. Оптимизирую я это только после профиля - сперва бенчмарк с -benchmem и allocs/op, потом уже разбор, что и почему уезжает.

Сильный ответ: механизм сведён к одному правилу, страх из C снят конкретной идиомой, названы инструмент проверки и обе характерные строки вывода, добавлена цена боксинга и оговорка про инлайн. Финал про порядок работы показывает инженера, а не любителя микрооптимизаций.

На чём валят

  • Боятся возвращать адрес локальной переменной по привычке из C.
  • Считают, что new всегда кладёт в кучу, а литерал - на стек. Решает анализ, а не форма записи.
  • Не знают про цену боксинга и логируют через fmt в горячем цикле.
  • Читают -gcflags=-m без строк can inline и удивляются противоречиям в выводе.
  • Оптимизируют аллокации без профиля и теряют читаемость впустую.

Проверьте себя

Пять вопросов из банка по этой подтеме. Всего их 4, остальные разбираются в тренажёре.

  1. #go_escape1 / 4
    Что определяет escape analysis компилятора Go?
    A)Какие переменные в функции нигде не используются и потому удаляемы компилятором
    B)Разместить значение на стеке или в куче — по «побегу» ссылки за функцию
    C)Порядок вычисления аргументов в выражении
    D)Какие горутины могут выполняться параллельно без гонок
    показать ответ и разбор
    +B)Разместить значение на стеке или в куче — по «побегу» ссылки за функцию

    // разбор: escape analysis — анализ компилятора: если на значение НЕ остаётся ссылок после возврата функции, оно кладётся на стек (дёшево, освобождается само). Если ссылка «убегает» наружу (возвращается указатель, кладётся в интерфейс/замыкание/канал), значение переносится в кучу — под управление GC. Решения смотрят через go build -gcflags=-m. Спот-чек: &local → moved to heap.

  2. #go_escape2 / 4
    func f() *T { t := T{}; return &t }. Где окажется t и легально ли это?
    A)На стеке f, и наружу вернётся висячий указатель, как было бы в C
    B)Ошибка компиляции: возвращать адрес локальной переменной запрещено
    C)В глобальной памяти, общей для всех вызовов f
    D)В куче: escape analysis увёл t туда, указатель остаётся валидным
    показать ответ и разбор
    +D)В куче: escape analysis увёл t туда, указатель остаётся валидным

    // разбор: Возвращать адрес локальной переменной в Go безопасно и идиоматично: escape analysis видит, что ссылка на t переживает функцию, и размещает t в куче, а не на стеке. Никакого висячего указателя (как в C) — GC удержит объект, пока на него есть ссылки. Ценой одной кучевой аллокации. Спот-чек: «moved to heap: t».

  3. #go_escape3 / 4
    Как посмотреть, какие значения компилятор увёл в кучу?
    A)Собрать с флагом -gcflags=-m и прочитать строки про moved to heap
    B)Снять профиль кучи через pprof во время работы программы
    C)Запустить программу с -race: детектор сообщает о размещении в куче
    D)Вызвать runtime.GC() и сравнить статистику до и после
    показать ответ и разбор
    +A)Собрать с флагом -gcflags=-m и прочитать строки про moved to heap

    // разбор: Escape analysis работает при компиляции, и её решения видны прямо в выводе сборки: «moved to heap: t» для возвращаемого адреса локальной переменной, «x escapes to heap» для значения, уложенного в интерфейс. Профиль кучи показывает другое — что уже наотделялось во время работы; он отвечает «сколько и откуда», а -gcflags=-m — «почему вообще в куче».

  4. #go_escape4 / 4
    Какие типичные ситуации заставляют значение уехать в кучу?
    A)Обращение к полю структуры через точку внутри метода типа
    B)Возврат адреса локальной переменной и укладка значения в интерфейс
    C)Объявление переменной внутри цикла вместо объявления до него
    D)Передача аргумента по значению в функцию из другого пакета
    показать ответ и разбор
    +B)Возврат адреса локальной переменной и укладка значения в интерфейс

    // разбор: Три частых источника: вернули &t наружу, положили значение в переменную интерфейсного типа (в том числе аргументом в fmt.Println) или замкнули переменную в функции, которая живёт дольше кадра. Ещё один — слайс, размер которого неизвестен при компиляции. Отсюда практическая оптимизация горячих путей: убрать боксинг в any и заранее задать вместимость слайса.

дальше

Теорию прочитали. Навык ставится повторением

В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.