lenec ru

← все посты

Когда брать sync.Pool, а когда не стоит: бенчмарки и практика

14K

Каждый раз, когда я вижу 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% выигрыша на холодном эндпоинте, оставь как было.

Комментарии 1

  • Алексей Морозов

    По sync.Pool заметил ещё один кейс: на API-шлюзе с buffer-ами под []byte профит был, но только пока структура внутри не разрасталась за 4К. На крупных аллокациях пул начал держать столько памяти, что RSS вырос вдвое за час, и пришлось ставить ограничение по размеру через явный sizeFloor. Без этого — мина замедленного действия в долгоживущем процессе.

Войдите, чтобы оставить комментарий.