Escape analysis в Go
Собери программу с флагом -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, остальные разбираются в тренажёре.
- Что определяет escape analysis компилятора Go?A)Какие переменные в функции нигде не используются и потому удаляемы компиляторомB)Разместить значение на стеке или в куче — по «побегу» ссылки за функциюC)Порядок вычисления аргументов в выраженииD)Какие горутины могут выполняться параллельно без гонок
показать ответ и разбор
+B)Разместить значение на стеке или в куче — по «побегу» ссылки за функцию// разбор: escape analysis — анализ компилятора: если на значение НЕ остаётся ссылок после возврата функции, оно кладётся на стек (дёшево, освобождается само). Если ссылка «убегает» наружу (возвращается указатель, кладётся в интерфейс/замыкание/канал), значение переносится в кучу — под управление GC. Решения смотрят через go build -gcflags=-m. Спот-чек: &local → moved to heap.
- func f() *T { t := T{}; return &t }. Где окажется t и легально ли это?A)На стеке f, и наружу вернётся висячий указатель, как было бы в CB)Ошибка компиляции: возвращать адрес локальной переменной запрещеноC)В глобальной памяти, общей для всех вызовов fD)В куче: escape analysis увёл t туда, указатель остаётся валидным
показать ответ и разбор
+D)В куче: escape analysis увёл t туда, указатель остаётся валидным// разбор: Возвращать адрес локальной переменной в Go безопасно и идиоматично: escape analysis видит, что ссылка на t переживает функцию, и размещает t в куче, а не на стеке. Никакого висячего указателя (как в C) — GC удержит объект, пока на него есть ссылки. Ценой одной кучевой аллокации. Спот-чек: «moved to heap: t».
- Как посмотреть, какие значения компилятор увёл в кучу?A)Собрать с флагом -gcflags=-m и прочитать строки про moved to heapB)Снять профиль кучи через pprof во время работы программыC)Запустить программу с -race: детектор сообщает о размещении в кучеD)Вызвать runtime.GC() и сравнить статистику до и после
показать ответ и разбор
+A)Собрать с флагом -gcflags=-m и прочитать строки про moved to heap// разбор: Escape analysis работает при компиляции, и её решения видны прямо в выводе сборки: «moved to heap: t» для возвращаемого адреса локальной переменной, «x escapes to heap» для значения, уложенного в интерфейс. Профиль кучи показывает другое — что уже наотделялось во время работы; он отвечает «сколько и откуда», а -gcflags=-m — «почему вообще в куче».
- Какие типичные ситуации заставляют значение уехать в кучу?A)Обращение к полю структуры через точку внутри метода типаB)Возврат адреса локальной переменной и укладка значения в интерфейсC)Объявление переменной внутри цикла вместо объявления до негоD)Передача аргумента по значению в функцию из другого пакета
показать ответ и разбор
+B)Возврат адреса локальной переменной и укладка значения в интерфейс// разбор: Три частых источника: вернули &t наружу, положили значение в переменную интерфейсного типа (в том числе аргументом в fmt.Println) или замкнули переменную в функции, которая живёт дольше кадра. Ещё один — слайс, размер которого неизвестен при компиляции. Отсюда практическая оптимизация горячих путей: убрать боксинг в any и заранее задать вместимость слайса.
дальше
Теорию прочитали. Навык ставится повторением
В Сеньорчике эта подтема идёт в ежедневных сессиях: движок возвращает её, пока ответы не станут уверенными, и ведёт прогресс отдельно по каждой подтеме. Теория внутри тоже бесплатна, лимит только на количество вопросов в день.