Выделения в куче в Go: поведение под нагрузкой
Сервис на Go начал тратить 40% процессорного времени на сборку мусора при 5000 RPS. Разбираем, почему аллокации в куче становятся узким местом и как их найти профайлером.

Сервис-агрегатор на Go 1.22 держал 5000 запросов в секунду и вдруг стал отвечать медленнее под нагрузкой: p99 вырос с 20 до 180 мс, а pprof показал, что около 40% CPU уходит в runtime.gcBgMarkWorker и runtime.mallocgc. Код не менялся — вырос трафик. Причина оказалась не в логике, а в том, сколько объектов сервис отправляет в кучу на каждый запрос. Разберём, как компилятор решает, что положить в кучу, почему это бьёт по латентности под нагрузкой и как это измерить, а не угадать.
Стек, куча и escape analysis
Go не заставляет вас вручную выбирать, где размещать переменную. За это отвечает escape analysis — анализ во время компиляции, который решает, может ли объект остаться на стеке функции или обязан сбежать в кучу. Объект на стеке освобождается мгновенно при возврате из функции. Объект в куче живёт, пока на него есть ссылки, и его освобождением занимается сборщик мусора.
Под нагрузкой разница перестаёт быть теоретической. Каждый объект в куче — это работа для GC: пометить, обойти, освободить. Чем больше живущих между запросами объектов и чем чаще вы аллоцируете, тем чаще запускается сборка и тем дольше stop-the-world паузы (в современном Go они короткие, но конкурентная фаза всё равно ест CPU).
Escape analysis — статический анализ компилятора, определяющий, «убегает» ли указатель за пределы кадра стека функции. Если да — объект аллоцируется в куче. Посмотреть решения компилятора можно флагом -gcflags=-m.
Что заставляет объект сбежать в кучу
- возврат указателя на локальную переменную из функции;
- сохранение указателя в структуру, которая живёт дольше функции;
- передача значения в
interface{}— почти всегда вызывает аллокацию, потому что интерфейс хранит указатель; - замыкание, которое захватывает переменную по ссылке;
- слайс или мапа, размер которых компилятор не может определить статически;
- слишком крупный объект, не помещающийся в стековый кадр.
Как увидеть аллокации, а не гадать
Первый инструмент — компилятор. Он честно расскажет, что решил отправить в кучу:
go build -gcflags="-m" ./...
В выводе вы увидите строки вида ./main.go:14:9: &user escapes to heap и moved to heap: buf. Это и есть точки аллокаций. Флаг можно продублировать (-m -m) для более подробного объяснения, почему объект сбежал.
Второй инструмент — бенчмарк с отчётом об аллокациях. Он показывает не решение компилятора, а фактическое число выделений на операцию:
package bench
import "testing"
// go test -bench=. -benchmem
func BenchmarkFormat(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
_ = formatUser(42, "alice")
}
}
В отчёте появятся колонки B/op (байт на операцию) и allocs/op (число аллокаций на операцию). Именно allocs/op — та цифра, которую вы хотите снижать: каждое выделение это работа для GC под нагрузкой.
Третий инструмент — профиль кучи в проде. Подключите net/http/pprof и снимайте профиль на живом трафике:
go tool pprof http://localhost:6060/debug/pprof/allocs
В pprof команда top покажет функции с наибольшим объёмом аллокаций, а list имяФункции — построчную разбивку. Для профиля выделений (а не занятой памяти) используйте endpoint allocs, для текущего резидентного объёма — heap.
Оптимизировать аллокации вслепую — верный способ усложнить код и не сдвинуть латентность. Сначала профиль на реальном трафике, потом гипотеза, потом бенчмарк «до и после». Всё остальное — суеверия.
Где кучу разносит под нагрузкой
Интерфейсы в горячем пути
Логирование через interface{} или any в цикле обработки — типичный источник тихих аллокаций. Каждый примитив, обёрнутый в интерфейс, боксится в кучу. Один вызов log.Printf("user %d", id) может стоить аллокации на упаковку id. На 5000 RPS это тысячи выделений в секунду только на логи.
Слайсы без предварительной ёмкости
Слайс, растущий через append без заранее заданной ёмкости, переаллоцируется несколько раз по мере роста, копируя данные. Если вы знаете примерный размер — задайте его сразу:
// плохо: несколько переаллокаций при росте
result := []Item{}
for _, r := range rows {
result = append(result, convert(r))
}
// лучше: одна аллокация нужного размера
result := make([]Item, 0, len(rows))
for _, r := range rows {
result = append(result, convert(r))
}
Временные буферы на каждый запрос
Если на каждый запрос вы создаёте буфер под сериализацию или парсинг, а потом выбрасываете его, GC получает поток короткоживущих объектов. Здесь помогает sync.Pool — пул переиспользуемых объектов:
import (
"bytes"
"sync"
)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func handle(data []byte) string {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // обязательно чистим перед использованием
defer bufPool.Put(buf) // возвращаем в пул
buf.Write(data)
// ... работа с буфером ...
return buf.String()
}
Важная оговорка: sync.Pool не гарантирует, что объект переживёт следующий цикл GC — пул может быть очищен при сборке. Это инструмент для снижения давления на аллокатор, а не для долгого хранения. И не забывайте Reset(): забытая очистка — источник трудноуловимых багов с чужими данными в буфере.
Что даёт снижение аллокаций в цифрах
Замеряйте на своём железе и своём трафике — числа ниже показывают методику, а не универсальный результат. Схема замера: тот же бенчмарк с -benchmem до и после изменения, плюс профиль allocs под нагрузкой.
| Приём | Что снижает | Чем платите |
|---|---|---|
| Предвыделение ёмкости слайса/мапы | число переаллокаций и копирований | ничем, если размер известен |
sync.Pool для буферов | поток короткоживущих объектов | риск гонок при забытом Reset, объект не переживает GC |
| Отказ от boxing в горячем пути | аллокации на упаковку в интерфейс | менее гибкий код |
| Передача по значению вместо указателя (мелкие структуры) | escape в кучу | копирование, вредное для крупных структур |
Настройка GOGC | частоту сборок | больше резидентной памяти |
Ручка GOGC и GOMEMLIMIT
Переменная GOGC (по умолчанию 100) задаёт, насколько куча должна вырасти относительно живого объёма перед запуском сборки. Увеличив её до 200, вы делаете сборки реже ценой большего потребления памяти — иногда это дешевле, чем переписывать код. С Go 1.19 доступен GOMEMLIMIT — мягкий лимит памяти, который заставляет GC работать активнее при приближении к границе. Актуальные детали поведения смотрите в документации runtime для вашей версии.
Граничные случаи и где приём ломается
- Предвыделение с неверным размером — если задать ёмкость больше реальной, вы просто держите лишнюю память. Оценивайте по фактам, а не с запасом «на всякий случай».
- sync.Pool под низкой нагрузкой — при редких запросах пул очищается GC между вызовами, и переиспользования не происходит. Выигрыш есть только на плотном потоке.
- Передача по значению крупных структур — избегая escape, вы вводите копирование десятков байт на каждый вызов. Для структуры в сотни байт это дороже одной аллокации.
- Микрооптимизации вне горячего пути — снижение аллокаций в коде, вызываемом раз в минуту, не изменит p99. Профиль скажет, где реально жарко.
- escape analysis межпроцедурно ограничен — компилятор не всегда видит сквозь границы функций и интерфейсов, поэтому
-gcflags=-mиногда «пессимистичен». Доверяйтеallocs/op, а не только выводу компилятора.
Что делать дальше
Начните не с рефакторинга, а с измерения. Подключите net/http/pprof, снимите профиль allocs под реальной нагрузкой и найдите топ-3 функции по объёму выделений. Для каждой напишите бенчмарк с -benchmem, зафиксируйте allocs/op как baseline. Только после этого меняйте код и сравнивайте. В проде следите за метриками go_memstats_gc_cpu_fraction и частотой сборок через runtime.ReadMemStats или экспорт в Prometheus — рост доли CPU на GC под нагрузкой и есть сигнал, что пора возвращаться к профилю кучи. Инвалидация профиля при смене версии Go — отдельная тема: escape analysis улучшается от релиза к релизу, перемеряйте после апгрейда.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Go: Diagnostics (profiling, pprof), Go runtime package documentation (GOGC, GOMEMLIMIT)
Частые вопросы
У меня Go 1.20, а не 1.22 — приёмы сработают?
Я снизил allocs/op в бенчмарке, но p99 в проде не изменился. Почему?
sync.Pool сделал только хуже — откуда лишние аллокации?
Стоит ли передавать всё по указателю, чтобы избежать копирования?
Как понять, что проблема именно в GC, а не в чём-то другом?
Можно ли просто увеличить GOGC и не трогать код?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.