Когда брать sync.Pool, а когда не стоит: бенчмарки и практика
Каждый раз, когда я вижу sync.Pool в чужом коде, первая мысль — «зачем». Половина случаев это «вычитал в блогпосте, что pool ускоряет», и в реальности он либо не помогает, либо мешает. Другая половина — оправданно и даёт ощутимый выигрыш.
Расскажу, в каких ситуациях sync.Pool работает, в каких бесполезен, и покажу бенчмарки, которыми сам обычно проверяю гипотезы перед тем, как лезть в горячий код.
Что вообще делает sync.Pool
Это пул объектов с двумя свойствами:
- Объекты могут быть удалены сборщиком мусора в любой момент. Это не кэш, это «карман на горячем пути».
- Доступ почти lock-free, через per-P локальные слоты. Контеншна минимум.
Польза одна — снизить аллокации в горячем пути. Если у тебя на каждый запрос делается buf := make([]byte, 4096), и таких запросов 50 тысяч в секунду, GC устанет. Pool тут даст результат.
Простой бенчмарк: bytes.Buffer для рендера ответа
Сценарий — рендеринг JSON-ответа в HTTP-хендлере. Создаём bytes.Buffer, пишем туда, отправляем клиенту.
var bufPool = sync.Pool{
New: func() any { return new(bytes.Buffer) },
}
func renderWithPool(w io.Writer, v any) error {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset()
bufPool.Put(buf)
}()
if err := json.NewEncoder(buf).Encode(v); err != nil {
return err
}
_, err := w.Write(buf.Bytes())
return err
}
func renderNoPool(w io.Writer, v any) error {
buf := new(bytes.Buffer)
if err := json.NewEncoder(buf).Encode(v); err != nil {
return err
}
_, err := w.Write(buf.Bytes())
return err
}
Бенчмарк:
func BenchmarkRender(b *testing.B) {
data := map[string]any{"id": 42, "name": "item", "meta": []int{1, 2, 3}}
b.Run("pool", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderWithPool(io.Discard, data)
}
})
b.Run("nopool", func(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
renderNoPool(io.Discard, data)
}
})
}
Результаты на моей машине, Go 1.22:
BenchmarkRender/pool-12 2812345 410 ns/op 192 B/op 3 allocs/op
BenchmarkRender/nopool-12 1923456 615 ns/op 320 B/op 5 allocs/op
Разница есть: pool уменьшает аллокации с 5 до 3 и убирает примерно треть времени. На 50 тысячах RPS это заметно по pprof allocs.
Когда pool не помогает
Объект слишком маленький
Если ты пытаешься пулить структуру из трёх int-ов, ты делаешь систему хуже. Накладные расходы на Get/Put больше, чем стоимость аллокации stack-allocated объекта. Компилятор Go ещё и эскейп-анализом такие объекты часто оставляет на стеке. Туда pool лезть не надо.
Объект редко используется
Если функция вызывается раз в секунду, аллокация одного буфера — фоновый шум. Pool тут бесполезен и добавляет когнитивной нагрузки на читателя кода.
Объект слишком большой и переиспользуется неравномерно
Видел такой кейс: пулили слайсы по 10 МБ для парсинга больших файлов. На пиках pool наполнялся, потом GC их съедал, потом наполнялся снова. По метрикам — никакого выигрыша, зато код запутаннее. С большими объектами имеет смысл явный пул с ограничением размера, а не sync.Pool.
Состояние не очищается полностью
Это самая опасная категория. Pool возвращает объекты as-is. Если ты забыл сбросить поле, второй вызов получит мусор от первого.
type Request struct {
ID string
Items []Item
}
// Antipattern: Items не сбросили
bufPool := sync.Pool{New: func() any { return &Request{} }}
r := bufPool.Get().(*Request)
r.ID = "new"
// Items от предыдущего использования
Безопасный сброс — отдельный метод Reset, и его вызов в Put. Я обычно делаю helper:
func putReq(r *Request) {
r.ID = ""
r.Items = r.Items[:0]
requestPool.Put(r)
}
Сюда же относится протекание данных между запросами. Если в Items были чувствительные данные, второй запрос их увидит. Это уже security issue.
Реальный кейс: matching engine
В одном проекте я писал часть order-matching на Go. На каждое входящее сообщение создавался Order, его нужно было десериализовать, прогнать через валидацию, потом отдать в matching loop. На пиках — десятки тысяч сообщений в секунду.
Первая версия — без пула: GC pause доходила до 8 мс, что для торгового движка много. После профилирования через pprof увидел, что 30% аллокаций — это Order и его дочерние слайсы.
Завернул Order в pool. Pause упала до 1.5 мс, p99 latency обработки — на 25%. Но пришлось добавить тщательный Reset и тесты, которые гоняют один и тот же объект через множество транзакций — чтобы поймать любое незаресеченное поле.
Мораль: pool оправдан, когда ты упёрся в GC pause или в alloc rate, и видишь это по pprof. Без замеров — это карго-культ.
Тонкости: GOMAXPROCS и P-локальные слоты
Pool устроен так: каждый logical processor (P) имеет свой локальный слот без блокировок. Это значит, что если у тебя GOMAXPROCS=8, фактически у тебя 8 параллельных «карманов». Между ними объекты тоже могут перетекать через shared-список, но реже.
Практический вывод: пул эффективен для коротких операций, где Get и Put случаются на одном P. Если ты пуляешь объект из горутины, которая может перейти на другой P (после блокирующего syscall), эффективность падает. Не до нуля, но заметно.
Ещё один момент: при каждом запуске GC pool частично очищается. Это значит, что после длинного периода покоя пул будет «холодным», и первая порция запросов столкнётся с аллокациями. На метриках это видно как небольшой всплеск latency после простоя. Обычно несущественно, но если у тебя жёсткий SLA — учти.
Альтернативы
Чанковый аллокатор для коротких задач
Если у тебя есть пакет работ с известным размером, иногда проще выделить большой кусок памяти один раз и нарезать его внутри. Это работает для парсинга в один проход, например.
Pre-allocated arrays
Если знаешь capacity заранее — make([]Item, 0, expectedLen) часто решает проблему без всякого пула.
Свой пул с ограничением
Когда нужен пул с гарантированным размером (например, ровно 1024 коннекшна) — sync.Pool не подходит, у него нет верхнего лимита и объекты пропадают. Делай свой через буферизированный канал.
Чек-лист перед тем, как добавлять sync.Pool
- Есть ли реальная проблема с аллокациями? Открыть pprof, проверить top по alloc_objects.
- Объект достаточно большой, чтобы pool окупился? Хотя бы пара сотен байт.
- Объект используется часто? Десятки тысяч раз в секунду — да, единицы — нет.
- Есть ли чёткий метод Reset? Если нет, готов написать?
- Покрыли тестами случаи, где pool отдаёт грязный объект?
Если хотя бы на один пункт ответ «нет» — pool, скорее всего, навредит больше, чем поможет.
Полезно держать в голове, что Go-команда сама внутри stdlib использует sync.Pool очень избирательно: fmt, encoding/json, net/http — и обычно это десятки строк инфраструктуры вокруг одного пула. Это правильный масштаб применения. Если ты делаешь pool ради 5% выигрыша на холодном эндпоинте, оставь как было.