Escape-анализ в Go: почему объект уехал в кучу под нагрузкой
Сервис на Go держал 40 000 rps, потом latency на p99 вырос вдвое, а профиль показал 70% времени в runtime.mallocgc. Разбираем, почему объекты уезжают в кучу и что ломается первым под нагрузкой.
Профиль CPU под нагрузкой показывает 70% в runtime.mallocgc и runtime.scanobject, GC пробегает каждые 200–300 мс, p99 latency вырос с 8 до 19 мс без изменения кода — только вырос трафик. Виновником оказалась одна функция, где локальный объект неожиданно попал в кучу вместо стека. Разберём, как escape-анализ решает, где живёт объект, почему решение иногда бьёт мимо интуиции и что именно деградирует первым, когда аллокации растут линейно с RPS.
Материал про Go 1.22–1.23. Поведение escape-анализа менялось между версиями, но описанные ниже причины «побегов» стабильны уже несколько релизов.
Что делает escape-анализ и почему это важно на горячем пути
Компилятор Go на этапе сборки решает для каждого создаваемого значения: можно ли разместить его на стеке текущей горутины или объект «убегает» (escapes) и должен жить в куче. Стековая аллокация почти бесплатна — указатель стека сдвигается, при выходе из функции память освобождается автоматически. Кучевая аллокация стоит вызова аллокатора и, что важнее, добавляет объекту работу для сборщика мусора.
Разница не в одном объекте, а в том, что происходит под нагрузкой. Одна лишняя аллокация в обработчике при 40 000 rps — это 40 000 объектов в секунду, которые GC должен пометить и учесть. Именно давление на GC, а не сам вызов malloc, обычно ломается первым.
Как посмотреть решения компилятора
Escape-анализ не гадают — его печатает сам компилятор:
go build -gcflags='-m' ./...
# Более подробно, два уровня диагностики:
go build -gcflags='-m -m' ./pkg/handlerВ выводе вы увидите строки вида moved to heap: buf или escapes to heap. Это единственный достоверный источник: догадки по коду часто ошибаются.
Механизм: почему объект убегает
Компилятор консервативен. Если он не может доказать, что указатель на объект не переживёт функцию, объект уезжает в кучу. Основные причины «побега» на практике:
- Возврат указателя на локальную переменную. Классика:
return &user. Объект обязан пережить функцию, значит — куча. - Сохранение указателя в структуру или срез, живущий дольше функции. Кладёте
&itemв поле объекта, который возвращается наверх, — уедет. - Передача в
interface{}. Присваивание конкретного значения в интерфейс требует, чтобы значение жило в куче, потому что компилятор в общем случае не знает, что с ним сделают. Это самая частая скрытая причина. - Слишком большой объект. Массивы и структуры выше порога размера стекового фрейма уезжают, даже если формально не убегают.
- Замыкания, захватывающие переменную по ссылке. Если горутина или колбэк держит замыкание, захваченные переменные переезжают в кучу.
Отдельно про интерфейсы, потому что это ловушка номер один в веб-обработчиках:
package logging
import "log/slog"
type Order struct {
ID int64
Total int64
}
// requestID и любой аргумент slog.Any уходят через interface{}.
// Каждый лог-вызов на горячем пути — минимум одна аллокация.
func logOrder(logger *slog.Logger, o Order) {
logger.Info("order created",
slog.Int64("order_id", o.ID), // Int64 не боксится
slog.Any("order", o), // а o уходит в interface{} и убегает
)
}slog.Int64 и другие типизированные хелперы не создают лишний бокс. А slog.Any заворачивает значение в any, и структура Order уезжает в кучу на каждый вызов.
Escape-анализ работает пофункционально и консервативно: он не заглядывает во все возможные пути вызова. Если компилятор сомневается — он выбирает кучу. Поэтому «оптимизировать» стоит не по интуиции, а по выводу -gcflags='-m'.Что ломается первым под нагрузкой
Рост аллокаций не ломает всё сразу. Есть предсказуемая последовательность:
- Частота GC. Go запускает сборку, когда куча вырастает вдвое от размера после прошлой сборки (управляется
GOGC, по умолчанию 100). Больше аллокаций — куча растёт быстрее — GC чаще. Первым в профиле распухаютruntime.gcBgMarkWorkerиruntime.scanobject. - p99 latency, а не средняя. Средняя задержка держится, потому что GC в Go конкурентный, но фазы assist (когда аллоцирующая горутина сама помогает сборке) бьют по отдельным запросам. Хвост распределения растёт первым.
- CPU на mallocgc. Аллокатор становится точкой конкуренции между P (процессорами планировщика). На профиле видно рост
runtime.mallocgcи переходов вmcache/mcentral. - RSS процесса. Куча живёт дольше, пиковый размень между сборками растёт. OOM-killer приходит последним, но приходит.
Как это увидеть в цифрах
Не гадайте — снимите два профиля под реальной нагрузкой:
# CPU-профиль 30 секунд под нагрузкой
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
# Профиль аллокаций: сколько байт и объектов и откуда
go tool pprof http://localhost:6060/debug/pprof/allocsВ pprof по allocs смотрите alloc_objects — число аллокаций, а не только байты. Мелкие частые объекты давят на GC сильнее, чем редкий крупный буфер. Команда top покажет функции-лидеры, list имяФункции — конкретные строки.
Метрика, которую стоит завести в мониторинге: go_memstats_gc_cpu_fraction (доля CPU, потраченная на GC) и частота сборок из runtime/metrics. Если доля CPU на GC переваливает за 5–10% — на горячем пути слишком много мусора.
Что с этим делать без микрооптимизаций вслепую
Правило первое: сначала профиль, потом правки. Escape-анализ ломает интуицию, и «очевидная» оптимизация часто ничего не меняет либо делает хуже читаемость без выигрыша.
| Приём | Что выигрываете | Чем платите |
|---|---|---|
| Передавать структуру по значению, а не по указателю | Мелкие структуры остаются на стеке, копия дешевле аллокации | Для крупных структур копирование дороже; порог не универсален |
sync.Pool для переиспользуемых буферов | Убирает аллокации на горячем пути (например, bytes.Buffer) | Сложность, риск утечки данных между запросами, объекты могут вычищаться при GC |
Типизированные хелперы вместо any (slog, форматирование) | Убирает бокс в интерфейс | Только при работе с известными типами |
Предаллокация срезов make([]T, 0, n) | Убирает серию реаллокаций при росте | Нужно знать примерный размер; переоценка тратит память |
Пример sync.Pool для буфера сериализации — рабочий на Go 1.22:
package handler
import (
"bytes"
"sync"
)
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func renderResponse(payload []byte) []byte {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // обязательно: из пула приходит грязный объект
defer bufPool.Put(buf)
buf.WriteString("{\"data\":")
buf.Write(payload)
buf.WriteByte('}')
// Возвращаем копию: сам buf уедет обратно в пул и будет переиспользован
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
return result
}Обратите внимание на две ловушки: Reset() после Get обязателен, а возвращать наружу buf.Bytes() напрямую нельзя — эта память уедет в пул и будет перезаписана следующим запросом.
Где приём ломается
sync.Poolпри низком трафике бесполезен. Пул очищается при каждой сборке GC, и при редких запросах вы всё равно аллоцируете заново — плюс тащите сложность.- Передача по значению для крупных структур бьёт по стеку. Копия большой структуры на каждом уровне вызова съедает выигрыш и раздувает фреймы.
- Данные текут между запросами. Забыли
Resetили отдали наружу ссылку на объект из пула — получите чужие данные в ответе. Это баг безопасности, а не только производительности. - Инлайнинг влияет на анализ. Заинлайненная функция может не аллоцировать там, где неинлайненная — аллоцировала бы. После рефакторинга перепроверяйте
-gcflags='-m'.
Что делать дальше
Начните с профиля allocs под реальной нагрузкой и найдите три функции-лидера по alloc_objects. Прогоните по ним go build -gcflags='-m -m' и посмотрите, что именно уезжает в кучу — часто это один slog.Any, один возврат указателя или один боксинг в интерфейс. Правьте только эти места, а не весь код.
В мониторинге заведите долю CPU на GC и частоту сборок из runtime/metrics — так вы увидите деградацию по мусору раньше, чем она вылезет в p99. Крутить GOGC вверх (например, до 200) стоит только после того, как убрали лишние аллокации: это компромисс между RSS и частотой GC, а не замена профилированию.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Go: Diagnostics (профилирование и pprof), sync.Pool — документация пакета sync
Частые вопросы
У меня другая версия Go — приёмы всё ещё работают?
Я применил sync.Pool, а профиль почти не изменился. Почему?
Указатель или значение — как выбрать, чтобы объект не убегал?
Может ли escape-анализ навредить в проде, если я ничего не трогаю?
Стоит ли просто увеличить GOGC вместо борьбы с аллокациями?
Как отличить лишние аллокации от необходимых в профиле?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.