Escape analysis в Go: почему переменная попадает в кучу и как это проверить

• 11 мин чтения

Возврат указателя на локальную переменную обычно связывают с аллокацией в куче. Но сообщение moved to heap в диагностике и 0 allocs/op в бенчмарке могут относиться к одному исходнику. Разберу, почему это не противоречие, как читать go build -gcflags=-m=2 и когда найденную аллокацию вообще стоит устранять. Примеры запущены на Go 1.26.9 для linux/amd64 на процессоре AMD Ryzen 5 PRO 4650U.

Какую задачу решает escape-анализ

В Go нельзя явно выбрать стек или кучу. Компилятор принимает решение за разработчика. Для этого он строит граф связей между значениями и смотрит, куда могут попасть ссылки на них.

В этих примерах значение «убегает», когда ссылка на него может сохраниться после возврата функции. Это может произойти через return, глобальную переменную, канал или замыкание. Но перенос в кучу возможен и по другим причинам, например из-за размера объекта.

Рассмотрим два варианта:

package example

func localValue() int {
	x := 42
	return x
}

func returnedPointer() *int {
	x := 42
	return &x
}

В localValue вызывающий код получает копию числа. После завершения функции исходный x больше никому не нужен. Поэтому его можно разместить в кадре стека.

В returnedPointer функция возвращает адрес x. Вызывающий код сможет разыменовать указатель после завершения returnedPointer. Компилятор обязан сохранить объект, на который указывает результат. Обычно такой объект размещается в куче.

Здесь важно различать переменную и хранилище значения. Когда говорят «переменная убежала в кучу», имеют в виду объект, в котором она хранится. Само имя x существует только во время компиляции.

Что меняется между стеком и кучей

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

Объекты в куче живут не по кадрам функций. Их память освобождает сборщик мусора после того, как объект перестаёт быть достижимым. За это приходится платить работой по выделению памяти, учёту указателей и последующей сборке.

Но куча не является ошибкой. Без неё нельзя вернуть долгоживущий объект, сохранить состояние в замыкании или собрать структуру данных с произвольным временем жизни. Стек тоже не бесплатен: большие кадры требуют записи памяти, а рост стека имеет свою цену.

Поэтому полезный вопрос не «как убрать все аллокации», а «есть ли лишние аллокации на горячем пути». Ответ на него дают профиль и бенчмарк, а не одна строка диагностики компилятора.

Как увидеть решения компилятора

Сохраним примеры в пакете и запустим сборку с диагностикой:

$ go build -gcflags='-m=2' .

Флаг -gcflags передаёт аргументы компилятору Go. Опция -m включает сообщения об оптимизациях. Уровень 2 добавляет причину и цепочку, по которой значение убежало.

Для returnedPointer компилятор вывел:

./escape.go:11:2: x escapes to heap in returnedPointer:
./escape.go:11:2:   flow: ~r0 ← &x:
./escape.go:11:2:     from &x (address-of) at ./escape.go:12:9
./escape.go:11:2:     from return &x (return) at ./escape.go:12:2
./escape.go:11:2: moved to heap: x

Последняя строка сообщает о фактическом переносе x в кучу. Строки с flow показывают путь: адрес x стал возвращаемым значением ~r0. Такой разбор полезнее, чем просто escapes to heap: в большой функции видно, какое именно присваивание или вызов изменило решение.

Для localValue сообщения moved to heap нет. Зато компилятор сообщает, что функцию можно встроить:

./escape.go:5:6: can inline localValue with cost 7 as: func() int { x := 42; return x }

Инлайнинг подставляет тело функции вместо вызова и меняет контекст анализа. Итоговая программа может вести себя не так, как изолированная функция.

Диагностику можно запустить для всех пакетов модуля:

$ go build -gcflags='-m=2' ./...

Вывод будет большим: компилятор сообщает не только об escape-анализе, но и об инлайнинге, развернутых операциях и других решениях. Для разбора конкретного места удобно сохранить вывод в файл и искать по имени функции. Номера строк в примерах зависят от расположения кода, поэтому ориентироваться стоит на имена и сообщения.

Типичные причины переноса в кучу

Возврат указателя

Это самый понятный случай:

func newScore() *int {
	score := 100
	return &score
}

Объект score нужен после возврата функции. В отличие от языков с неопределённым поведением для указателя на локальную переменную, Go делает такой код корректным. Вызывающий код получит рабочий указатель, а память позже освободит сборщик мусора.

Возврат указателя не означает, что код надо немедленно переписать. Если функция вызывается редко, одна аллокация обычно не имеет значения. Кроме того, инлайнинг может убрать её полностью.

Сохранение ссылки вне функции

Похожий результат даёт запись адреса в глобальную переменную:

package example

var saved *int

func saveScore() {
	score := 100
	saved = &score
}

После завершения saveScore адрес остаётся доступен через saved. В диагностике появится цепочка к {heap} и сообщение moved to heap: score.

Глобальная переменная здесь лишь наглядный пример. Тот же принцип работает при записи указателя в поле долгоживущей структуры, отправке через канал или сохранении в map. Компилятор анализирует не синтаксическую форму «глобальная переменная», а возможный путь ссылки.

Замыкание, которое переживает функцию

func counter() func() int {
	n := 0
	return func() int {
		n++
		return n
	}
}

Возвращаемая функция изменяет n. Значит, все вызовы замыкания должны обращаться к одному состоянию. Его нельзя оставить в исчезнувшем кадре counter.

Go 1.26.9 показывает сразу два решения:

./escape.go:20:2: counter capturing by ref: n (addr=false assign=true width=8)
./escape.go:21:9: func literal escapes to heap
./escape.go:20:2: n escapes to heap in counter:
./escape.go:20:2:   flow: {storage for func literal} ← &n:
./escape.go:20:2:     from n (captured by a closure) at ./escape.go:22:3
./escape.go:20:2:     from n (reference) at ./escape.go:22:3
./escape.go:20:2: moved to heap: n

В кучу попадают и объект замыкания, и захваченное n. Но если замыкание создаётся и используется только внутри одной функции, компилятор может оставить всё на стеке. Само наличие func() { ... } ничего не гарантирует.

Передача значения через интерфейс

Интерфейсное значение состоит из описания типа и ссылки на данные. При упаковке значения компилятору иногда приходится создать объект в куче:

package example

import "fmt"

func printValue(x int) string {
	return fmt.Sprint(x)
}

Диагностика для x:

./escape.go:16:20: x escapes to heap in printValue:
./escape.go:16:20:   flow: {storage for ... argument} ← &{storage for x}:
./escape.go:16:20:     from x (spill) at ./escape.go:16:20
./escape.go:16:20:     from ... argument (slice-literal-element) at ./escape.go:16:19
./escape.go:16:20:   flow: {heap} ← {storage for ... argument}:
./escape.go:16:20:     from fmt.Sprint(... argument...) (call parameter) at ./escape.go:16:19
./escape.go:16:20: x escapes to heap

Здесь x упаковывается в any, потому что fmt.Sprint принимает variadic-параметр ...any. Диагностика также сообщает, что срез variadic-аргументов не убежал, хотя его элемент потребовал хранилища для x.

Не всякая работа с интерфейсом обязана аллоцировать память. Решение зависит от конкретного вызова, типа, размера значения и возможностей оптимизации. Например, специализированный API вроде slog.Int позволяет избежать упаковки числа в any там, где универсальный slog.Any её использует. Сравнивать нужно на своём коде.

Возврат слайса

Слайс сам по себе является небольшим дескриптором: указатель, длина и ёмкость. Отдельно существует базовый массив. Escape-анализ рассматривает и его.

func localSlice() int {
	buf := make([]int, 64)
	buf[0] = 42
	return buf[0]
}

func returnedSlice() []int {
	buf := make([]int, 64)
	buf[0] = 42
	return buf
}

В первом случае массив не нужен после возврата числа. Компилятор сообщает:

./escape.go:30:13: make([]int, 64) does not escape

Во втором базовый массив доступен через возвращаемый слайс:

./escape.go:36:13: make([]int, 64) escapes to heap in returnedSlice:
./escape.go:36:13:   flow: buf ← &{storage for make([]int, 64)}:
./escape.go:36:13:     from return buf (return) at ./escape.go:38:2
./escape.go:36:13: make([]int, 64) escapes to heap

То же относится к map и каналам: make описывает создание значения, но не фиксирует место хранения. Локальный make может остаться на стеке, а долгоживущий объект попадёт в кучу.

Слишком большой объект для стека

Иногда значение переносится не потому, что его адрес покидает функцию. У компилятора есть пределы размера объектов на стеке. В исходниках Go для обычных переменных используется ir.MaxStackVarSize. Для неявных объектов вроде new используется ir.MaxImplicitStackVarSize. В разобранной ранее статье о нулевых значениях эти пределы составляли 128 и 64 КиБ соответственно.

Это детали реализации, а не гарантии спецификации. Не стоит проектировать структуры ровно под текущий порог. Если размер буфера важен для производительности, проверяйте вывод компилятора и бенчмарк после обновления Go.

Почему взятие адреса ещё ничего не доказывает

Оператор & сам по себе не отправляет значение в кучу:

func increment(x *int) {
	*x++
}

func calculate() int {
	n := 41
	increment(&n)
	return n
}

Адрес n передаётся в increment, но не сохраняется там после возврата. Компилятор видит всю цепочку и может оставить n на стеке. После инлайнинга increment в calculate анализ станет ещё проще.

Похожая ситуация с new:

func localNew() int {
	n := new(int)
	*n = 42
	return *n
}

new(int) создаёт указатель, но объект не обязательно попадает в кучу. Если указатель используется только локально, компилятор может разместить значение на стеке или вообще свести функцию к константе.

Обратное тоже верно: в коде может не быть ни &, ни new, но аллокация появится при упаковке в интерфейс, создании замыкания или вызове make. Смотреть нужно на диагностику конкретной версии компилятора, а не считать символы в исходнике.

Как инлайнинг меняет картину

Рассмотрим бенчмарк функции, которая возвращает указатель:

package example

import "testing"

func returnedPointer() *int {
	x := 42
	return &x
}

func BenchmarkReturnedPointer(b *testing.B) {
	b.ReportAllocs()
	for b.Loop() {
		_ = returnedPointer()
	}
}

На Go 1.26.9 результат оказался таким:

BenchmarkReturnedPointer-12  785232626  1.954 ns/op  0 B/op  0 allocs/op

При этом диагностика изолированной функции сообщала moved to heap: x. Противоречия нет. После инлайнинга контекст изменился: в этом месте вызова бенчмарк не показывает аллокаций в куче. Сообщение moved to heap для отдельной функции не означает, что каждый её вызов в итоговой программе обязательно аллоцирует память.

Для проверки самой функции добавим директиву //go:noinline:

//go:noinline
func returnedPointer() *int {
	x := 42
	return &x
}

Теперь измерение показывает реальную аллокацию вызова:

BenchmarkReturnedPointer-12  45087049  28.35 ns/op  8 B/op  1 allocs/op

Директива полезна для эксперимента, но её не следует добавлять в рабочий код только ради красивого бенчмарка. Она запрещает оптимизацию и может сделать измерение менее похожим на реальную программу.

Практический вывод такой: -gcflags=-m=2 объясняет возможности и причины, а бенчмарк показывает результат в конкретном месте вызова. Для оптимизации нужны оба инструмента.

Как подтвердить влияние на производительность

Метод b.ReportAllocs() добавляет в отчёт B/op и allocs/op. Первая метрика показывает байты в куче на операцию. Вторая показывает число выделений памяти. Подробнее о настройке бенчмарков и сравнении результатов через benchstat я писал в статье о бенчмаркинге.

Сравним локальный и возвращаемый слайс из примера выше. Обе функции помечены //go:noinline, чтобы измерение не зависело от подстановки тела:

package example

import "testing"

//go:noinline
func localSlice() int {
	buf := make([]int, 64)
	buf[0] = 42
	return buf[0]
}

//go:noinline
func returnedSlice() []int {
	buf := make([]int, 64)
	buf[0] = 42
	return buf
}

func BenchmarkLocalSlice(b *testing.B) {
	b.ReportAllocs()
	for b.Loop() {
		_ = localSlice()
	}
}

func BenchmarkReturnedSlice(b *testing.B) {
	b.ReportAllocs()
	for b.Loop() {
		_ = returnedSlice()
	}
}

Запускаю оба бенчмарка один раз:

$ go test -bench='Benchmark(LocalSlice|ReturnedSlice)$' -benchmem -count=1 .
BenchmarkLocalSlice-12      511347346    2.080 ns/op    0 B/op    0 allocs/op
BenchmarkReturnedSlice-12     5793662  214.8 ns/op   512 B/op    1 allocs/op

512 байт соответствуют массиву из 64 значений int на 64-битной платформе. Времена показывают один запуск на указанном выше процессоре, а не устойчивый коэффициент ускорения. Переносить эти наносекунды на приложение нельзя. В реальном коде работа с данными, кэш процессора и сборщик мусора изменят результат.

Для поиска места оптимизации последовательность такая:

  1. Снять профиль реальной нагрузки через pprof.
  2. Найти горячую функцию и значительный вклад аллокаций.
  3. Проверить её вывод -gcflags=-m=2.
  4. Убрать конкретную лишнюю аллокацию, если это возможно без ухудшения API.
  5. Сравнить версии одним бенчмарком с достаточным числом запусков.

Работа с pprof разобрана в отдельной статье. Без профиля легко оптимизировать редкий вызов и не получить заметного эффекта для системы.

Когда не стоит бороться с аллокацией

Есть несколько распространённых ловушек.

Не стоит заменять понятный API на переиспользуемые буферы без измерений. Если функция вызывается при старте приложения, возврат нового слайса обычно удобнее и безопаснее общего пула. Профиль должен подтвердить необходимость усложнения.

Возврат значения вместо указателя тоже не всегда помогает. Большая структура требует копирования. Указатель может быть правильным выбором по семантике, особенно если объект изменяемый или разделяемый.

Передавать слайс через указатель ради экономии обычно не нужно. Слайс уже содержит указатель на базовый массив. Тип *[]int чаще только усложняет код.

Ноль аллокаций в микробенчмарке не доказывает, что вся программа стала быстрее. Могут вырасти задержки из-за синхронизации пула или удержания больших буферов. Иногда проигрывает ещё и читаемость кода.

Наконец, решения компилятора меняются между выпусками Go. Поэтому вывод в статье привязан к Go 1.26.9. Для рабочего проекта диагностику нужно снимать его toolchain.

Короткий ориентир по выводу компилятора

СообщениеЧто оно означает
does not escapeОбъект можно оставить локальным, перенос в кучу не требуется
escapes to heapАнализ компилятора требует размещения объекта в куче. Причину нужно смотреть в соседних сообщениях
moved to heap: xКомпилятор фактически перенёс хранилище x в кучу
flow: ...Цепочка присваиваний и вызовов, приведшая к решению
can inlineФункция может быть встроена, после чего решения способны измениться
leaking paramПараметр или его часть передаётся дальше, но это ещё не всегда означает итоговую аллокацию

В большом выводе сначала ищите moved to heap и escapes to heap возле нужной функции. Затем поднимайтесь по flow к источнику. Сообщения об инлайнинге помогут понять, почему в месте вызова результат отличается.

Итог

Escape-анализ сохраняет корректность программы и выбирает место хранения значений. Если ссылка нужна после завершения функции, объект обычно попадает в кучу. Если время жизни локально, компилятор может использовать стек или удалить объект после других оптимизаций.

Сообщение escapes to heap — повод разобраться в причине, а не автоматически переписывать функцию. Запустите go build -gcflags='-m=2', проследите flow, затем проверьте место вызова через b.ReportAllocs(). Для решения об оптимизации нужно понять, где возникает аллокация, насколько часто выполняется этот путь и что придётся усложнить ради её устранения.


Об авторе: Александр Бруяко — руководитель разработки с опытом в backend, инфраструктуре и управлении командами.


Теги: