🧐 sync.Pool в Go: 5 случаев, когда он вредит — 29 мая 2026 г. в 14:58:00.783
🧐 sync.Pool в Go: 5 случаев, когда он вредит Про sync.Pool обычно пишут в контексте оптимизации. Меньше аллокаций, ниже давление на GC, быстрее обработка. Звучит как бесплатный буст, поэтому разработчики начинают пулить всё подряд. А потом бенчмарки показывают, что стало хуже. Разберём конкретные ситуации, когда sync.Pool не помогает или ломает то, что работало. Маленькие объекты Пулить структуру из трёх int — хуже, чем просто создать новую. У пула есть собственные накладные расходы: упаковка в any (interface boxing), type assertion при каждом Get, атомарные операции на P-локальном кеше. Для маленьких объектов эти расходы перевешивают стоимость обычного new. Вы добавляете сложность в код и получаете отрицательный результат. Простой способ проверить — бенчмарк с -benchmem. Если B/op и allocs/op не упали заметно, а ns/op вырос, пул обходится дороже, чем аллокация. Объекты без сброса состояния Это фабрика багов. Вы вызываете Get, получаете старый bytes.Buffer с данными предыдущего запроса, забываете вызвать Reset() и начинаете дописывать к чужим данным. В реальных системах это приводило к тому, что данные одного пользователя утекали в ответ другого. Правильный паттерн — сбрасывать состояние сразу после Get: buf := bufPool.Get().(*bytes.Buffer) buf.Reset() defer bufPool.Put(buf) Можно сбрасывать и при Put. Главное — выбрать одну стратегию и не отступать от неё. Но сброс при Get надёжнее, потому что защищает от случаев, когда кто-то положил объект в пул без очистки. Долгоживущие объекты sync.Pool спроектирован для вещей, которые вы берёте, используете и возвращаете в рамках одной операции. Взяли буфер, записали ответ, вернули. Если объект живёт между запросами или хранит состояние на протяжении сессии, пул не нужен. Просто храните ссылку. Ещё один нюанс: GC может очистить пул в любой момент. Объекты в sync.Pool не гарантированно доживают до следующего вызова Get. Если вы рассчитываете на то, что объект будет на месте, вы рассчитываете зря. Буферы переменного размера Допустим, вы пулите []byte. Большинство буферов занимают 1KB, но изредка некоторые вырастают до 10MB. После возврата в пул эти 10MB остаются закреплёнными в памяти до следующего цикла GC. Под нагрузкой таких раздутых буферов накапливается много, и потребление памяти растёт непредсказуемо. Решение — ограничивать размер того, что возвращаете в пул: defer func() { if buf.Cap() < 64*1024 { bufPool.Put(buf) } }() Буферы крупнее порога просто отпускаются в GC. Если размеры сильно разнятся, можно завести отдельный sync.Pool для каждого класса размеров. sync.Pool вместо кеша sync.Pool — это не кеш. GC может стереть его содержимое в любой момент, и вы ничего с этим не сделаете. Нет ни TTL, ни лимита на размер, ни гарантии сохранности. Если вам нужно, чтобы объекты жили дольше одного цикла GC, используйте полноценный кеш. Как понять, что пул вредит Запустите бенчмарк с -benchmem и сравните версии с пулом и без: BenchmarkHandler-8 500000 2400 ns/op 1024 B/op 3 allocs/op BenchmarkHandlerPooled-8 800000 1500 ns/op 48 B/op 1 allocs/op Такой результат означает, что пул работает. Но если ns/op вырос, а allocs/op почти не изменился, пул не оправдал себя. Убирайте его без сожалений. В проде настоящий эффект пула виден через метрики GC: частоту запусков и длительность пауз (runtime.ReadMemStats, pprof). Если пул не снижает эти показатели, он занимает место в коде без пользы. sync.Pool не стоит применять по умолчанию. Сначала профилируйте, найдите аллокации, которые реально влияют на производительность, и только потом пробуйте пул. Замерьте результат. Если улучшения нет, откатывайте. Большей части кода пул не нужен.

