Boxing интерфейсов в Go: как аллокации съели CPU
Сервис на Go начал тратить 30% CPU в GC под нагрузкой без роста трафика. Причина — упаковка значений в interface{} на горячем пути. Разбираем механизм, замеры и где подход ломается.

Сервис агрегации метрик на Go 1.22 держал 12 000 запросов в секунду и внезапно начал упираться в CPU: профиль показывал, что 28–32% времени уходит в runtime.mallocgc и сборку мусора, хотя объём входящего трафика не менялся неделями. RSS процесса плавал, p99 латентности вырос с 4 до 19 мс. Виновником оказалась не логика, а незаметная упаковка чисел в interface{} на пути обработки каждого события. Ниже — как это работает внутри, как мы это увидели в профиле и когда упаковка действительно дорога, а когда на неё можно закрыть глаза.
Что такое boxing и почему он вообще возникает
В Go интерфейсное значение — это пара из двух машинных слов: указатель на таблицу типа (itab для именованных интерфейсов, type для interface{}) и указатель на сами данные. Второе слово — именно указатель. Когда вы кладёте в интерфейс конкретное значение, которое не помещается в это слово как указатель (а числа, структуры, большинство скалярных типов туда «как есть» не кладутся), рантайм должен разместить копию значения где-то, куда можно указать. Чаще всего — в куче.
Это и есть boxing1: значение упаковывается в отдельно выделенную ячейку кучи, а в интерфейс кладётся указатель на неё. Каждая такая упаковка — потенциальная аллокация, а каждая аллокация — работа для сборщика мусора позже.
// Go 1.22
var sink interface{}
func box(n int) interface{} {
// n упаковывается: рантайм зовёт convT64 и выделяет ячейку в куче
return n
}
// В горячем цикле это тысячи аллокаций в секунду
for i := 0; i < count; i++ {
sink = box(i)
}
Ключевой момент: не всякая передача в интерфейс аллоцирует. Рантайм Go держит кеш маленьких целых чисел (аналог кеша в других языках) — упаковка значений от 0 до 255 берёт готовый указатель из статической таблицы staticuint64s и аллокации не делает. Именно поэтому баг легко не заметить в тестах на мелких данных: числа 0–255 упаковываются бесплатно, а на проде прилетают ID и таймстампы в миллионах.
Как мы это увидели в профиле
Первым делом сняли CPU-профиль с работающего процесса через net/http/pprof:
# процесс уже импортирует _ "net/http/pprof"
go tool pprof http://localhost:6060/debug/pprof/profile?seconds=30
В топе по cum висели runtime.mallocgc, runtime.convT64 и runtime.gcBgMarkWorker. Функция convT64 — прямая подсказка: рантайм упаковывает 64-битные значения в интерфейс. Дальше сняли профиль аллокаций, он точнее показывает источник:
# сколько байт и объектов аллоцировано и где
go tool pprof -alloc_space http://localhost:6060/debug/pprof/heap
# внутри pprof:
(pprof) top
(pprof) list ИмяФункции # показывает строки-виновники
Команда list вывела ровно ту строку, где событие с полем Value int64 клалось в map[string]interface{} перед сериализацией. Каждое событие давало от одной до трёх аллокаций только на упаковку чисел.
Профиль CPU говорит «горит GC», профиль аллокаций говорит «вот эта строка кода». Один без другого оставляет вас гадать: смотрите оба.
Замеры: сколько это стоит
Чтобы не спорить на глаз, написали микробенчмарк. Он сравнивает работу с конкретным типом и через interface{} на диапазоне чисел выше 255, где кеш не спасает.
// bench_test.go, Go 1.22
package boxbench
import "testing"
func BenchmarkConcrete(b *testing.B) {
var sum int64
for i := 0; i < b.N; i++ {
v := int64(i + 1000) // заведомо > 255
sum += v
}
_ = sum
}
func BenchmarkBoxed(b *testing.B) {
var sink interface{}
for i := 0; i < b.N; i++ {
sink = int64(i + 1000) // упаковка в кучу
}
_ = sink
}
# запуск с учётом аллокаций
go test -bench=. -benchmem
Порядок результатов на нашей машине (Go 1.22, обычный x86-64 сервер, один поток) выглядел так. Абсолютные наносекунды у вас будут другими — важна разница и колонка allocs/op:
| Бенчмарк | ns/op | B/op | allocs/op |
|---|---|---|---|
| BenchmarkConcrete | ~0.3 | 0 | 0 |
| BenchmarkBoxed | ~15–25 | 8 | 1 |
Одна аллокация на операцию — это мало на единичном вызове и много на горячем пути в миллионы вызовов в секунду. Не сама упаковка убивает CPU, а её последствие: рост числа живых объектов заставляет GC чаще запускаться и дольше сканировать. Мы подтвердили это через GODEBUG=gctrace=1: частота циклов сборки упала примерно втрое после устранения упаковки, а доля CPU в GC вернулась к 8–10%.
Что именно поменяли
Убрали map[string]interface{} из горячего пути. Вместо универсального контейнера завели типизированную структуру для события и сериализовали её напрямую, минуя промежуточную упаковку скаляров в интерфейсы. Универсальный путь через interface{} оставили только для редких админских эндпоинтов, где вызовов единицы в минуту и аллокации несущественны.
Где подход ломается и о чём легко забыть
- Кеш маленьких чисел вводит в заблуждение. Значения 0–255 упаковываются без аллокации, поэтому синтетический тест «в цикле кладём i в interface{}» при малом i покажет ноль аллокаций и усыпит бдительность. Всегда тестируйте на данных того же порядка, что и в проде.
- Строки и слайсы ведут себя иначе, чем числа. Заголовок строки (указатель + длина) не помещается в одно слово, поэтому упаковка строки в интерфейс тоже аллоцирует под копию заголовка. Но данные строки при этом не копируются — копируется только заголовок.
- fmt.Sprintf и логирование — тихий источник упаковки. Каждый аргумент вариативной функции
...interface{}упаковывается. Логи уровня debug на горячем пути дают ту же нагрузку на GC, даже если строка потом отбрасывается. - Escape-анализ иногда спасает, но полагаться нельзя. Если компилятор докажет, что упакованное значение не покидает функцию, аллокация может уехать на стек. Проверяйте флагом
-gcflags=-m, но не стройте на этом архитектуру: небольшое изменение кода ломает вывод escape-анализа. - Дженерики убирают упаковку не всегда. Параметр типа
[T any]не аллоцирует при вызове с конкретным типом, но если внутри дженерик-функции значение снова кладётся вinterface{}или тип имеет метод-интерфейс, упаковка вернётся. Дженерики — не автоматическая защита от boxing.
Как проверить escape-анализ
# покажет, что уехало в кучу и почему
go build -gcflags='-m -m' ./...
# ищите строки вида: "escapes to heap" рядом с вашим значением
Строка moved to heap или ... escapes to heap напротив вашего значения — прямое подтверждение аллокации. Если её нет, значение живёт на стеке и GC его не трогает.
Когда упаковкой можно пренебречь
Не всякий interface{} — проблема. Разница между горячим и холодным путём тут решает всё.
| Ситуация | Стоит ли бороться с boxing |
|---|---|
| Обработка каждого запроса/события, тысячи в секунду | Да, убирать с горячего пути |
| Разбор конфига при старте, десятки вызовов | Нет, читаемость важнее |
| Значения гарантированно в диапазоне 0–255 | Нет, аллокации не будет |
| Логи и метрики на debug-уровне в цикле | Да, оборачивать в проверку уровня до вызова |
Что делать дальше
Начните с измерения, а не с рефакторинга. Подключите net/http/pprof, снимите профиль аллокаций под реальной нагрузкой и найдите строки через list. Если convT64, convTstring или mallocgc в топе — ищите горячий interface{}. Включите GODEBUG=gctrace=1 и смотрите на частоту циклов GC до и после правки: это честный показатель, что упаковка ушла. Метрика, за которой стоит следить в проде постоянно, — доля CPU в сборке мусора и число аллокаций на запрос; их можно снимать штатно через runtime/metrics в Go 1.16+.
1 Boxing (упаковка) — размещение значения конкретного типа в отдельной ячейке памяти (обычно в куче) ради того, чтобы на него можно было сослаться через интерфейс. Обратная операция, извлечение значения из интерфейса, — unboxing.
Источники: Profiling Go Programs — The Go Blog, Go Diagnostics — pprof и gctrace
Частые вопросы
У меня не Go 1.22, а более старая или новая версия. Поведение то же?
Я убрал interface{}, а GC всё равно грузит CPU. Что дальше?
Дженерики полностью решают проблему boxing?
Почему бенчмарк показывает ноль аллокаций, хотя я кладу число в interface{}?
Насколько опасно оставить упаковку на холодном пути?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.