Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Как Go хранит память: стек, куча, срез и указатель

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

6 шагов сценариясхема в SVG, без canvas

Две зоны памяти и один дескриптор

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

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

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

Схема: стек горутины и куча

Нажмите на элемент схемы, чтобы узнать, что это и зачем. Шаги сценария переключаются кнопками под схемой.

Стек горутины (кадр функции)func main()sptr → A133len / capbещё не объявленruntime.growsliceвызывается только при нехватке ёмкостистарый массив остаётся в куче, пока есть ссылкиКуча (heap) — общая память процессаA1102030Переменная s — заголовок срезаМассив A1

s — заголовок среза

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

Шаг 1 из 6

Срез создан, массив выделен в куче

s := make([]int, 3, 3)

make сразу выделяет массив из 3 элементов в куче и возвращает срез — это структура из трёх слов: указатель на первый элемент, длина и ёмкость. Сам заголовок (24 байта) компилятор держит в кадре горутины, а данные лежат в куче. Поэтому «срез — это ссылка» точнее, чем «срез — это массив».

len = 3cap = 3
Правило, которое стоит запомнить: append всегда может вернуть новый срез. Если старый срез где-то сохранён, он может «отстать» от нового. Лечится копированием ( 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 (функцию можно встроить).

Что изучить дальше

Что внутри

Стек, куча и граница между ними

Стек горутины — непрерывный участок памяти, который растёт по требованию: пока в кадр помещается всё нужное, новых страниц не выделяют. Куча — общая область, откуда рантайм раздаёт объекты через escape analysis: всё, что не доказано как «локальное», считается кучей.

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

Что происходит при append

append увеличивает длину среза. Если в capacity не хватает, рантайм выделяет новый массив вдвое больше, копирует данные и переключает срез на новый массив — старый остаётся жить, пока на него кто-то ссылается.

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

Как пользоваться

  1. Проследите одну переменную, а не весь код. Возьмите срез, который у вас вызывает подозрения, и пройдите по сценарию: где он создан, где расширен, куда ушёл.
  2. Сравните два варианта кода. Один и тот же срез, но с полным выражением s[:5:5] и без него, дают разное поведение по памяти — схема показывает разницу.
  3. Проверьте на heap-профиле. Если в профиле видно крупную аллокацию среза — скорее всего, дело в ёмкости, а не в длине.

Когда это пригождается

Память растёт, а данные не меняются

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

Изменение элемента не видно снаружи

Если вы сделали срез из массива и отдали его по значению, изменения элемента видны (массив общий), а вот append — нет: он переключает срез на новый массив. Поэтому изменение элемента и добавление ведут себя по-разному.

Интерфейс увеличивает расход памяти

Значение, помещённое в интерфейс, упаковывается в кучу: даже int не остаётся на стеке. На горячем пути это десятки тысяч лишних аллокаций в секунду.

Схема объясняет модель, а не поведение конкретного компилятора: решение об escape analysis принимается при компиляции, и один и тот же исходник может дать разные раскладки в разных версиях Go.