Deep Engineering
Продвинутый·Опубликовано·25 МИН

Стек, куча и escape-анализ: почему указатель ещё ничего не значит

Где живёт значение, в Go решает не программист и не наличие & — решает компилятор, и вопрос у него один: переживёт ли значение свой кадр. Замерено: указатель без ухода наружу даёт ноль выделений, а структура без единого указателя — одно; уход в кучу стоит ×12,8, и цена растёт вместе с размером значения.

Полное техническое изложение

TL;DR

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

Отсюда главное следствие: по виду кода место не угадывается. Замерено: адрес взят, наружу не ушёл — 0 выделений, и компилятор так и печатает: does not escape. И наоборот: структура без единого &, положенная в интерфейс, — 1 выделение, потому что интерфейс хранит адрес. Оба ходовых правила — «указатель значит куча» и «нет указателя значит стек» — ошибаются, и ошибаются в разные стороны.

Дальше — числа, флаги и границы. Один и тот же вызов по указателю решается по-разному: вызываемая функция только читает — 0, сохраняет — 1. Размер, известный только в рантайме, — это куча: make([]byte, 64) даёт 0 выделений, make([]byte, n) — 1, даже если срез никуда не уходит. fmt выделяет, потому что аргументы у него ...any, но на числах 0–255 — не выделяет: у рантайма есть готовый массив таких значений. Цена ухода в кучу растёт с размером значения: ×8,6 на шестнадцати байтах и ×33,5 на двухстах пятидесяти шести — это не надбавка, а работа. И решения печатает сам компилятор (go build -gcflags='-m'); они зависят от его версии, поэтому смотреть стоит свой вывод, а не запоминать список случаев.

Порог входа
Перед уроком достаточно понимать
  • функция заводит локальные переменные, и обычно они нужны, только пока она работает;
  • у значения можно взять адрес и передать его дальше — в другую функцию или наружу;
  • программу перед запуском собирает компилятор, и часть решений о ней он принимает сам.
Заранее знать не нужно
  • что такое escape-анализ, кадр функции и флаг -gcflags='-m';
  • как устроен интерфейс внутри, что происходит с переменной, захваченной замыканием, зачем нужен sync.Pool и что такое встраивание функций.

Что здесь на самом деле спрашивают

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

В Go этого различия нет на уровне языка. Спецификация описывает &x не говоря ни слова о том, где лежит x; FAQ отвечает на вопрос отказом от вопроса:

you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language.

перевод

вам не нужно это знать. Каждая переменная в Go существует, пока на неё есть ссылки. Место хранения, выбранное реализацией, не имеет отношения к семантике языка.

Go FAQ

Это не уход от ответа, а сам ответ: возврат указателя на локальную переменную в Go законен, и именно поэтому вопрос о размещении отдан компилятору.

Дальше идёт вторая половина того же абзаца FAQ — и вот она уже правило:

if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap.

перевод

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

Go FAQ

Три слова в нём делают всю работу. «Не может доказать» — значит, по умолчанию куча, а стек надо заслужить. «После возврата» — значит, вопрос про срок жизни, а не про синтаксис. И «обязан» — значит, это не оптимизация, а требование корректности.

База: два места, где может жить значение

Прежде чем разбирать анализ, стоит назвать обычными словами сами эти два места — без них весь дальнейший разговор висит в воздухе.

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

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

И вот главное, ради чего написан урок: в Go программист не выбирает между этими двумя местами напрямую. В языке нет ни new против стека, ни отдельного способа «положить в кучу»; спецификация о размещении молчит вовсе. Выбор делает компилятор, и делает его по одному вопросу: может ли значение понадобиться после того, как вызов закончился?

Два примера, на которых это видно без единого термина.

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

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

Разница между примерами не в том, что во втором «появился указатель». Разница в том, что во втором значение нужно после возврата, а в первом нет. Это единственный критерий, и он работает в обе стороны: адрес, взятый и никуда не уехавший, значение в кучу не отправляет.

Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Дальше — про то, что путей «понадобиться после возврата» больше, чем кажется: значение уезжает в кучу через интерфейс, через замыкание, через тело чужой функции — и даже просто из-за длины, неизвестной на сборке.

Механизм 1: ссылка убегает

База назвала критерий словами. Теперь та же мысль в виде модели, по которой можно разбирать свой код: она про поток ссылок, а не про синтаксис.

контракт языкаПостановка задачи и гарантия языка: возврат указателя на локальную переменную законен. Конкретные решения компилятора начнутся ниже и помечены отдельно.

Компилятор отслеживает, куда может попасть ссылка на значение, и задаёт по каждому пути один вопрос: переживёт ли она кадр?

Ни одна ветка этого дерева не спрашивает про &. Все спрашивают одно: не окажется ли ссылка достижимой после возврата. Отсюда и то, что «указатель» и «куча» — независимые вещи.

И отсюда же несимметричность, которую называет FAQ: компилятор обязан доказать, что ссылка НЕ переживает кадр. Не сумел доказать — значит куча. По умолчанию куча; стек надо заслужить.

Механизм 2: семь случаев и решение по каждому

деталь реализации · Go 1.24Числа выделений и формулировки does not escape — вывод нынешнего компилятора. Модель выше от версии не зависит; конкретные решения — да.

Щёлкайте по строкам — и смотрите, где решение расходится с привычным правилом:

Числа выделений печатает прогон bench/goescape/internals.go:

выделений
локальная структура без указателя0
указатель на локальную, не покидает кадр0
указатель передан в функцию, которая читает0
указатель передан в функцию, которая хранит1
значение положено в интерфейс1
замыкание вызвано на месте0
замыкание сохранено наружу1

Вторая строка ломает правило «указатель — значит куча». Пятая ломает обратное: & в коде нет вообще, а выделение есть.

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

Отсюда практическое, которое стоит назвать: go func() { ... }() внутри цикла отправляет в кучу всё, что захватил, — и это одна из самых частых незамеченных аллокаций в конкурентном коде.

А третья и четвёртая — самая содержательная пара. Вызов выглядит одинаково: в обоих случаях в функцию передаётся &s. Отличается тело вызываемой функции — и решение меняется. Значит, уход в кучу не свойство вызова, это свойство всей достижимости значения, посчитанной через границы функций.

Компилятор готов назвать своё решение сам. bench/goescape/decisions.sh просит его об этом флагом -gcflags='-m -l':

./practice.go:44:7:  &Point{...} does not escape
./practice.go:50:10: &Point{...} escapes to heap
./practice.go:57:10: p escapes to heap

Первая строка — тот самый случай: адрес взят, а значение осталось в кадре. Это, а не догадки по коду, и есть способ узнать ответ для своей функции.

И у всей этой таблицы есть граница, которую стоит назвать прямо: это решения нынешнего компилятора. Escape-анализ живёт в оптимизаторе, а не в языке — спецификация о размещении молчит, а оптимизатор от версии к версии становится умнее. Значит, строка, которая убегает сегодня, на следующей версии может не убегать, и наоборот; изменится это молча, без единой ошибки сборки. Поэтому запоминать стоит не список случаев, а способ спросить: -gcflags='-m' на своём коде и на той версии Go, которой вы собираете.

Механизм 3: почему fmt добавляет выделений — и когда не добавляет

деталь реализации · Go 1.24Кеш малых значений и то, какие вызовы fmt выделяют, — устройство нынешнего рантайма, а не гарантия языка: границы кеша могут сдвинуться, и тогда сдвинутся числа ниже.

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

выделений
та же переменная без печати0
fmt.Fprintln(io.Discard, 42)0
fmt.Fprintln(io.Discard, 100000)1
упаковать в интерфейс 2550
упаковать в интерфейс 2561
fmt.Sprintf("%v", структура)2

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

Но почему тогда 42 не выделяет? Первая редакция этого замера печатала только 42, получила ноль — и тем самым опровергала собственный текст. Причина оказалась не в escape-анализе: у рантайма есть готовый массив значений 0–255, и упаковка малого числа берёт адрес оттуда, ничего не выделяя. На 256 кеш кончается, и выделение появляется.

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

Механизм 4: размер, известный только во время выполнения

деталь реализации · Go 1.24Числа выделений — снова вывод нынешнего компилятора. Причина (кадр размечается на сборке) устойчивее самих чисел, но и она про реализацию, а не про спецификацию.
выделений
make([]byte, 64) — константа0
make([]byte, n)n известен в рантайме1
make([]byte, 64) и срез сохранён1

Вторая строка не выводится ни из одного расхожего правила. Срез никуда не уходит — и всё равно куча.

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

Практический вывод узкий и потому полезный: в горячем пути константная длина буфера — это не педантизм, а разница между стеком и кучей. var buf [64]byte и buf := make([]byte, n) при n == 64 дают разный машинный код.

Глубже: сколько это стоит — и чего в этой цене нет

наблюдение замераbench/goescape, go1.24.7 linux/amd64, два ядра. Числа сняты на конкретной машине и в микрозамере, где куче не дают вырасти: см. оговорку в конце раздела.

Одна и та же структура, собранная в функции, которая возвращает число, против той же в функции, возвращающей указатель:

время
структура остаётся на стеке1,94 нс
та же структура уходит в кучу24,84 нс
×12,8

И вторая половина, которая объясняет, почему одну кратность запоминать бесполезно:

размерстеккучакратность
16 байт2,66 нс22,94 нс×8,6
256 байт2,37 нс79,34 нс×33,5

Прежняя запись этого замера давала ×25,9 — вдвое больше. Она была снята, пока на той же машине шла сборка сайта. Выделение в куче страдает от конкуренции за память сильнее, чем работа со стеком, поэтому кратность раздувается: число, снятое на занятой машине, измеряет машину, а не язык. Кратность здесь вообще не стоит запоминать — стоит запомнить, от чего она зависит.

Стековая колонка от размера почти не зависит. Отвести место в кадре стоит одинаково: это сдвиг указателя стека, и он не знает, сколько байтов за ним. Кучная растёт вчетверо, потому что выделение включает обнуление всей области.

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

Чего в этой цене нет

Три вещи, без которых числа выше выдают себя за больше, чем есть.

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

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

Того, что куча иногда быстрее. Значение в куче, пережившее много вызовов, выделяется один раз; то же значение «на стеке» в цикле может собираться и разбираться каждую итерацию. Escape-анализ — это про размещение, а не про скорость, и приравнивать «ушло в кучу» к «медленно» неверно ровно так же, как «указатель» к «куче».

Отсюда единственный работающий порядок действий: сначала профиль, потом -gcflags='-m'. Профиль говорит, стоит ли вообще смотреть; флаг говорит, куда именно.

И то и другое делают на своей версии Go. Кратность зависит от машины, а сам факт выделения — от компилятора: перенести сюда чужое число или чужой вывод «эта конструкция не выделяет» нельзя, их получают заново.

Как отвечать на собеседовании

Короткий ответ: в Go программист не выбирает между стеком и кучей — выбор делает компилятор, и вопрос у него один: переживёт ли значение свой кадр. Если доказать, что не переживёт, нельзя — куча. Поэтому возврат указателя на локальную переменную законен, а & в коде сам по себе о размещении не говорит ничего.

Этого достаточно, чтобы ответить верно. Дальше — то, что добавляют, если собеседник копает.

Если интервьюер копает глубже

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

Опровергните оба ходовых правила числами. «& ничего не значит: у меня указатель на локальную структуру дал ноль выделений. И наоборот — структура без &, но положенная в интерфейс, дала одно».

Назовите неочевидную причину. «make с длиной, известной только в рантайме, уходит в кучу, даже никуда не уезжая: кадр размечается на сборке».

Скажите, как узнать точно. «go build -gcflags='-m' — компилятор печатает does not escape и escapes to heap построчно. Гадать по коду не нужно».

И про цену — с оговоркой. «У меня вышло ×12,8 на мелкой структуре, но кратность растёт с размером: выделение включает обнуление. Плюс работа сборщика, которой в микрозамере не видно».

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

Дальше спросят

Спросят дальше

Возвращать структуру или указатель на неё?

Короткий ответ

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

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

Спросят дальше

Что такое sync.Pool и когда он нужен?

Короткий ответ

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

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

Спросят дальше

Замыкание всегда отправляет захваченное в кучу?

Короткий ответ

Нет, и это тот же вопрос про срок жизни. Если замыкание вызывается прямо здесь и никуда не сохраняется, захваченные переменные остаются в кадре. Если оно уходит наружу — возвращается, сохраняется в поле, передаётся в go — всё, что оно захватило, обязано пережить кадр.

Отсюда практическое: go func() { ... }() внутри цикла отправляет в кучу всё захваченное, и это одна из самых частых незамеченных аллокаций в конкурентном коде.

Спросят дальше

Почему у горутины стек 2 КБ, если кадры бывают большие?

Короткий ответ

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

Практическое следствие: глубокая рекурсия в Go не падает от переполнения, а дорожает — каждый рост стоит копирования. И обратное: значение, слишком большое для кадра, компилятор отправит в кучу независимо от того, убегает оно или нет.

Спросят дальше

Как escape-анализ связан со встраиванием функций?

Короткий ответ

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

Это же — главная ловушка микрозамеров. Функцию на две строки компилятор встроит, аллокация исчезнет, и замер покажет ноль там, где в настоящем коде единица. Поэтому в замерах этого урока стоит //go:noinline: без него сравнивалось бы одно и то же с самим собой.

Спросят дальше

Стоит ли переписывать код ради стека?

Короткий ответ

Только с профилем в руках. Escape-анализ — это то, что стоит уметь читать, когда pprof уже показал, что программа проводит время в выделениях; это не критерий, по которому пишут код с самого начала.

Причина не в лени: приёмы против выделений — предвыделенные буферы, sync.Pool, возврат по значению вместо указателя — почти всегда делают код длиннее и хрупче. Платить этим за выигрыш, которого никто не измерил, — плохая сделка, и на собеседовании такой ответ ценится выше, чем перечисление приёмов.

Частые заблуждения

Утверждение

взяли адрес — значит, значение в куче

На самом деле

Замер этого не подтверждает: указатель на локальную структуру, не покидающий кадр, даёт ноль выделений, и компилятор печатает про эту строку does not escape. Решает не &, а то, переживёт ли значение кадр.

Утверждение

нет указателя — значит, значение на стеке

На самом деле

Тоже нет. Структура без единого &, положенная в интерфейс, даёт одно выделение: интерфейс хранит адрес, и адрес переживает кадр. Отсюда же берутся выделения от fmt — у него аргументы ...any.

Утверждение

передача по указателю дешевле, потому что не копирует

На самом деле

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

Утверждение

возвращать указатель на локальную переменную опасно

На самом деле

Это привычка из C. В Go такой возврат законен: компилятор увидит, что значение переживает кадр, и разместит его в куче. Опасности нет; есть цена.

Утверждение

make в куче всегда, потому что это динамическая память

На самом деле

Замер: make([]byte, 64) без ухода наружу — 0 выделений. А вот make([]byte, n) с длиной, известной только в рантайме, — 1, даже когда срез никуда не уходит: кадр размечается на сборке, и неизвестную длину заранее не отведёшь.

Утверждение

логирование не влияет на память, оно только пишет текст

На самом деле

Аргументы fmt объявлены как ...any, и упаковка в интерфейс требует адреса. Замерено: fmt.Fprintln(io.Discard, 100000) — одно выделение. Отладочная печать в горячем цикле меняет распределение памяти измеряемого кода, и профиль, снятый вместе с ней, описывает другую программу.

Утверждение

упаковка числа в интерфейс всегда выделяет

На самом деле

Для значений 0–255 — нет: у рантайма есть готовый массив таких значений, и упаковка берёт адрес оттуда. Замерено: 255 — ноль выделений, 256 — одно. Из-за этого замер на маленьких числах может честно показать «логи бесплатны» и оказаться неверным.

Утверждение

уход в кучу — фиксированная надбавка

На самом деле

Это работа, пропорциональная размеру: выделение включает обнуление всей области. Замерено: ×8,6 на шестнадцати байтах и ×33,5 на двухстах пятидесяти шести, при почти неизменной стековой колонке. И это ещё без работы сборщика, которой в микрозамере не видно.

Практика

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

Практика · что напечатает

Четыре функции, у каждой печатается число выделений в куче. В какой из них указатель НЕ отправит значение в кучу — и какая отправит его туда без всякого указателя?
func local() {
p := Point{1, 2, 3, 4}
sinkN = p.X + p.W
}

func pointerStays() {
p := &Point{1, 2, 3, 4}
sinkN = p.X + p.W
}

func pointerEscapes() {
sinkP = &Point{1, 2, 3, 4}
}

func intoInterface() {
p := Point{1, 2, 3, 4}
sinkI = p
}

Практика · оцените

Одна и та же структура из четырёх полей. Во сколько раз дороже собрать её так, чтобы она ушла в кучу, чем так, чтобы осталась на стеке?
раз

Проверка знаний

Вопрос 1 из 6

Функция создаёт структуру, берёт её адрес и читает через него поле, никуда указатель не передавая. Где окажется структура?

Источники и что читать дальше

3 ИСТОЧНИКА

  1. Go FAQ — как узнать, где размещена переменнаяОфициальная документация. Ответ, который стоит цитировать дословно, потому что он снимает сам вопрос: «you don't need to know. Each variable in Go exists as long as there are references to it. The storage location chosen by the implementation is irrelevant to the semantics of the language» (вам не нужно это знать. Каждая переменная в Go существует, пока на неё есть ссылки. Место хранения, выбранное реализацией, не имеет отношения к семантике языка). И там же — прямо про кучу: «if the compiler cannot prove that the variable is not referenced after the function returns, then the compiler must allocate the variable on the garbage-collected heap» (если компилятор не может доказать, что на переменную не ссылаются после возврата из функции, он обязан разместить её в куче со сборкой мусора).https://go.dev/doc/faq#stack_or_heap
  2. Компилятор gc — диагностика -mОфициальная документация. Про то, как посмотреть решения самому: «-m: print optimization decisions» (-m: печатать решения оптимизатора). Это и есть источник формулировок `does not escape` и `escapes to heap`, которые приводятся в уроке.https://pkg.go.dev/cmd/compile
  3. Спецификация Go — оператор взятия адресаОфициальная документация. Место, где язык отказывается от различия, привычного по C: «For an operand x of type T, the address operation &x generates a pointer of type *T to x» (Для операнда x типа T операция взятия адреса &x порождает указатель типа *T на x). В спецификации нет ни слова про то, где x лежит, — и это не пробел, а решение: размещение отдано реализации.https://go.dev/ref/spec#Address_operators