sync.Pool в Go: типичные ошибки и как их избежать
sync.Pool снижает нагрузку на сборщик мусора, но неправильное использование превращает его в источник data race, утечек и ложной экономии. Разбираем конкретные ошибки с примерами кода и замерами.

Зачем вообще пул, если есть GC
Сервис на Go принимает 10 000 запросов в секунду, каждый аллоцирует буфер на 4 КБ. Это 40 МБ мусора в секунду — GC справится, но паузы вырастут, а латентность p99 поползёт вверх. sync.Pool позволяет переиспользовать объекты между вызовами, сокращая число аллокаций. Идея простая, но реализация полна ловушек: объекты могут утекать с грязным состоянием, пул может не давать никакого выигрыша, а бенчмарк — врать.
Материал написан для Go 1.21–1.23. Внутреннее устройство sync.Pool менялось: victim cache появился в Go 1.13, а до этого пул очищался на каждом цикле GC. Всё, что ниже, относится к актуальной реализации.
Ошибка 1. Забыть сбросить состояние перед возвратом в пул
Самая частая и самая опасная проблема. Объект возвращается в пул с данными предыдущего запроса, следующий потребитель получает чужое состояние. В лучшем случае — мусор в ответе, в худшем — утечка персональных данных между пользователями.
Плохо
// Go 1.22+
var bufPool = sync.Pool{
New: func() any {
return new(bytes.Buffer)
},
}
func handleRequest(data []byte) []byte {
buf := bufPool.Get().(*bytes.Buffer)
// buf может содержать данные предыдущего вызова
buf.Write(data)
result := buf.Bytes()
bufPool.Put(buf) // грязный буфер вернулся в пул
return result
}
Правильно
func handleRequest(data []byte) []byte {
buf := bufPool.Get().(*bytes.Buffer)
buf.Reset() // очищаем перед использованием
buf.Write(data)
result := make([]byte, buf.Len())
copy(result, buf.Bytes())
bufPool.Put(buf)
return result
}
Обратите внимание на copy: без него вы вернёте слайс, указывающий на внутренний массив буфера, который будет перезаписан следующим потребителем. Это вторая ловушка в том же примере.
Правило: Reset перед использованием, а не перед возвратом. Если сбрасывать при Put, а между Put и следующим Get объект окажется не в пуле (GC его собрал), Reset выполнится впустую. Если сбрасывать при Get — Reset гарантированно происходит перед каждым использованием.
Ошибка 2. Хранить в пуле объекты с финализаторами или горутинами
sync.Pool не гарантирует, что объект останется живым: GC может удалить его в любой момент между циклами. Если объект держит открытый файл, сетевое соединение или запущенную горутину, вы получите либо утечку ресурса (горутина не остановлена), либо use-after-close (соединение закрыто GC, но код пытается писать).
Пул подходит для «тупых» значений: буферов, слайсов, структур без внешних зависимостей. Для соединений к БД или gRPC-стримов нужен отдельный пул с явным управлением жизненным циклом — например, database/sql внутри реализует именно такой.
Ошибка 3. Класть в пул объекты разного размера
Типичная ситуация: пул буферов, одни запросы пишут 100 байт, другие — 10 МБ. Большой буфер возвращается в пул, достаётся следующему запросу на 100 байт и держит 10 МБ памяти без дела. Пул превращается в коллекцию крупных аллокаций, которые GC не может забрать (они referenced).
Решение: ограничить максимальный размер
const maxBufSize = 64 * 1024 // 64 КБ
func releaseBuf(buf *bytes.Buffer) {
// Не возвращаем раздутые буферы в пул
if buf.Cap() > maxBufSize {
return // пусть GC заберёт
}
buf.Reset()
bufPool.Put(buf)
}
Альтернатива — несколько пулов под разные диапазоны размеров, как делает стандартная библиотека в net/http. Но для большинства сервисов хватает порога отсечения.
Ошибка 4. Использовать пул там, где он не нужен
Не каждая аллокация заслуживает пула. sync.Pool добавляет накладные расходы: мьютекс (пусть и оптимизированный через per-P local pool), type assertion при Get, вызов Reset. Если объект маленький и живёт наносекунды, пул может быть медленнее обычного new.
| Критерий | Пул оправдан | Пул избыточен |
|---|---|---|
| Размер объекта | Сотни байт и выше | Десятки байт, скалярные обёртки |
| Частота аллокаций | Тысячи в секунду | Единицы в секунду |
| Давление на GC | Видно в pprof (alloc_space) | Не видно |
| Стоимость инициализации | Дорогая (компиляция regexp, парсинг) | Тривиальная (new struct) |
Перед добавлением пула снимите профиль: go tool pprof -alloc_space. Если целевая аллокация не входит в топ-10 — пул ничего не даст, только усложнит код.
Ошибка 5. Полагаться на пул как на кеш
sync.Pool — не кеш. Документация прямо говорит: объекты могут быть удалены в любой момент без уведомления. С Go 1.13 введён victim cache — объекты переживают один цикл GC, а на втором удаляются. Но это деталь реализации, а не контракт. Если вам нужен кеш — используйте sync.Map, LRU-кеш или внешнее хранилище.
Как измерить эффект правильно
Стандартный testing.B с b.ReportAllocs() покажет число аллокаций, но не покажет влияние на GC в реальной нагрузке. Для полной картины нужно два замера:
- Бенчмарк с
-benchmem— фиксирует allocs/op и bytes/op до и после внедрения пула. - Нагрузочный тест с
GODEBUG=gctrace=1— смотрите на STW-паузы и частоту циклов GC. Сокращение числа циклов при той же нагрузке — главный показатель успеха.
Сравнивайте не средние, а перцентили: пул влияет именно на хвост распределения латентности.
Сноска: victim cache — механизм внутри sync.Pool, при котором объекты при сборке мусора не удаляются сразу, а перемещаются во «вторичный» пул. Если до следующей сборки объект востребован — он возвращается в основной пул. Если нет — удаляется окончательно. Это снижает эффект «холодного старта» после GC.
Чек-лист перед выкаткой
- Профиль аллокаций снят, целевая аллокация в топе — пул оправдан.
- Reset вызывается при Get, а не при Put.
- Возвращаемые данные копируются — потребитель не держит ссылку на внутренности объекта из пула.
- Объекты с внешними ресурсами (файлы, соединения, горутины) в пул не попадают.
- Установлен порог на максимальный размер — раздутые объекты уходят в GC.
- В тестах есть
go test -race— data race при неправильном пуле ловится именно так.
Что дальше
После внедрения пула следующий шаг — мониторинг в проде. Добавьте метрику на количество вызовов New (оберните фабричную функцию счётчиком). Если New вызывается на каждый запрос — пул не работает: объекты не возвращаются или GC забирает их быстрее, чем вы кладёте. Если New не вызывается вообще — пул перенасыщен, возможно, стоит уменьшить порог возврата. Тема пулов соединений и горутин (worker pool) — отдельный разговор с другими паттернами управления жизненным циклом.
Поиск источников: Реальные статьи и книги со ссылками, библиография по ГОСТ
Спросить за 15 ₽Источники: Документация sync.Pool — стандартная библиотека Go, Go Blog — профилирование программ на Go
Частые вопросы
sync.Pool потокобезопасен?
Когда GC очищает пул?
Что если New не задан, а пул пуст?
Можно ли ограничить размер пула?
Что будет, если забыть вернуть объект в пул через Put?
sync.Pool не сработал, аллокации не уменьшились — в чём причина?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.