Аллокации в горячем пути Go: как я убрал 40% времени в GC
Сервис тратил четверть CPU на сборку мусора. Разбираю, как escape-анализ, sync.Pool и предвыделение срезов срезали аллокации в горячем пути и что показал pprof до и после.

Сервис агрегации метрик на Go 1.22 под нагрузкой в ~15 000 запросов в секунду упирался не в бизнес-логику, а в сборку мусора. Профиль показывал: 24% процессорного времени уходило на runtime.gcBgMarkWorker и runtime.mallocgc. Пауза GC при этом держалась в норме (единицы миллисекунд), но общая пропускная способность стояла на месте, а CPU был занят почти целиком. Ниже — как я нашёл источник аллокаций в горячем пути и что получил после правок. Замеры делал на одном инстансе (8 vCPU, 16 ГБ) с фиксированным профилем нагрузки.
Почему аллокации бьют именно по горячему пути
Go управляет памятью автоматически, и на редко вызываемом коде лишняя аллокация не заметна. Горячий путь — функция, которая вызывается на каждый запрос, — умножает любую мелочь на частоту вызова. Каждая аллокация в куче — это работа для mallocgc при создании и для сборщика при уборке. При 15k RPS одна лишняя аллокация на запрос превращается в 15 000 аллокаций в секунду и постоянное давление на GC.
Ключевая величина, которую я мерил, — не количество мусора в мегабайтах, а частота срабатывания GC и доля CPU на нём. Go запускает сборку, когда куча вырастает вдвое относительно размера после прошлой сборки (управляется GOGC, по умолчанию 100). Чем быстрее растёт куча, тем чаще цикл, тем больше CPU на разметку.
Оптимизировать аллокации имеет смысл только там, где профиль показал проблему. Убирать одну аллокацию из функции, вызываемой раз в минуту, — потраченное время и усложнённый код на ровном месте.
Как я нашёл источник
Не угадывал, а смотрел профиль аллокаций. Подключил net/http/pprof и снял heap-профиль под нагрузкой:
import _ "net/http/pprof"
// В main запускаем отдельный сервер для профилирования
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}()
Дальше — сам профиль по количеству объектов, а не по занятому месту. Именно число аллокаций (alloc_objects) даёт давление на GC:
go tool pprof -alloc_objects http://localhost:6060/debug/pprof/heap
В интерактивном режиме команда top показала два лидера: сериализация ответа через json.Marshal и разбор входящей строки в срез. Дальше list имяФункции показывает конкретные строки, где выделяется память.
Три правки, которые дали основной эффект
1. Предвыделение среза с известной длиной
Горячий парсер собирал срез точек и рос через append от нулевой длины. Каждое удвоение ёмкости — новая аллокация и копирование. Количество точек в пакете было известно заранее из заголовка, поэтому я выделял ёмкость сразу:
// Было: срез растёт с нуля, несколько реаллокаций на пакет
var points []Point
for _, raw := range chunks {
points = append(points, parsePoint(raw))
}
// Стало: одна аллокация нужной ёмкости
points := make([]Point, 0, len(chunks))
for _, raw := range chunks {
points = append(points, parsePoint(raw))
}
Мелочь на вид, но при тысячах точек в пакете это убирало 3-4 реаллокации на каждый запрос.
2. Переиспользование буферов через sync.Pool
На каждый ответ создавался новый bytes.Buffer под сериализацию. Такой буфер живёт доли секунды и сразу становится мусором. sync.Pool держит пул переиспользуемых объектов, снимая нагрузку с аллокатора:
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func encodeResponse(resp *Response) ([]byte, error) {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // обязательно чистим перед использованием
defer bufPool.Put(buf)
if err := json.NewEncoder(buf).Encode(resp); err != nil {
return nil, err
}
// Копируем, потому что буфер вернётся в пул и будет перезаписан
out := make([]byte, buf.Len())
copy(out, buf.Bytes())
return out, nil
}
Две ловушки, на которых легко подорваться. Первая — Reset перед использованием: пул отдаёт объект в том состоянии, в каком его вернули. Вторая — нельзя отдавать наружу срез, который указывает на внутренний буфер пула: после Put буфер переиспользуется, и данные испортятся. Поэтому copy.
3. Убрал escape в кучу через анализ компилятора
Часть структур утекала в кучу без явной причины. Go принимает решение об escape1 на этапе компиляции, и это можно увидеть:
go build -gcflags='-m' ./...
В выводе строки вида moved to heap: x и escapes to heap показывают, что переменная не осталась на стеке. У меня причиной был возврат указателя на локальную структуру и передача значения через interface{} в логгер на горячем пути. Убрал логирование из горячего участка (перенёс в семплированное) и стал возвращать значение вместо указателя там, где структура маленькая. Часть аллокаций после этого просто исчезла — переменные остались на стеке.
Что получилось по замерам
Замеры снимал бенчмарком горячей функции и профилем CPU под той же нагрузкой 15k RPS. Условия: Go 1.22, 8 vCPU, GOGC=100, фиксированный набор запросов.
| Метрика | До | После |
|---|---|---|
| Аллокаций на запрос (allocs/op) | ~47 | ~11 |
| Байт на запрос (B/op) | ~9 800 | ~2 400 |
| Доля CPU на GC (по профилю) | ~24% | ~14% |
| Пропускная способность | 15k RPS | ~21k RPS |
Цифры — с моего стенда и моего профиля нагрузки; на другом наборе данных распределение будет иным. Важен не абсолют, а метод: смотреть allocs/op и долю CPU на сборке, а не догадываться.
Как мерить allocs/op у себя
Бенчмарк с флагом покажет аллокации на операцию — это самый честный способ сравнить до и после:
func BenchmarkEncode(b *testing.B) {
resp := makeSampleResponse()
b.ReportAllocs()
b.ResetTimer()
for i := 0; i < b.N; i++ {
_, _ = encodeResponse(resp)
}
}
Запуск: go test -bench=Encode -benchmem. В выводе будут колонки B/op и allocs/op. Прогоняйте до и после каждой правки — так видно, что именно дало эффект, а что нет.
Где подход ломается
- sync.Pool под редкой нагрузкой бесполезен. Пул чистится при сборке мусора, и если между запросами проходит цикл GC, объекты уже удалены —
Getкаждый раз зовётNew. Выигрыш есть только на действительно горячем пути. - Переиспользуемый буфер живёт дольше, чем кажется. Если ссылка на данные из пула уходит в горутину, в канал или в кеш, она переживёт
Putи получит испорченные данные. Отсюда правило: наружу отдавать только копию. - Предвыделение ёмкости требует знать размер. Если оценка завышена в разы, вы аллоцируете больше, чем нужно, и теряете память впустую. Предвыделение помогает, когда размер известен или предсказуем.
- Escape-анализ меняется между версиями. То, что осталось на стеке в Go 1.22, может утечь в куче в другой версии — компилятор эволюционирует. Проверяйте
-gcflags='-m'на своей версии, а не переносите вывод из чужой статьи. - Оптимизация усложняет код. Пул и ручное копирование читаются хуже, чем прямой
json.Marshal. Делайте это только там, где профиль доказал проблему, и оставляйте комментарий почему.
Что делать дальше
Если после правок GC всё ещё заметен в профиле, следующие шаги по убыванию отдачи:
- Снимите свежий
-alloc_objectsпрофиль и найдите новый топ — оптимизации сдвигают узкое место, а не убирают его насовсем. - Посмотрите на
GOGCиGOMEMLIMIT(последний появился в Go 1.19): если памяти в запасе много, повышениеGOGCснижает частоту сборки ценой большей кучи. Это компромисс, а не бесплатный выигрыш. - Проверьте горячие интерфейсы: передача значений через
interface{}часто вызывает скрытый escape. Дженерики в Go 1.18+ иногда позволяют его избежать. - Включите непрерывное профилирование в проде — разовый снимок под искусственной нагрузкой не покажет всплески на реальном трафике.
Инвалидация памяти в долгоживущих структурах и настройка планировщика горутин — отдельные темы, здесь я разбирал только аллокации в горячем пути сериализации и парсинга.
1 Escape-анализ — механизм компилятора Go, решающий, можно ли разместить переменную на стеке (дёшево, освобождается автоматически при выходе из функции) или она должна уйти в кучу (дороже, требует работы сборщика мусора). Переменная «убегает» (escapes) в кучу, если компилятор не может доказать, что она не переживёт вызов функции.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: Go: Profiling Go Programs (официальный блог), sync.Pool — пакет sync, документация Go
Частые вопросы
У меня другая версия Go, вывод -gcflags='-m' отличается — это нормально?
Я добавил sync.Pool, но allocs/op не изменился. Почему?
Стоит ли предвыделять ёмкость срезов везде?
Не сломается ли что-то в проде из-за переиспользования буферов?
Как понять, что аллокации вообще стоит оптимизировать в моём сервисе?
Помогает ли просто увеличить GOGC вместо переписывания кода?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.