Память в Go: стек, куча и escape-анализ

Где живёт значение — на стеке или в куче — в Go решает не программист, а escape-анализ компилятора. Что уводит переменную в кучу (возврат указателя, боксинг в interface, захват замыканием), как это увидеть через go build -gcflags=-m, чем это грузит сборщик мусора и как проверять бенчами — на живом стенде с реальным выводом

«Указатель — значит куча» — так думают о размещении памяти в Go, и это неверно. Где живёт значение — на стеке или в куче — решает не тип и не наличие &, а escape-анализ компилятора: статический проход, который отвечает на один вопрос — переживёт ли значение кадр стека, в котором родилось. Если нет — оно остаётся на стеке и исчезает бесплатно при возврате. Если да — «убегает» (escapes) в кучу, и потом его убирает сборщик мусора. Программист не управляет этим явно: он лишь пишет код так, чтобы значение не пережило свой кадр — или смиряется с тем, что оно переживёт.

Эта статья — про то, как читать решения компилятора, что именно уводит переменную в кучу и сколько это стоит в наносекундах и аллокациях. Всё заземлено на живой стенд digital-cookbook/go-memory/escape/: каждое правило — реальная строка go build -gcflags=-m, каждое число — прогнанный бенчмарк (go1.26.3 windows/amd64). Escape-анализ — это не абстракция для авторов компиляторов, а первый инструмент, к которому тянешься, когда профиль показывает лишние аллокации на горячем пути.

Схема «Полдень»: escape-анализ Go — компилятор выносит вердикт «does not escape» (стек, 0 allocs, ~0.24 ns) или «escapes to heap» (куча, GC, 1 alloc, ~16 ns); причины утечки — возврат указателя, упаковка в интерфейс, захват замыканием

В статье

Стек против кучи

У каждой горутины есть свой стек — область, которая растёт и сжимается вместе с цепочкой вызовов. Когда функция вызывается, под её локальные переменные отводится кадр (frame); когда функция возвращается, кадр снимается целиком, одним движением указателя стека. Освобождение памяти на стеке стоит ровно ничего — никакого учёта, никакого сборщика, просто «указатель уехал назад». Именно поэтому стековое размещение — самое дешёвое, что есть.

Куча (heap) — общая для всех горутин область для значений, чьё время жизни не совпадает с временем жизни какого-то одного кадра. За кучей следит сборщик мусора (GC): он периодически обходит достижимые объекты и освобождает недостижимые. Это не бесплатно — каждое значение в куче создаёт работу для GC и давление на аллокатор.

В языках вроде C/C++ размещение выбирает программист: локальная переменная — стек, malloc/new — куча. В Go такого выбора нет. Ключевое слово new и оператор & не означают «в кучу»: они лишь дают адрес значения, а куда это значение ляжет, компилятор решает сам, исходя из того, что с этим адресом дальше происходит. Формально это следует и из Go FAQ: «как размещается хранилище — на стеке или в куче — не должно волновать программиста… Если компилятор может доказать, что на переменную нигде не остаётся ссылок после возврата функции, он разместит её на стеке».

Отсюда единственное правило, которое стоит держать в голове; всё остальное — его частные случаи:

В кучу уходит то, что переживает свой кадр стека. Не «указатель» — а «дожил ли до момента, когда кадр уже снят».

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

Что уводит значение в кучу

Разберём правило на шести функциях стенда. Каждая иллюстрирует ровно один случай, и под каждой — реальная строка из go build -gcflags=-m ./escape/. Полный вывод компилятора приведён ниже целиком; здесь берём его по строкам.

Локаль, адрес которой не покидает функцию → стек

Взять адрес локальной переменной — ещё не повод для кучи. Если указатель используется и «умирает» внутри функции, значение остаётся на стеке.

func newOnStack() int {
	p := &Point{X: 1, Y: 2} // адрес не покидает функцию → стек
	return p.X + p.Y
}

Компилятор доказывает, что p не переживает вызов, и печатает:

escape/escape.go:31:7: &Point{...} does not escape

does not escape — значение на стеке, аллокации нет. Обратите внимание: в коде есть и &, и композитный литерал, а куча всё равно не задействована.

Возврат указателя на локаль → куча

А вот если тот же адрес возвращается наружу, локаль обязана пережить кадр вызывающего — на стеке её держать нельзя.

func newOnHeap() *Point {
	p := Point{X: 1, Y: 2} // p переживает функцию через возвращённый адрес
	return &p
}
escape/escape.go:40:2: moved to heap: p

moved to heap: p — переменная перенесена со стека в кучу. Формулировка «moved to heap» относится именно к именованной локали, у которой взяли адрес и выпустили его наружу.

Боксинг значения в interface{} → куча

Интерфейс в Go — это пара «(тип, указатель на данные)». Чтобы положить в interface{}/any значение-примитив, его нужно «упаковать» (boxing) — разместить где-то, куда будет указывать интерфейс. Escape-анализ помечает это размещение как уход в кучу (x escapes to heap). Но помечено ≠ всегда аллоцируется: фактическое размещение зависит от сценария и оптимизаций рантайма — ниже в разделе про бенчмарки увидим, как для малых целых аллокация исчезает вовсе.

func boxIntoInterface(x int) any {
	var v any = x // боксинг int в интерфейс → escape
	return v
}
escape/escape.go:50:14: x escapes to heap

x escapes to heap — значение убегает через упаковку в интерфейс. Подробнее про внутреннее устройство пары «тип+данные» — в статье про интерфейсы Go; там же — почему боксинг примитивов вообще происходит. У этого правила есть тонкий нюанс с малыми целыми — про него в разделе про бенчмарки.

Ссылка утекает в долгоживущую структуру → куча

Если указатель на локаль сохранить в структуру, которая переживёт вызов (пакетную переменную, срез, поле долгоживущего объекта), — значение уходит в кучу по тому же принципу: оно теперь достижимо после снятия кадра.

var leaked []*Point // пакетная переменная — переживает любой вызов

func leakViaSlice() {
	p := Point{X: 1, Y: 2}      // p доживает дольше вызова через срез
	leaked = append(leaked, &p) // ссылка утекает в пакетную переменную
}
escape/escape.go:60:2: moved to heap: p
escape/escape.go:61:17: append escapes to heap

Снова moved to heap: p — но причина другая: не возврат, а утечка ссылки в leaked. Про то, как устроен рост среза при append и почему заодно «убегает» сам бэкинг-массив, — в разборе внутренностей срезов и map.

*T-аргумент, который только читают → стек

Передача указателя в функцию сама по себе кучу не вызывает. Если вызываемая функция только читает через указатель и никуда его не сохраняет, адрес не «убегает» — значение вызывающего остаётся на стеке.

func stackArg(p *Point) int {
	return p.X + p.Y // указатель только читается, не утекает
}
escape/escape.go:69:15: p does not escape

p does not escape — параметр не утекает, вызывающему не нужно уводить своё значение в кучу. Это ровно то, что делает передачу *T в «читающие» методы дешёвой: escape-анализ проходит сквозь границу вызова.

Захват замыканием, переживающим вызов → куча

Замыкание держит захваченные переменные живыми, пока живо само. Если замыкание переживает функцию (например, его вернули), захваченные переменные тоже обязаны пережить кадр — их переносят в кучу.

func closureCapture() func() int {
	n := 0 // захвачена замыканием, которое переживает функцию → куча
	return func() int {
		n++
		return n
	}
}
escape/escape.go:79:2: moved to heap: n
escape/escape.go:80:9: func literal escapes to heap

Здесь две строки: moved to heap: n — захваченная переменная ушла в кучу; func literal escapes to heap — само замыкание (возвращаемое наружу) тоже размещается в куче. Если бы замыкание вызывалось на месте и не покидало функцию, n могла бы остаться на стеке.

Свод правил

Все шесть случаев — одно правило под разными углами. «Указатель» ≠ «куча»: решает не форма кода, а время жизни.

Функция Где живёт Почему Строка -gcflags=-m
newOnStack стек адрес локали не покидает функцию &Point{...} does not escape
newOnHeap куча возвращается указатель на локаль moved to heap: p
boxIntoInterface куча боксинг значения в any x escapes to heap
leakViaSlice куча ссылка утекает в пакетный срез moved to heap: p
stackArg стек *Point-аргумент только читается p does not escape
closureCapture куча захват замыканием, переживающим вызов moved to heap: n + func literal escapes to heap

Отдельный механизм: размер, а не время жизни

Все шесть случаев выше — про время жизни («переживает ли значение кадр»). Но есть вторая, столь же частая причина уйти в кучу — размер, который компилятор не может разложить на стеке. make([]byte, n) с непостоянным n уходит в кучу, даже если буфер локален и никуда не убегает: чтобы зарезервировать место на стеке, размер нужен на этапе компиляции, а n известен только в рантайме. Константный make([]byte, 64) — остаётся на стеке.

Тонкость, ломающая наивное чтение -gcflags=-m: escape-анализ обе функции метит одинаковоdoes not escape:

escape/escape.go:95:13: make([]byte, n) does not escape
escape/escape.go:102:13: make([]byte, 64) does not escape

Но does not escape ≠ «ноль аллокаций». Бенчмарк стенда (go1.26.3 windows/amd64) показывает разницу, которой в построчном выводе компилятора нет:

Бенчмарк ns/op allocs/op Что показывает
SizeVarMake (make([]byte, n)) ~21.4 1 (64 B) размер-переменная → куча, хотя «does not escape»
SizeConstMake (make([]byte, 64)) ~1.4 0 размер-константа → стек

Практический вывод продолжает тему статьи: -gcflags=-m показывает намерение escape-анализа (убегает ли значение из кадра), а окончательный вердикт «была ли аллокация» даёт только allocs/op в бенче. Буфер с размером-переменной — классическое «почему мой явно локальный слайс всё равно аллоцировал»: он не escape по времени жизни, но уходит в кучу по размеру. Знаете размер и он невелик — берите массив фиксированной длины или константный make; не знаете — аллокация неизбежна, и её лучше амортизировать (переиспользуемый буфер, sync.Pool).

Как читать escape-анализ

Решения escape-анализа компилятор печатает по флагу -m:

go build -gcflags=-m ./...

Флаг передаётся именно компилятору (gc) через -gcflags. Для всего стенда вывод целиком выглядит так:

# tech.khorost/go-memory-cookbook/escape
escape/escape.go:30:6: can inline newOnStack
escape/escape.go:39:6: can inline newOnHeap
escape/escape.go:49:6: can inline boxIntoInterface
escape/escape.go:59:6: can inline leakViaSlice
escape/escape.go:69:6: can inline stackArg
escape/escape.go:78:6: can inline closureCapture
escape/escape.go:80:9: can inline closureCapture.func1
escape/escape.go:31:7: &Point{...} does not escape
escape/escape.go:40:2: moved to heap: p
escape/escape.go:50:14: x escapes to heap
escape/escape.go:60:2: moved to heap: p
escape/escape.go:61:17: append escapes to heap
escape/escape.go:69:15: p does not escape
escape/escape.go:79:2: moved to heap: n
escape/escape.go:80:9: func literal escapes to heap
escape/escape.go:95:13: make([]byte, n) does not escape
escape/escape.go:102:13: make([]byte, 64) does not escape

Как расшифровывать строки:

  • X does not escape — значение или аргумент не переживает вызов в смысле времени жизни; escape-анализ не переносит его в кучу ради переживания кадра. Это ещё не гарантия нулевой аллокации: does not escape — про время жизни, а не про все причины разместить объект в куче (см. make([]byte, n) ниже и раздел про размер).
  • X escapes to heap — значение переживает вызов, размещается в куче.
  • moved to heap: X — именованная локальная переменная перенесена со стека в кучу (обычно потому, что взяли её адрес и выпустили наружу).
  • func literal escapes to heap — само замыкание уходит в кучу.
  • make([]byte, n) does not escape vs make([]byte, 64) does not escape — обе строки говорят одно и то же про время жизни, но размещение разное: буфер с размером-переменной n всё равно уйдёт в кучу (1 аллокация), а с константой 64 — останется на стеке. Флаг -m этой разницы по строкам не показывает — её ловит только бенч (см. ниже).
  • can inline …, inlining call to … — сообщения про инлайнинг, не про escape; их можно игнорировать, когда смотришь именно размещение памяти.

Если нужно понять почему компилятор принял решение, удвойте флаг:

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

С -m -m компилятор печатает цепочку: через какие присваивания и вызовы ссылка «дотекает» до места, вынуждающего кучу. Это тот уровень, на котором видно, например, что параметр «утекает в результат», и понятно, какое звено рвать, чтобы escape исчез.

Одно предупреждение: строки и номера позиций машинозависимы и версионнозависимы. Точные формулировки, порядок строк и решения по инлайнингу отличаются между релизами Go и целевыми платформами. Вывод выше снят на go1.26.3; воспроизводите его на своей сборке, а не сверяйтесь дословно.

Цена в бенчмарках

Escape видно не только в выводе компилятора, но и в аллокациях. Бенчмарки стенда с b.ReportAllocs() печатают allocs/op и B/op — прямое доказательство «стек или куча». Значения аккумулируются в sink-переменные, чтобы компилятор не выкинул вызовы как мёртвый код.

Числа сверены на go1.26.3 windows/amd64 (ориентировочно):

Бенчмарк ns/op allocs/op B/op Что показывает
BenchmarkOnStack ~0.24 0 0 значение на стеке — аллокаций нет
BenchmarkOnHeap ~16 1 16 указатель на локаль → 1 аллокация в куче
BenchmarkBoxInterface ~13 1 8 боксинг в any → аллокация упаковки
BenchmarkStackArg ~0.53 0 0 *T-аргумент не утекает — аллокаций нет

Разница между стеком и кучей здесь — не проценты, а порядки: ~0.24 ns/op и ноль аллокаций у newOnStack против ~16 ns/op и одной аллокации у newOnHeap. stackArg (~0.53 ns/op, 0 allocs) подтверждает: передача указателя, который только читают, остаётся на стеке.

Важный нюанс: кэш малых целых

С BenchmarkBoxInterface связана ловушка, о которой стоит знать. Escape-анализ метит боксинг int в any как x escapes to heap — казалось бы, каждая упаковка обязана аллоцировать. Но рантайм Go кэширует малые целые (0–255) в статической таблице staticuint64s: интерфейс, боксящий такое значение, указывает на уже существующую ячейку, и аллокации не происходит, хотя компилятор пометил heap. То есть escape-анализ описывает намерение разместить в куче, а рантайм на малых значениях эту аллокацию оптимизирует прочь.

Поэтому наивный бенч, боксящий 0, 1, 2…, показал бы обманчивый ноль аллокаций. Бенч стенда специально боксит значения больше 255, чтобы кэш их не покрывал и аллокация упаковки была видна:

func BenchmarkBoxInterface(b *testing.B) {
	b.ReportAllocs()
	// Боксим значения > 255: малые int (0–255) рантайм кэширует в staticuint64s,
	// и на них боксинг НЕ аллоцирует, хотя escape-анализ и метит «escapes to heap».
	for i := 0; i < b.N; i++ {
		sinkAny = boxIntoInterface(i + 256)
	}
}

Вывод отсюда общий: -gcflags=-m говорит о решении компилятора, а allocs/op — о фактическом поведении рантайма. Обычно они совпадают, но кэш малых целых — пример, где расходятся. Верить в вопросе «сколько реально аллоцируется» надо -benchmem, а -gcflags=-m использовать, чтобы понять причину.

Связь со сборщиком мусора

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

Отсюда практический рычаг: убрать лишний escape = снять нагрузку с GC, не трогая ни его настройки, ни GOGC. Типичные приёмы — возвращать значение вместо указателя там, где копия дешевле кучи; переиспользовать буфер вместо создания нового на каждой итерации; держать *T-аргументы «читающими», чтобы они не утекали; выносить боксинг в интерфейс за пределы горячего цикла. Каждый устранённый escape — это аллокация, которая не родилась, и работа, которую не сделает сборщик.

Важно и обратное: не всякий escape вреден. Долгоживущий объект обязан быть в куче — это правильно, и бороться с этим бессмысленно. Оптимизировать стоит escape на горячем пути, где аллокации плодятся тысячами в секунду; там разница «стек против кучи» превращается в разницу latency. Механику самого сборщика и то, как это выглядит в других рантаймах, разбирает статья про управление памятью в разных языкахготовится, с 24 сентября.

Как диагностировать

Рабочий цикл — три инструмента в порядке «что → почему → где под нагрузкой» (команды ниже — из корня go-memory; из самой папки escape/ меняйте ./escape/ на .):

  1. go test -benchmem — первый экран. Колонки allocs/op и B/op показывают, сколько аллокаций и байт на операцию. Здесь видно, что аллоцирует и насколько горячо.
go test -bench=. -benchmem -run='^$' ./escape/
  1. go build -gcflags=-mпочему именно эти аллокации: какая переменная и по какой причине ушла в кучу. С -m -m — полная цепочка «утекания» ссылки.
go build -gcflags='-m -m' ./...
  1. pprof alloc-профильгде в коде рождаются аллокации под реальной нагрузкой. Собрать профиль в бенче и открыть по объёму или числу объектов:
go test -bench=. -memprofile=mem.out -run='^$' ./escape/
go tool pprof -alloc_space mem.out    # по байтам
go tool pprof -alloc_objects mem.out  # по числу объектов

Типовой маршрут: нашли горячую аллокацию в -benchmem или pprof → посмотрели причину в -gcflags=-m → убрали escape (значение вместо указателя, переиспользование буфера, sync.Pool) → перемерили. Подробнее про то, как ставить корректные бенчмарки и снимать pprof-профили без ложных выводов, — в статье про профилирование и бенчмарки в Go.

Демо и версии

  • Код: живой стенд digital-cookbook/go-memory/, подкаталог escape/ — шесть показательных функций (по одной на правило), тесты корректности (go test ./... зелёный) и бенчмарки аллокаций. Корректное поведение покрыто тестами, а «стек или куча» видно глазами — в строках -gcflags=-m и в колонке allocs/op. Код в тексте — выжимки.
  • Как воспроизвести (из корня go-memory): go build -gcflags=-m ./escape/ (escape-анализ, -m -m — подробнее) и go test -bench=. -benchmem -run='^$' ./escape/ (аллокации). Если вы уже внутри папки escape/, замените ./escape/ на .: go build -gcflags=-m . и go test -bench=. -benchmem -run='^$' .. Числа сверены на go1.26.3 windows/amd64.
  • Оговорка: точные строки -gcflags=-m (формулировки, позиции, решения по инлайнингу) и числа бенчмарков машинозависимы и версионнозависимы — снимайте их на своей сборке, а не сверяйтесь дословно с приведёнными.
  • Смежные статьи: профилирование и бенчмарки в Go, интерфейсы Go (боксинг и пара «тип+данные»), внутренности срезов и map, управление памятью в разных языкахготовится, с 24 сентября.

Документация и первоисточники

Обсуждение в Telegram

Присоединиться →

Комментарии