Калькулятор аллокаций Go: сколько памяти съедает ваш код
Почти каждый вопрос «почему мой сервис на Go ест память» упирается в одно и то же место: переменная, которая должна жить в стеке горутины, почему-то оказалась в куче. Причины всегда одни и те же — значение ушло в интерфейс, срез вернулся из функции, замыкание пережило вызов. Этот калькулятор показывает, сколько байт стоит каждая конструкция, где она физически лежит и что именно переносит её в кучу.
Как Go решает, где живёт значение
Go разделяет память на две большие зоны. В кадре горутины живут локальные переменные, аргументы функций и их промежуточные значения: работа с ними стоит почти ноль, потому что это обычные регистры и адреса в стеке. Размер кадра горутины стартует с пары килобайт и растёт по мере надобности. Куча — общая память процесса, откуда работает сборщик мусора: здесь живут срезы, карты, строки, интерфейсы и всё, что должно пережить вызов функции.
Решение о том, где именно окажется значение, принимает escape analysis — анализ компилятора, который строит граф «кто на кого ссылается». Если значение не переживает вызов, компилятор оставляет его в кадре. Если ссылка на него уходит наружу — в интерфейс, в возвращаемую структуру, в замыкание, — переменная переезжает в кучу, потому что иначе ссылка стала бы висячей. Никакой «магии рантайма» здесь нет: это статический анализ, и его результат видно в выводе компилятора.
Вторая механика — классы размеров. Рантайм не выделяет память «по байту»: существует лестница размеров (8, 16, 24, 32, 48, 64, 80, 96, 112, 128 байт и дальше), и любая аллокация округляется вверх до ближайшего класса. Поэтому срез из 64 элементов int занимает не 512 байт «ровно», а ближайший класс — в нашем примере 512 байт, а вот 65 элементов уже перепрыгнут на следующий класс. Эта деталь объясняет, почему профили памяти показывают круглые числа, а не те, что вы ожидали.
Ниже — интерактивная таблица. Меняйте размер среза и карты, включайте флаги «значение уходит в интерфейс», «срез возвращается наружу», «замыкание живёт дольше вызова» и смотрите, как меняются суммы на стеке и в куче. Под таблицей — разбор того, как читать вывод escape analysis, и подсказки, где искать настоящие цифры в вашем сервисе.
Что аллоцирует ваш код
Меняйте размеры и флаги — таблица и итоговые цифры пересчитаются сразу. Все значения для 64-битной платформы.
Таблица: где живёт каждое значение
| Конструкция | Где живёт | Размер | Почему так |
|---|---|---|---|
| int (обычная переменная) | стек горутины | 8 Б на стеке | Пока значение не убегает, компилятор держит его в кадре горутины. |
| заголовок среза []int | стек горутины | 24 Б (ptr, len, cap) | Срез — это структура из трёх слов, сам массив данных лежит в куче. |
| массив данных среза (64 × int) | куча | 512 Б | Аллокация округляется до класса размера рантайма. Если размер задан константой и срез не убегает, Go выделит массив на стеке. |
| карта map[string]int (1000 записей) | куча | 26.6 КБ | Один бакет — 8 записей и 208 байт. Значения занимают 23.4 КБ без служебных накладных расходов. |
| интерфейс any / error | стек + куча | 16 Б (без boxing) | Сам интерфейс — два указателя. Дополнительная аллокация возникает, только когда внутрь кладут значение. |
| замыкание с захватом | стек горутины | 0 Б (без захвата) | Замыкание без захватов компилятор вообще не создаёт. |
| канал с буфером | куча | ≈96 Б + буфер | Структура hchan выделяется в куче всегда, даже если буфер пустой. |
Итог по одному вызову
Как это выглядит в профиле
В heap-профиле вы увидите не «функцию int», а места, где аллоцируется память. Если в профиле доминируют runtime.makeslice и runtime.makemap — ищите горячий путь, который создаёт срезы или карты в цикле.
Как проверить escape analysis на своей машине
go build -gcflags='-m' ./... 2>&1 | head -40
# ./main.go:12:6: can inline add
# ./main.go:14:6: leaking param: n
# ./main.go:18:13: moved to heap: id
# ./main.go:18:22: escape analysis does not escapego test -benchmem и профиль -memprofile.Как читать escape analysis
- 1.
does not escape— переменная остаётся в кадре горутины, аллокации нет. - 2.
moved to heap— компилятор перенёс переменную в кучу сам. Обычно это цена интерфейса, горутины или указателя, который ушёл за границу функции. - 3.
leaking param— параметр функции «утекает» наружу: функция отдаёт его ссылкой. Лечится возвратом значения, а не указателя. - 4.
can inline— функцию компилятор подставит по месту: дешевле, но только для простых тел.
Частые вопросы про память в Go
Почему мой Go-сервис ест больше памяти, чем ожидалось?
Чаще всего виноваты три вещи: срезы и карты, которые создаются в цикле или в обработчике запроса, значения, упакованные в интерфейс (fmt.Sprintf, any, ошибка), и замыкания, которые переживают вызов. Первые две группы дают аллокации на каждый запрос, третья удерживает память до сборки мусора. Откройте heap-профиль командой go tool pprof и посмотрите, какие строки dominate по аллокациям: обычно верх списка — это make в горячем пути.
Что такое escape analysis и как его включить?
Escape analysis — это статический анализ компилятора, который решает, может ли значение жить в кадре горутины или оно обязано уйти в кучу. Включается флагом -gcflags='-m': go build -gcflags='-m' ./... показывает пометки does not escape, moved to heap, leaking param. Проверить решение для конкретной функции можно через go build -gcflags='-m=2' — вывод станет подробнее.
Как уменьшить количество аллокаций в горячем пути?
Проверьте три вещи. Первое: не создавайте срезы и карты в цикле — переиспользуйте буфер или берите срез из пула. Второе: не заворачивайте числа в интерфейсы без нужды — логирование через fmt.Sprintf создаёт строку на каждый вызов, дешевле strconv.AppendInt. Третье: не держите замыкания с большими захватами — вынесите данные в структуру и передавайте её по ссылке.
Правда ли, что карта в Go медленнее среза?
Сравнивать их некорректно: у них разные задачи. Срез — это непрерывный блок данных с быстрым доступом по индексу, карта — хеш-таблица для поиска по ключу произвольного типа. Реальная разница в скорости появляется там, где вы обходите карту целиком или делаете тысячи поисков по срезу. Если нужен быстрый доступ по ключу — берите map, но помните про накладные расходы бакетов и про то, что ключ-строка хешируется при каждом обращении.
Что изучить дальше
- Как Go хранит памятьКликабельная схема стека горутины, кучи, указателя и среза — и пошаговый сценарий с append.
- Как читать профиль pprofПошаговый разбор профилей cpu, heap, goroutine и block с командами и примерами вывода.
- Светофор качества кодаЭвристики простоты для присланного Go-кода: длина функции, аргументы, вложенность, дубли.
- Бенчмарки и профилированиеУрок продвинутого курса: как измерить аллокации и не оптимизировать наугад.
Что внутри
Сколько стоит каждая конструкция
Калькулятор считает не абстрактные цифры, а конкретные: целое число на стеке, срез — указатель плюс три слова заголовка в куче, карта — несколько аллокаций на первую запись и рост при добавлении, интерфейс — упаковка значения в кучу вместе с потерей его типа, замыкание — отдельная окружение в куче, если оно переживает вызов.
Разница между «переменная лежит на стеке» и «переменная уехала в кучу» определяется escape analysis: компилятор смотрит, куда уходит ссылка на значение. Если ссылка не выходит за пределы функции, значение остаётся в кадре, и аллокации нет вообще. Если вы передаёте указатель в структуру, возвращаете его из функции или прячете в замыкании — значение переезжает в кучу.
Именно поэтому нельзя ответить на вопрос «сколько памяти ест мой обработчик» в отрыве от escape analysis: одна и та же переменная в одном и том же типе будет или не будет в куче в зависимости от того, как вы её используете.
Что показывает калькулятор на выходе
Для каждой конструкции выводится размер в байтах, место хранения (стек или куча), число аллокаций на операцию и причина, по которой значение ушло в кучу. Отдельно считается «память на один запрос» — если обработчик вызывается десять тысяч раз в секунду, умножение сразу показывает, где искать причину.
Для срезов и карт выводится ёмкость и длина: срез, у которого len равен нулю, а cap равен ста, удерживает весь подложенный массив — это классическая утечка, которую видно только при внимательном чтении кода. Для карт выводится оценка по числу ключей с оговоркой, что точная величина зависит от реализации хеш-таблицы.
Как пользоваться
- Возьмите реальную функцию, а не учебный пример. Скопируйте в калькулятор код обработчика, который у вас растёт по памяти. Учебные примеры показывают правила, а не вашу картину.
- Проверьте каждое место, где значение может уехать в кучу. Возврат указателя, поле структуры, замыкание, интерфейс, канал — это места, где стек превращается в кучу.
- Сравните с heap-профилем. Калькулятор даёт ожидание, pprof — факт. Если цифры расходятся, смотрите аллокации в горячем пути: они и создают давление на сборщик.
Когда это пригождается
Сервис ест память, а трафик не растёт
Смотрите на размер живых объектов (heap inuse), а не на сумму аллокаций (alloc space). Если inuse растёт между сборками, у вас утечка: незакрытый канал, забытая подписка, карта без вытеснения. Калькулятор покажет, какие конструкции в вашем коде чаще всего уезжают в кучу.
Профилирование показало тысячи аллокаций в обработчике
Чаще всего это fmt.Sprintf и приведение к any в цикле: каждое такое значение упаковывается в кучу. Замена на strconv.AppendInt и работа с конкретными типами убирает аллокацию целиком, а не сдвигает её.
Решили, нужен ли пул объектов
Пул имеет смысл там, где объекты большие и переиспользуются: буферы, клиенты соединений. Для коротких строк и мелких структур пул добавит блокировки и почти не сэкономит.
Размеры в калькуляторе — оценочные и опираются на типичные реализации Go. Точные числа зависят от версии рантайма, архитектуры и того, что компилятор успел доказать про ваш код. Ориентируйтесь на порядок величин и на соотношения, а не на последний байт.