Интерфейсы и boxing в Go: что происходит под нагрузкой
Каждое присваивание int в interface{} может обернуться аллокацией в куче. На горячем пути это превращается в миллионы объектов и рост нагрузки на GC. Разбираем, когда Go боксирует значения и как это увидеть.

Сервис на Go отдавал метрики через слой, где значения складывались в map[string]interface{}. При 20 тысячах запросов в секунду профиль показал, что 40% аллокаций в куче приходятся не на бизнес-логику, а на упаковку чисел в интерфейсы. GC-паузы выросли, латентность p99 поползла вверх. Причина — boxing: помещая int или float64 в interface{}, вы можете заставить рантайм выделить объект в куче. Разберём, когда это происходит, как это померить и что с этим делать. Речь про Go 1.21–1.22, поведение стабильно уже несколько релизов, но детали оптимизаций компилятора меняются.
Как интерфейс хранит значение
Интерфейсное значение в Go — это два машинных слова. Первое указывает на таблицу типов (itab для именованных интерфейсов или _type для interface{}), второе — на данные. Ключевой момент во втором слове: оно всегда указатель. Интерфейс не хранит значение по значению, он хранит указатель на значение.
Отсюда следствие: чтобы положить int в интерфейс, рантайму нужен адрес этого int. Если исходное значение живёт на стеке и после присваивания ускользает в интерфейс, который переживает вызов, компилятор обязан переместить значение в кучу — иначе указатель повиснет. Это и есть boxing.
Интерфейс в Go не «содержит» число. Он содержит указатель на число. Каждый раз, когда компилятор не может доказать, что число останется на стеке, оно уезжает в кучу — и это отдельная аллокация.
Когда аллокации нет
Не любое присваивание в интерфейс стоит аллокации. Рантайм и компилятор экономят в нескольких случаях:
- Указательные типы. Если вы кладёте в интерфейс
*T, срез, map, канал или функцию — второе слово уже указатель, копировать в кучу нечего. - Escape-анализ доказал, что значение не убегает. Если интерфейс используется локально и не переживает кадр стека, компилятор может оставить всё на стеке.
- Мелкие целые. Рантайм держит статическую таблицу для небольших значений
uint8и части малыхint. Присваивание такого числа в интерфейс отдаёт указатель на заранее выделенный объект, без новой аллокации. Границы этой таблицы — деталь реализации, полагаться на них в коде нельзя.
Как увидеть boxing в своём коде
Догадки тут не работают — нужен escape-анализ компилятора и профиль аллокаций.
Escape-анализ
Флаг -gcflags=-m заставляет компилятор рассказать, что он решил про каждое значение:
go build -gcflags='-m' ./...
В выводе ищите строки вида ... escapes to heap рядом с местом, где переменная попадает в интерфейс. Двойной флаг -m -m даёт причину решения. Пример кода, который боксирует:
package metrics
// storeAny кладёт число в interface{}, значение убегает в кучу
func storeAny(store map[string]interface{}, key string, value int) {
store[key] = value // здесь value уедет в кучу
}
Компилятор на строке присваивания напишет что-то вроде value escapes to heap. Значение value живёт на стеке функции, но map переживёт вызов, поэтому число копируется в кучу.
Профиль аллокаций
Escape-анализ показывает, где может быть аллокация. Сколько их реально на горячем пути — покажет бенчмарк с -benchmem:
package metrics
import "testing"
func BenchmarkStoreAny(b *testing.B) {
store := make(map[string]interface{}, 8)
b.ReportAllocs()
for i := 0; i < b.N; i++ {
storeAny(store, "latency", i)
}
}
go test -bench=StoreAny -benchmem ./metrics/
В колонках вывода смотрите на allocs/op и B/op. Если на операцию приходится хотя бы одна аллокация там, где вы её не ждёте, — это boxing. Конкретные числа зависят от вашего железа и данных, поэтому важен не абсолют, а разница между вариантами до и после правки.
Что это стоит под нагрузкой
Отдельная аллокация в 8–16 байт кажется мелочью. Проблема не в байтах, а в частоте и в том, что за аллокациями следит сборщик мусора. Go использует конкурентный mark-and-sweep: чем больше живых объектов и чем выше скорость их создания, тем чаще запускается цикл сборки и тем больше работы на фазе разметки.
На горячем пути с десятками тысяч операций в секунду boxing превращается в миллионы короткоживущих объектов в секунду. Прямые последствия:
- Рост частоты циклов GC — процессорное время уходит на сборку, а не на запросы.
- Рост латентности хвоста (p99, p999) из-за фоновой работы сборщика и точек синхронизации.
- Давление на кэш: разбросанные по куче мелкие объекты хуже ложатся в кэш процессора, чем плотный массив значений.
Как убрать лишний boxing
Не всякий boxing вреден — вредит только тот, что попал на горячий путь. Сначала профиль, потом правка. Варианты по убыванию частоты применения.
Типизированные структуры вместо interface{}
Если поля заранее известны, замените map[string]interface{} на структуру с конкретными типами. Числа лягут по значению внутрь структуры, аллокаций на упаковку не будет.
type Sample struct {
Latency int
Code int
Cached bool
}
Дженерики вместо интерфейсного контейнера
Начиная с Go 1.18 обобщённый контейнер хранит значения своего типа, а не упаковывает их в интерфейс. []int внутри дженерик-функции остаётся срезом чисел.
// Sum работает с любым числовым типом без boxing
func Sum[T int | int64 | float64](values []T) T {
var total T
for _, v := range values {
total += v
}
return total
}
Оговорка: дженерики в Go компилируются через частичную мономорфизацию (GC-shape stenciling). Для типов-указателей это иногда возвращает косвенность. Для конкретных чисел boxing уходит — но проверяйте бенчмарком, а не на веру.
Пул объектов для неизбежного boxing
Если интерфейсный слой убрать нельзя (плагинная система, чужой API), а объекты крупные, sync.Pool снижает частоту аллокаций, переиспользуя уже выделенную память. Для мелких чисел пул не окупается — накладные расходы на синхронизацию перевесят выигрыш.
Сравнение подходов
| Подход | Boxing чисел | Гибкость типов | Когда выбирать |
|---|---|---|---|
| map[string]interface{} | Есть, на горячем пути дорого | Максимальная | Прототип, редкий путь, заранее неизвестные поля |
| Типизированная структура | Нет | Поля фиксированы | Схема данных известна и стабильна |
| Дженерик-контейнер | Нет для чисел | Один тип на экземпляр | Однотипные коллекции на горячем пути |
| interface{} + sync.Pool | Есть, но реже | Максимальная | Крупные объекты, слой интерфейсов не убрать |
Где это ломается
Несколько граничных случаев, о которых легко забыть при оптимизации.
- fmt и логирование. Каждый вызов
fmt.Sprintf("%d", n)боксирует аргументы в[]interface{}. На горячем пути это заметно. Для логов чисел используйте специализированные методы структурного логгера, а не форматную строку. - Оптимизации компилятора не гарантированы. Escape-анализ консервативен: в сомнении он отправляет значение в кучу. Инлайнинг и решения по побегу меняются между версиями Go, поэтому бенчмарк после обновления рантайма — не паранойя.
- Таблица малых значений — деталь реализации. То, что
interface{}(i)для маленькогоiсегодня не аллоцирует, завтра может измениться. Не стройте на этом контракты. - Преждевременная оптимизация. Убирать boxing на холодном пути ради «чистоты» — потеря времени и читаемости. Правьте только то, что видно в профиле под реальной нагрузкой.
Что делать дальше
Соберите профиль аллокаций боевого сервиса под нагрузкой: go tool pprof по heap-профилю с флагом -alloc_objects покажет, где создаётся больше всего объектов. Если верх списка занимают runtime.convT* — это и есть boxing из интерфейсов. После правки сравните allocs/op в бенчмарке до и после, а в проде следите за частотой GC-циклов (go_gc_duration_seconds в метриках рантайма) и за p99 латентности. Инвалидация кэша типов и работа с cgo-указателями — отдельные темы, здесь мы разобрали только упаковку значений в интерфейсы.
Редактор изображений: Убрать лишнее, заменить фон, добавить объект, расширить кадр — на вашем изображении.
Спросить за 20 ₽Источники: The Go Programming Language Specification — Interface types, Go: Compiler And Runtime Optimizations (диагностика через -gcflags)
Частые вопросы
У меня Go 1.19, а не 1.22 — escape-анализ и дженерики работают так же?
Я убрал interface{}, а allocs/op не упал. Почему?
Всегда ли присваивание int в interface{} даёт аллокацию?
Стоит ли переписывать весь код с interface{} на дженерики?
Как отличить аллокации от boxing от остальных в pprof?
sync.Pool всегда ускоряет работу с интерфейсами?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.