sync.Pool в Go: как устроено внутри и когда экономит аллокации
Разбираем, как sync.Pool раскладывает объекты по proc-локальным пулам, почему объекты исчезают после GC и на каких сценариях пул реально снижает нагрузку на аллокатор.

Профилировщик показывает, что 40% времени CPU уходит в runtime.mallocgc, а GC срабатывает каждые пару секунд под нагрузкой в несколько тысяч запросов в секунду. Классический совет — переиспользовать объекты через sync.Pool. Но пул из стандартной библиотеки ведёт себя не так, как ожидает большинство: объекты в нём периодически исчезают, а на некоторых сценариях он не даёт выигрыша вовсе. Разберём, что происходит внутри пула в Go 1.22–1.23, где он ускоряет код, а где превращается в лишнюю прослойку.
Речь только про механизм sync.Pool и его взаимодействие с планировщиком и сборщиком мусора. Пулы соединений, воркер-пулы горутин и объектные пулы на каналах — отдельные темы, здесь их не трогаем.
Что такое sync.Pool на уровне API
sync.Pool — контейнер временных объектов, которые можно взять через Get() и вернуть через Put(). У пула есть одно поле, которое имеет смысл задавать, — функция New: её вызывают, когда пул пуст и Get не нашёл готового объекта.
// Go 1.22
var bufPool = sync.Pool{
New: func() any {
// выделяем буфер, только когда в пуле пусто
return new(bytes.Buffer)
},
}
func handle(data []byte) {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // обязательно чистим: объект пришёл из прошлого использования
defer bufPool.Put(buf)
buf.Write(data)
// ... работа с buf
}
Ключевое, что видно уже здесь: Get возвращает объект в том состоянии, в котором его положил предыдущий владелец. Пул не обнуляет содержимое — за Reset отвечаете вы. Забыли — получите чужие данные из прошлого запроса.
Как объекты разложены внутри
Наивная реализация пула — один список под общим мьютексом. На многоядерной машине это узкое место: горутины со всех ядер конкурируют за одну блокировку. sync.Pool устроен иначе — он proclocal, то есть привязан к P (процессору планировщика Go).
У каждого P есть своя локальная структура poolLocal. В ней два хранилища:
- private — ровно один объект, доступный только текущему P. Обращение к нему не требует ни блокировок, ни атомарных операций, пока горутина не переехала на другой P.
- shared — двусторонняя очередь (
poolChain). В неё владелец P кладёт и забирает объекты со своего конца, а другие P могут воровать с чужого конца.
Алгоритм Get примерно такой: сначала проверяется private текущего P, затем свой shared, затем идёт попытка украсть объект из shared соседних P (work stealing), и только если всё пусто — вызывается New. Put кладёт объект в private, а если тот занят — в свой shared.
Почему это масштабируется
В горячем пути — когда одна горутина берёт и тут же возвращает объект на том же P — работа идёт через private без синхронизации. Именно этот сценарий даёт основной выигрыш: аллокация заменяется на пару присваиваний. Как только начинается воровство между P, включаются атомарные операции, и пул дороже, но всё равно дешевле общего мьютекса.
Пул экономит не память, а работу аллокатора и сборщика мусора. Если объекты и так живут долго, класть их в пул бессмысленно — вы просто добавите слой.
Куда деваются объекты после GC
Главный сюрприз для тех, кто ждёт от пула кеша: сборщик мусора очищает пул. При старте цикла GC рантайм вызывает зарегистрированный хук, который переносит содержимое пулов в victim-буфер, а на следующем цикле GC этот буфер выбрасывается.
Механика двухуровневая и появилась в Go 1.13, чтобы объекты не пропадали резко на каждом GC:
- первый GC после
Put: живые объекты изlocalперемещаются вvictim(жертвенный слой); Getпри пустомlocalможет ещё достать объект изvictim;- следующий GC: то, что осталось в
victimи не было востребовано, теряет ссылки и собирается.
Практический вывод: пул хорош там, где объекты берут и возвращают часто, в пределах интервала между сборками мусора. Класть в sync.Pool объект «на всякий случай, вдруг пригодится через минуту» бессмысленно — к тому моменту его почти наверняка не станет.
Где пул реально помогает, а где нет
| Сценарий | Пул помогает? | Почему |
|---|---|---|
Буферы []byte / bytes.Buffer на каждый запрос в HTTP-хендлере | Да | Высокая частота Get/Put, короткое время жизни, объект переиспользуется до GC |
| Крупные структуры парсинга, создаваемые тысячи раз в секунду | Да | Аллокация дорогая, частота высокая |
| Объекты, живущие дольше цикла GC | Нет | GC вычистит пул раньше, чем объект снова понадобится |
| Редкие вызовы (несколько раз в секунду) | Нет | Выигрыш меньше накладных расходов на логику пула |
| Буферы сильно разного размера | Осторожно | Пул отдаёт любой объект, можно получить огромный буфер под мелкую задачу |
Как замерить, а не поверить на слово
Прежде чем встраивать пул, замерьте аллокации бенчмарком. Флаг -benchmem покажет allocs/op и B/op — именно их пул должен снижать.
// bench_test.go, Go 1.22
func BenchmarkWithoutPool(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf := new(bytes.Buffer)
buf.WriteString("payload")
_ = buf.String()
}
}
func BenchmarkWithPool(b *testing.B) {
b.ReportAllocs()
for i := 0; i < b.N; i++ {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset()
buf.WriteString("payload")
_ = buf.String()
bufPool.Put(buf)
}
}
Запуск: go test -bench=. -benchmem. Смотрите на колонку allocs/op: если у варианта с пулом она упала до нуля или близко, пул делает своё дело. Если ns/op при этом вырос — на вашем сценарии частота вызовов слишком мала, и накладные расходы съедают выигрыш. Конкретные цифры зависят от железа и размера объектов, поэтому меряйте на своих данных, а не подставляйте числа из чужих статей.
Где подход ломается
Несколько граничных случаев, о которые спотыкаются на проде.
- Хранение объектов переменного размера. Пул с буферами не знает про их вместимость. Если вы клали в него буферы по мегабайту,
Getдля мелкой задачи вернёт мегабайтный буфер, и он останется в живых. Это тихая утечка памяти. Решение — не возвращать в пул слишком крупные объекты либо держать пулы по классам размеров. - Забытый Reset. Объект приходит из
Putв прежнем состоянии. Не сбросили — прочитали чужие данные. Для буферов это ещё и утечка содержимого между запросами разных пользователей. - Put указателя на объект, ссылка на который утекла. Если вы положили объект в пул, но где-то осталась живая ссылка, вы получите гонку данных: два владельца у одного объекта.
- Ожидание кеша. Пул не кеш и не пул фиксированного размера. Нельзя рассчитывать, что там всегда лежит N готовых объектов — GC может опустошить его в любой момент.
- Хранение объектов с внешними ресурсами. Файловые дескрипторы, соединения — не для
sync.Pool: GC выбросит объект, не закрыв ресурс. Для таких вещей нужны явные пулы с методом закрытия.
Что делать дальше
Встраивать пул стоит только после профилирования: соберите pprof с -alloc_objects и найдите места с самой высокой частотой аллокаций короткоживущих объектов — именно они кандидаты на пул. Для буферов переменного размера посмотрите в сторону нескольких пулов по классам размеров или готовых решений вроде bytebufferpool. И заведите бенчмарк с -benchmem в CI: он покажет регрессию, если кто-то случайно уберёт Reset или начнёт класть в пул объекты, которые не переиспользуются.
Тест и проверочная: Быстрые ответы с объяснением, почему остальные варианты неверны
Спросить за 10 ₽Источники: Пакет sync — документация Go, Исходный код sync/pool.go в репозитории Go
Частые вопросы
У меня другая версия Go, поведение то же самое?
Почему после встраивания пула бенчмарк не показал ускорения?
Нужно ли самому обнулять объект перед Put или после Get?
Можно ли хранить в пуле соединения с базой или файловые дескрипторы?
Сколько объектов реально хранится в пуле?
Как понять, что проблема именно в аллокациях, а не в чём-то ещё?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.