Escape analysis, стек и куча

О лекции

Локальная переменная живёт на стеке горутины, пока компилятор не решит, что она «убегает». Тогда объект на куче, и его жизнь оплачивает сборщик мусора.

Рантайм и инструменты Go golang собеседование

У каждой горутины свой стек: быстро выделяется, растёт и сжимается. Куча процесса общая: объекты переживают функцию, их собирает GC. Куда попадёт переменная, решает анализ побега на этапе компиляции. В исходнике нет слова «положи на стек», как в C.

На бэкенде это про аллокации в горячем пути. Лишний указатель, интерфейс, замыкание в цикле — давление на сборщик, хвост задержки, больше CPU. Микрооптимизация вслепую вредна. Сначала бенчмарк и флаг компилятора, потом упрощение кода.

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

Что заставляет escape

Вернули указатель на локальную переменную — она должна пережить функцию, значит куча. Сохранили адрес в структуре, которая уже на куче, в глобальной, в слайсе, который уходит наружу. Положили значение в интерфейс: часто упаковка, объект на куче. Точный случай зависит от компилятора.

func heapInt() *int {
	x := 1
	return &x // x escapes to heap
}

func stackOK() int {
	x := 1
	return x // значение, не адрес: x может остаться на стеке
}

// go build -gcflags='-m' .
// ищите строки «escapes to heap»

Указатель наружу — побег на кучу. Флаг -m показывает решение компилятора.

Пример. Хендлер собирает user := User{Name: name} и делает return &user. Структура уезжает на кучу. Если вернуть значение User, шанс остаться на стеке выше. Не цель сама по себе — смотрите бенчмарк.

Замыкание, которое уходит в горутину или в слайс колбэков, захватывает переменные — они живут с замыканием на куче. append в слайс, который возвращаете, кладёт элементы туда, где живёт массив: часто куча. Это нормально. Плохо, когда в горячем цикле плодят лишние мелкие объекты без нужды.

Как смотреть

go build -gcflags='-m' пишет, что ушло на кучу и почему. Читать всё подряд бессмысленно: смотрят функции из профиля. В бенчмарке b.ReportAllocs() и -benchmem показывают allocs/op и B/op. Падение аллокаций после упрощения типа — проверяемый эффект, не легенда.

Не боритесь с побегом ради борьбы. Читаемый код с одной аллокацией на запрос лучше нечитаемого с нулём. Имеет смысл горячий путь сериализации, копирование буферов, лишние fmt.Sprintf и интерфейсы на каждый мелкий тип. Стек не бесконечен: глубокая рекурсия и огромный массив в кадре могут раздуть стек горутины. Огромный массив в кадре часто как раз уезжает на кучу.

Типичная ошибка

Обещать «в Go всё на стеке» или наоборот «new всегда куча». Решает компилятор. Вернуть указатель на локальную переменную — типичный побег. Гнаться за нулём аллокаций без бенчмарка. Путать стек горутины с кучей процесса и с двоичной кучей как структурой данных.

На собеседовании

Указатель или интерфейс наружу → куча → сборщик. Смотреть -gcflags=-m и allocs в бенчмарке. В горячем пути убирают лишние упаковки и замыкания в цикле, а не переписывают весь сервис.