sync.Pool в Go: как сократить аллокации, но не получить кэш

Представим, что у нас HTTP-сервис.

Каждый запрос создаёт временный буфер:

func processRequest(data []byte) []byte { buf := make([]byte, 0, 4096) // Обработка данных return buf }

Если таких запросов тысячи в секунду, приложение постоянно создаёт новые объекты.

Часть этой работы можно сократить с помощью sync.Pool.

Как работает sync.Pool

Это стандартный механизм Go для повторного использования временных объектов.

Например, можно хранить в нём буферы:

var bufferPool = sync.Pool{ New: func() any { return make([]byte, 0, 4096) }, }

Использование:

func processRequest(data []byte) { buf := bufferPool.Get().([]byte) buf = buf[:0] // Работаем с буфером buf = append(buf, data...) // Возвращаем для повторного использования bufferPool.Put(buf) }

Вместо постоянного создания нового буфера мы можем получить уже существующий.

Но есть несколько важных нюансов.

1. sync.Pool - это не кэш Нельзя рассчитывать, что объект обязательно останется внутри пула. Сборщик мусора может очистить содержимое sync.Pool. Например: bufferPool.Put(buf) runtime.GC() buf = bufferPool.Get().([]byte) После сборки мусора мы не можем гарантировать, что получим именно тот буфер, который положили ранее. Поэтому sync.Pool подходит для временных объектов, но не для хранения важных данных.

2. Объект нужно правильно сбрасывать Допустим, мы вернули в пул буфер, в котором остались старые данные. Следующий пользователь может получить тот же объект. Поэтому перед использованием нужно сбрасывать состояние: buf = buf[:0]

А если внутри объекта есть дополнительные поля, их тоже нужно приводить в исходное состояние.

3. Не стоит складывать в пул всё подряд У sync.Pool есть смысл, когда: объекты часто создаются; их повторное использование действительно уменьшает нагрузку; стоимость создания заметна; объект можно безопасно переиспользовать.

Если объект маленький и дешёвый, пул может оказаться бесполезным или даже ухудшить производительность.

Проверим на benchmark

Сравним обычное создание буфера и использование sync.Pool.

func BenchmarkMakeBuffer(b *testing.B) { for b.Loop() { buf := make([]byte, 0, 4096) buf = append(buf, "hello world"...) _ = buf } } var pool = sync.Pool{ New: func() any { return make([]byte, 0, 4096) }, } func BenchmarkPoolBuffer(b *testing.B) { for b.Loop() { buf := pool.Get().([]byte) buf = buf[:0] buf = append(buf, "hello world"...) pool.Put(buf) } }

Запуск: go test -bench=. -benchmem

Посмотрим на три показателя: ns/op - время одной операции; B/op - сколько байт выделяется на операцию; allocs/op - количество аллокаций.

Мой эксперимент: сравнить оба варианта на одной машине и посмотреть, действительно ли sync.Pool даёт выигрыш.

И отдельно проверить, что происходит с результатами после runtime.GC().

Главное, что стоит запомнить: sync.Pool не гарантирует хранение объектов. Он помогает переиспользовать временные объекты, когда они доступны.

И как обычно в Go, прежде чем оптимизировать, стоит сначала измерить.