Стек, куча и 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 — и вот она уже правило:
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 программист не выбирает между
этими двумя местами напрямую. В языке нет ни new против стека, ни отдельного
способа «положить в кучу»; спецификация о размещении молчит вовсе. Выбор делает
компилятор, и делает его по одному вопросу: может ли значение
понадобиться после того, как вызов закончился?
Два примера, на которых это видно без единого термина.
Первый. Функция создаёт структуру, читает из неё поле и возвращает число. После возврата до структуры уже никак не добраться: ссылок на неё не осталось нигде. Компилятор это видит и оставляет её в кадре: ноль выделений в куче.
Второй. Та же структура, но функция отдаёт наружу её адрес. Теперь после возврата на структуру ссылаются — значит, кадр она обязана пережить, и компилятор размещает её в куче: одно выделение.
Разница между примерами не в том, что во втором «появился указатель». Разница в том, что во втором значение нужно после возврата, а в первом нет. Это единственный критерий, и он работает в обе стороны: адрес, взятый и никуда не уехавший, значение в кучу не отправляет.
Этого уже достаточно, чтобы ответить на базовый вопрос собеседования. Дальше — про то, что путей «понадобиться после возврата» больше, чем кажется: значение уезжает в кучу через интерфейс, через замыкание, через тело чужой функции — и даже просто из-за длины, неизвестной на сборке.
Механизм 1: ссылка убегает
База назвала критерий словами. Теперь та же мысль в виде модели, по которой можно разбирать свой код: она про поток ссылок, а не про синтаксис.
Компилятор отслеживает, куда может попасть ссылка на значение, и задаёт по каждому пути один вопрос: переживёт ли она кадр?
Ни одна ветка этого дерева не спрашивает про &. Все спрашивают одно: не
окажется ли ссылка достижимой после возврата. Отсюда и то, что «указатель» и
«куча» — независимые вещи.
И отсюда же несимметричность, которую называет FAQ: компилятор обязан доказать, что ссылка НЕ переживает кадр. Не сумел доказать — значит куча. По умолчанию куча; стек надо заслужить.
Механизм 2: семь случаев и решение по каждому
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 добавляет выделений — и когда не добавляет
Самая частая непрошеная куча в реальном коде приезжает не из указателей, а из логов:
| выделений | |
|---|---|
| та же переменная без печати | 0 |
fmt.Fprintln(io.Discard, 42) | 0 |
fmt.Fprintln(io.Discard, 100000) | 1 |
упаковать в интерфейс 255 | 0 |
упаковать в интерфейс 256 | 1 |
fmt.Sprintf("%v", структура) | 2 |
Причина выделения — не форматирование, а сигнатура: аргументы у fmt
объявлены как ...any. Чтобы упаковать значение в интерфейс, нужен адрес, а
адрес уходит в вызываемую функцию и переживает кадр.
Но почему тогда 42 не выделяет? Первая редакция этого замера печатала только 42, получила ноль — и тем самым опровергала собственный текст. Причина оказалась не в escape-анализе: у рантайма есть готовый массив значений 0–255, и упаковка малого числа берёт адрес оттуда, ничего не выделяя. На 256 кеш кончается, и выделение появляется.
Отсюда два практических вывода, и ни один не про запрет логов. Первый: отладочная печать в горячем цикле меняет не только время, но и распределение памяти измеряемого кода — профиль, снятый вместе с ней, описывает другую программу. Второй, менее очевидный: замер на маленьких числах может не показать этого вовсе, и вывод «логи бесплатны» будет получен честно и окажется неверным.
Механизм 4: размер, известный только во время выполнения
| выделений | |
|---|---|
make([]byte, 64) — константа | 0 |
make([]byte, n) — n известен в рантайме | 1 |
make([]byte, 64) и срез сохранён | 1 |
Вторая строка не выводится ни из одного расхожего правила. Срез никуда не уходит — и всё равно куча.
Причина простая, но её редко проговаривают: место в кадре компилятор обязан отвести заранее, на сборке. Кадр — это фиксированный кусок стека, его размер зашит в код функции. Неизвестную длину заранее не отведёшь.
Практический вывод узкий и потому полезный: в горячем пути константная длина
буфера — это не педантизм, а разница между стеком и кучей. var buf [64]byte
и buf := make([]byte, n) при n == 64 дают разный машинный код.
Глубже: сколько это стоит — и чего в этой цене нет
Одна и та же структура, собранная в функции, которая возвращает число, против той же в функции, возвращающей указатель:
| время | |
|---|---|
| структура остаётся на стеке | 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
}Практика · оцените
Проверка знаний
Функция создаёт структуру, берёт её адрес и читает через него поле, никуда указатель не передавая. Где окажется структура?
Это не пересказ и не отдельный текст: всё ниже взято из самой статьи — её выжимка, заголовки разборов, колонка «на самом деле» и таблица версий. Поэтому разойтись со статьёй эти тезисы не могут.
Суть
- У значения есть два возможных места: стек и куча. Стек связан с вызовами функций и живёт ровно столько, сколько живёт вызов; куча — память, которая может пережить вызов. В 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'); они зависят от его версии, поэтому смотреть стоит свой вывод, а не запоминать список случаев.
На самом деле
- Замер этого не подтверждает: указатель на локальную структуру, не покидающий кадр, даёт ноль выделений, и компилятор печатает про эту строку
does not escape. Решает не&, а то, переживёт ли значение кадр. - Тоже нет. Структура без единого
&, положенная в интерфейс, даёт одно выделение: интерфейс хранит адрес, и адрес переживает кадр. Отсюда же берутся выделения отfmt— у него аргументы...any. - Только если значение и так уходит в кучу. Копия ложится в кадр вызывающего и кучи не трогает, а указатель, отданный наружу, означает выделение плюс отложенную работу сборщика. Для мелких структур возврат по значению обычно дешевле — и это меряется, а не угадывается.
- Это привычка из C. В Go такой возврат законен: компилятор увидит, что значение переживает кадр, и разместит его в куче. Опасности нет; есть цена.
- Замер:
make([]byte, 64)без ухода наружу — 0 выделений. А вотmake([]byte, n)с длиной, известной только в рантайме, — 1, даже когда срез никуда не уходит: кадр размечается на сборке, и неизвестную длину заранее не отведёшь. - Аргументы
fmtобъявлены как...any, и упаковка в интерфейс требует адреса. Замерено:fmt.Fprintln(io.Discard, 100000)— одно выделение. Отладочная печать в горячем цикле меняет распределение памяти измеряемого кода, и профиль, снятый вместе с ней, описывает другую программу. - Для значений 0–255 — нет: у рантайма есть готовый массив таких значений, и упаковка берёт адрес оттуда. Замерено: 255 — ноль выделений, 256 — одно. Из-за этого замер на маленьких числах может честно показать «логи бесплатны» и оказаться неверным.
- Это работа, пропорциональная размеру: выделение включает обнуление всей области. Замерено: ×8,6 на шестнадцати байтах и ×33,5 на двухстах пятидесяти шести, при почти неизменной стековой колонке. И это ещё без работы сборщика, которой в микрозамере не видно.
Что разобрано
- Что здесь на самом деле спрашивают
- База: два места, где может жить значение
- Механизм 1: ссылка убегает
- Механизм 2: семь случаев и решение по каждому
- Механизм 3: почему fmt добавляет выделений — и когда не добавляет
- Механизм 4: размер, известный только во время выполнения
- Глубже: сколько это стоит — и чего в этой цене нет
- Как отвечать на собеседовании
- Дальше спросят
- Частые заблуждения
- Практика
- Проверка знаний
Источники и что читать дальше
3 ИСТОЧНИКА
- 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
- Компилятор gc — диагностика -mОфициальная документация. Про то, как посмотреть решения самому: «-m: print optimization decisions» (-m: печатать решения оптимизатора). Это и есть источник формулировок `does not escape` и `escapes to heap`, которые приводятся в уроке.https://pkg.go.dev/cmd/compile
- Спецификация 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