Как Go хранит память: стек, куча, срез и указатель
Почти все непонятные ошибки в Go — про память. Срез, который «не видит» изменения, map, который внезапно тормозит, программа, которая ест RAM на пустом месте. Причина одна: в Go срез — это не массив, а дескриптор из трёх слов, и его настоящие данные лежат совсем в другом месте.
Две зоны памяти и один дескриптор
Go разделяет память процесса на стек и кучу, и граница между ними проходит по одному признаку — может ли значение пережить вызов функции. Локальные переменные, аргументы и промежуточные значения живут в кадре горутины: работа с ними стоит почти ноль. Срезы, карты, строки, интерфейсы и всё, что должно жить дольше одного вызова, уезжает в кучу, где над памятью работает сборщик мусора.
Ключевая деталь, которую часто упускают: срез — это структура из трёх полей (указатель на первый элемент, длина, ёмкость), а не сам массив. Поэтому переменная среза на стеке занимает 24 байта независимо от того, сколько данных внутри. Передача среза по значению копирует только эти 24 байта — данные остаются общими. Отсюда и известная ловушка: подсрез, сохранённый отдельно, может «отстать» от исходного среза после того, как append перевыделит массив.
Ниже — схема, где слева кадр горутины с заголовками срезов, справа куча с массивами. Сценарий из шести шагов показывает, как работает append: где происходит перевыделение, что происходит с прежним массивом и почему «устаревший» срез продолжает удерживать память.
Схема: стек горутины и куча
Нажмите на элемент схемы, чтобы узнать, что это и зачем. Шаги сценария переключаются кнопками под схемой.
s — заголовок среза
24 байта в кадре горутины: указатель на первый элемент массива, текущая длина и ёмкость. Данных здесь нет — только адрес, по которому они лежат в куче.
Срез создан, массив выделен в куче
s := make([]int, 3, 3)make сразу выделяет массив из 3 элементов в куче и возвращает срез — это структура из трёх слов: указатель на первый элемент, длина и ёмкость. Сам заголовок (24 байта) компилятор держит в кадре горутины, а данные лежат в куче. Поэтому «срез — это ссылка» точнее, чем «срез — это массив».
copy, append([]int(nil), src...)) или тем, что подсрезы живут только внутри функции.Частые вопросы о памяти в Go
Чем срез в Go отличается от массива?
Массив [5]int — это фиксированный блок памяти, часть типа, размер известен на этапе компиляции. Срез []int — это дескриптор из трёх полей: указатель на первый элемент, длина и ёмкость. Сам массив данных всегда лежит отдельно — в куче или, если размер известен на этапе компиляции и данные не убегают, в стеке. Поэтому срез можно сравнивать с указателем, но с дополнительной возможностью ограничить видимую часть массива.
Почему после append старый срез перестал видеть изменения?
Потому что при нехватке ёмкости append выделяет новый массив, копирует данные и возвращает новый срез. Прежний массив остаётся жить, пока на него ссылается какой-нибудь сохранённый подсрез, но новые изменения в него не попадают. Это классическая ловушка: подсрез s[1:2] и исходный s перестают быть одним и тем же массивом. Решение — копировать данные явно (copy или append([]T(nil), src...)) либо не хранить подсрезы отдельно.
Когда переменная уезжает со стека в кучу?
Когда её адрес может пережить вызов функции. Это происходит, если значение кладут в интерфейс, возвращают из функции, сохраняют в замыкании, отправляют в канал или кладут в поле структуры, которая сама убегает. Компилятор сообщает об этом флагом -gcflags='-m': вы увидите moved to heap или leaking param. Если переменная никуда не уходит, она остаётся в кадре горутины, и аллокации нет вообще.
Что такое escape analysis и как его посмотреть?
Escape analysis — статический анализ компилятора, который решает, какие значения могут жить в стеке, а какие обязаны уйти в кучу. Включается флагом -m: go build -gcflags='-m' ./... Показывает пометки does not escape (значение остаётся в кадре), moved to heap (компилятор перенёс сам), leaking param (параметр утекает наружу) и can inline (функцию можно встроить).
Что изучить дальше
- Калькулятор аллокацийСчитаем байты на стеке и в куче для срезов, карт, интерфейсов и замыканий.
- Планировщик горутинПошаговая симуляция: кто блокируется на канале, кто просыпается и почему.
- Урок про срезы и appendРазбор роста ёмкости, копирования и подводных камней в базовом курсе.
- Представление памятиРазмеры типов, unsafe.Pointer и то, как устроен указатель на уровне байтов.
Что внутри
Стек, куча и граница между ними
Стек горутины — непрерывный участок памяти, который растёт по требованию: пока в кадр помещается всё нужное, новых страниц не выделяют. Куча — общая область, откуда рантайм раздаёт объекты через escape analysis: всё, что не доказано как «локальное», считается кучей.
Схема на странице показывает четыре сущности: значение на стеке, значение в куче, срез как «окно» в массив и указатель как адрес. Ошибки новичков почти всегда в том, что срез и его подложенный массив — разные вещи: укоротив срез, вы не освободили массив, а если массив большой и срез ушёл в интерфейс, его переживёт всё остальное.
Что происходит при append
append увеличивает длину среза. Если в capacity не хватает, рантайм выделяет новый массив вдвое больше, копирует данные и переключает срез на новый массив — старый остаётся жить, пока на него кто-то ссылается.
Пошаговый сценарий на странице показывает именно эту последовательность: сколько байт пришлось скопировать, сколько памяти занял старый массив до сборки мусора и почему после второго append ёмкость удваивается, а не растёт на единицу. Это объясняет, почему срез, выросший внутри функции, иногда «теряется» у вызывающего, а иногда — нет.
Как пользоваться
- Проследите одну переменную, а не весь код. Возьмите срез, который у вас вызывает подозрения, и пройдите по сценарию: где он создан, где расширен, куда ушёл.
- Сравните два варианта кода. Один и тот же срез, но с полным выражением s[:5:5] и без него, дают разное поведение по памяти — схема показывает разницу.
- Проверьте на heap-профиле. Если в профиле видно крупную аллокацию среза — скорее всего, дело в ёмкости, а не в длине.
Когда это пригождается
Память растёт, а данные не меняются
Классика: сервис держит срезы с большой ёмкостью, в которые кладут всё меньше элементов. Вытеснения не происходит, а массив остаётся в памяти до сборки мусора.
Изменение элемента не видно снаружи
Если вы сделали срез из массива и отдали его по значению, изменения элемента видны (массив общий), а вот append — нет: он переключает срез на новый массив. Поэтому изменение элемента и добавление ведут себя по-разному.
Интерфейс увеличивает расход памяти
Значение, помещённое в интерфейс, упаковывается в кучу: даже int не остаётся на стеке. На горячем пути это десятки тысяч лишних аллокаций в секунду.
Схема объясняет модель, а не поведение конкретного компилятора: решение об escape analysis принимается при компиляции, и один и тот же исходник может дать разные раскладки в разных версиях Go.