sync.Pool теряет то, что вы хотели сохранить, и держит то, что хотели выбросить
- пятница, 9 октября 2026 г. в 00:00:06
Привет, Хабр!
С sync.Pool знакомство обычно начинается одинаково: начинаешь разбираться с профилированием и оптимизацией Go‑приложения, открываешь профиль — а там сервис четверть процессорного времени кормит сборщик мусора.
Дальше — как по написанному:
Нашли горячий объект.
Завернули в пул.
Накатали бенчмарк.
Бенчмарк нарисовал двух‑трёхкратный выигрыш.
Код уехал в прод
В стандартной библиотеке пул торчит из каждого утюга — значит, приём одобрен сверху, значит, всё делаем правильно.
А подвох сидит прямо в названии.
Слово «пул» намекает на контейнер: есть содержимое, есть размер, есть время жизни, и всем этим рулит тот, кто контейнер завёл.
С sync.Pool — увы. Внутри куча ящиков, по одному на каждый P, выносит их сборщик мусора, когда ему вздумается, а сколько памяти стоит каждый лежащий там объект, пул не знает и знать не хочет.
В итоге объект, который вы рассчитывали дожить до следующего запроса, пул теряет за пару секунд.
А четырёхмегабайтный буфер, случайно залетевший туда на пике, он будет таскать за собой десятки циклов сборки.
Откроем sync/pool.go и выкинем всё, кроме несущего. Останется вот что:
type Pool struct { noCopy noCopy local unsafe.Pointer // массив [P]poolLocal victim unsafe.Pointer // тот же массив с прошлого цикла GC New func() any } type poolLocal struct { private any // только для своего P shared poolChain // свой P кладёт и берёт с головы, чужие воруют с хвоста }
Один слот private и одна очередь shared на каждый P.
Put кладёт в private, а если тот занят — в голову своей очереди. Get идёт обратным маршрутом: private, голова своей очереди, воровство с хвостов чужих, просмотр victim, и только потом New.
При всей этой механике у пула нет размера. Ни границы по числу объектов, ни лимита памяти, ни счётчика попаданий, ни способа спросить, что там сейчас лежит.
Единственный рычаг — функция New, которая срабатывает, когда все четыре предыдущие попытки вернулись пустыми.
Нет и владельца, который решал бы, когда всё это выбросить. Решает сборщик мусора, функцией poolCleanup на остановленном мире: она проходит по пулам с непустым основным кэшем, выбрасывает содержимое victim и переселяет туда текущий local. Как часто пулом пользуются, её не интересует.
У этой самой poolCleanup есть история, из‑за которой половина советов в интернете устарела. До Go 1.13, вышедшего 3 сентября 2019 года, она выбрасывала вообще всё на каждой сборке.
Отсюда родился совет, который до сих пор кочует по статьям: пул очищается на каждом GC, учитывайте.
Совет устарел семь лет назад. victim как раз и придумали, чтобы сгладить провал сразу после сборки: объект, который никому не понадобился за цикл, получает второй шанс и только потом исчезает.
Шанс, правда, один‑единственный. Кладём в пул сотню объектов, прогоняем сборку несколько раз подряд, забираем все сто обратно и считаем, сколько из них старые:
p := sync.Pool{New: func() any { atomic.AddInt64(&news, 1); return new([4096]byte) }} bufs := make([]*[4096]byte, 100) for i := range bufs { bufs[i] = p.Get().(*[4096]byte) } for _, b := range bufs { p.Put(b) } atomic.StoreInt64(&news, 0) for i := 0; i < gcs; i++ { runtime.GC() } for i := range bufs { bufs[i] = p.Get().(*[4096]byte) // забираем все сто разом, а не по кругу }
Последняя строчка очень важна.
Напишите вместо неё p.Put(p.Get()) в цикле, и получите стопроцентное попадание при любом раскладе, потому что по кругу будет ходить один и тот же объект.
Сколько объектов доживает до конца:
0 циклов GC -> из 100 объектов уцелело 100 1 циклов GC -> из 100 объектов уцелело 99 2 циклов GC -> из 100 объектов уцелело 0 3 циклов GC -> из 100 объектов уцелело 0
Никакого плавного вымывания: два цикла сборки без обращения к пулу — и в нём пусто.
Причём хватает, чтобы эти два цикла прошли между двумя вашими Get: пул не стареет поэлементно, он стареет целиком.
Отсюда получается условие, при котором пул вообще работает, и посчитать его можно на салфетке.
Берём интервал между сборками из GODEBUG=gctrace=1, берём среднюю паузу между обращениями к пулу и сравниваем.
Если вторая больше удвоенной первой, попаданий не будет. Вот как это выглядит при сборке раз в 500 мс:
Пауза между | Попаданий |
|---|---|
100 мс | 93% |
300 мс | 57% |
700 мс | 37% |
900 мс | 10% |
1,1 с | 0% |
3 с | 0% |
Ноль получается при удвоенном интервале сборки. То есть страдает не «слабо нагруженный сервис» как таковой, а конкретный пул, до которого руки доходят реже пары раз в секунду.
На практике это выглядит примерно так: свой пул у экспорта отчётов, свой у ночного обходчика, свой у админки — и так далее. Каждый Get там проваливается в New, а сверху вы ещё доплачиваете за pin и обход чужих очередей.
Из той же арифметики следует ещё одна вещь: кэш на пуле не построить. Распарсенные шаблоны, реестр подготовленных запросов, любая таблица, которую дорого наполнять, — всё это испарится между двумя пиками трафика. Для кэшей с Go 1.24 есть weak.Pointer и runtime.AddCleanup.
А вот пул соединений не соберёшь ни на том, ни на другом: соединению нужны понятное владение и закрытие в нужный момент, и сборщик тут совершенно не помощник.
Обратная сторона видна на обычном пуле буферов: берём срез, растим под размер ответа, возвращаем. 99% ответов весят килобайт, а раз в день приезжает экспорт на четыре мегабайта.
b := p.Get().(*[]byte) if cap(*b) < n { *b = make([]byte, 0, n) // вырос под большой ответ } *b = (*b)[:n] // ... работаем ... *b = (*b)[:0] p.Put(b) // и вернулся в пул уже четырёхмегабайтным
Пятьдесят кругов по килобайту, потом один круг по четыре мегабайта на 64 буфера, потом снова пятьдесят по килобайту:
после 1 КиБ-запросов: объектов в пуле 64, средняя ёмкость 1 КиБ, heap 1033 КиБ после всплеска 4 МиБ: объектов в пуле 64, средняя ёмкость 4096 КиБ, heap 262267 КиБ
256 мегабайт лежат в пуле ради нагрузки, которой хватает 64 килобайт.
Полсотни последующих кругов по килобайту ничего не уменьшили и не могли: Get отдаёт произвольный объект, а не подходящий по размеру, ёмкость среза сама не падает, а вернуть память способна только двойная сборка без обращений к пулу. Обращения же идут постоянно, сервис‑то работает.
При этом состояние у нас устойчивое. Один всплеск переводит пул в новый режим, и держится он там, пока пул дёргают.
Фиксится все это дело оговоркой на возврате. Так сделано в fmt, и комментарий там стоит прочитать:
func (p *pp) free() { // Proper usage of a sync.Pool requires each entry to have approximately // the same memory cost. To obtain this property when the stored type // contains a variably-sized buffer, we add a hard limit on the maximum // buffer to place back in the pool. See golang.org/issue/23199. if cap(p.buf) > 64*1024 { p.buf = nil } else { p.buf = p.buf[:0] } ppFree.Put(p) }
Порог в 64 КиБ выбрасывает раздувшийся буфер и возвращает в пул один принтер. С этой оговоркой разница пропадает целиком:
без предела: heap 262272 КиБ, удержано пулом 262144 КиБ с пределом: heap 384 КиБ, удержано пулом 256 КиБ
В net/http пошли дальше и подняли ограничение на уровень типа. В пуле лежит не срез, а указатель на массив фиксированной длины, так что положить туда объект другого размера просто нечем:
const copyBufPoolSize = 32 * 1024 var copyBufPool = sync.Pool{New: func() any { return new([copyBufPoolSize]byte) }} func putCopyBuf(b []byte) { if len(b) != copyBufPoolSize { panic("trying to put back buffer of the wrong size in the copyBufPool") } copyBufPool.Put((*[copyBufPoolSize]byte)(b)) }
Паника вместо тихого возврата выглядит грубовато, зато честно: вернули чужой размер — значит, ошибка в вызывающем коде. Есть и третий подход, оттуда же: несколько пулов под разные классы размера, как bufioWriter2kPool и bufioWriter4kPool.
Следующая проблема уже не в памяти пула, а в памяти вокруг него. Объект, лежащий в пуле, остаётся достижимым, а вместе с ним достижимо всё, на что он ссылается.
Как с этим обходятся в стандартной библиотеке, видно по net/http: textproto.Reader возвращают в пул, но перед этим обнуляют поле.
func putTextprotoReader(r *textproto.Reader) { r.R = nil textprotoReaderPool.Put(r) }
Строчка r.R = nil рвёт ссылку на *bufio.Reader, а тот держит буфер соединения. Без неё каждый лежащий в пуле ридер тащил бы за собой память уже закрытого соединения, и утечка росла бы вместе с числом ядер и пиковой конкурентностью. Так же ведёт себя парсер с полем src или любой объект, который хранит результат предыдущей работы.
Есть и зеркальная половина: ссылка, которую забыли отпустить с другой стороны. После Put объект принадлежит пулу, и забрать его может любая чужая горутина.
buf := pool.Get().(*bytes.Buffer) buf.WriteString(payload) pool.Put(buf) return buf.Bytes() // объект уже отдан, содержимое может перезаписать кто угодно
Гонка редкая и оттого неприятная. Под нагрузочным тестом с одним типом запросов она не воспроизводится вовсе, а в проде, когда параллельных обработчиков сотни и объекты начинают ходить между очередями разных P, выливается в ответ с куском чужих данных внутри. go test -race такое ловит, если в тесте есть настоящая параллельность.
И мелочь: у пула без
NewметодGetвозвращаетnil. Вnet/httpэто проверяют явно,if v := textprotoReaderPool.Get(); v != nil. А приведение типаGet().(*Foo)безNewупадёт на первом же промахе.
Структуру с полем sync.Pool копировать нельзя, и вот здесь, в отличие от всего предыдущего, инструменты помогают. В Pool лежит noCopy, и go vet это видит:
a.go:4:19: copyHolder passes lock by value: vf.holder contains sync.Pool contains sync.noCopy
Правда, в набор, который go test гоняет сам, проверка не входит: в cmd/go/internal/test/test.go строка -copylocks закомментирована. То есть go vet ./... ошибку найдёт, а go test ./... напечатает ok и промолчит.
Нужен либо отдельный vet в сборке, либо go test -vet=copylocks.
Но вернёмся к тому, с чего всё начиналось, — к бенчмарку, который показал выигрыш в два раза и отправил пул в прод. Врёт в нём вообще не код, а измерение. Вот вроде неплохой на вид пример:
func BenchmarkSmallPool(b *testing.B) { for b.Loop() { s := smallPool.Get().(*small) s.a = 1 sink = s smallPool.Put(s) } }
На структуре в 64 байта получается 12 нс против 29 нс у простого new и ноль аллокаций против одной. Выигрыш в два с половиной раза, хоть сейчас неси на ревью. Только меряет он не то: Put кладёт объект в private‑слот, следующая итерация оттуда же его и забирает, сборка за это время почти не ходит.
Стопроцентное попадание в самый дешёвый путь. В проде между Get и Put лежит обработка запроса, объектов в полёте столько же, сколько параллельных запросов, часть уезжает в чужие очереди, часть умирает на сборке.
Тот же пул буферов по 32 КиБ, сборка по‑прежнему раз в 500 мс, меняется только частота обращений:
Обращений к пулу | Пауза | Попаданий |
|---|---|---|
1/с | 1 с | 0% |
2/с | 500 мс | 42% |
5/с | 200 мс | 87% |
20/с | 50 мс | 98% |
100/с | 10 мс | 100% |
К тому же альтернатива с каждым релизом становится дешевле, а бенчмарки в статьях так и висят с 2019 года. В Go 1.27 аллокации меньше 80 байт подешевели ещё процентов на тридцать.
Так что обвязка вокруг мелкого объекта окупается далеко не всегда — и решает тут не цена аллокации, а то, как часто вы туда дёргаете. А вот на крупных буферах разрыв никуда не девается. Тот же бенчмарк на 32 КиБ даёт 8500 нс на выделение против 12 нс на пул.
Разница в семьсот раз, и ничем её не закроешь: 32 килобайта всё равно придётся обнулить. Пул окупается там, где объект большой, живёт недолго и запрашивается часто.
Выпадает хоть одно условие — и остаются сплошные накладные расходы.
Кстати, если хотите проверить, насколько уверенно вы разбираетесь в Go за пределами очевидных сценариев, пройдите короткий тест для Go‑разработчиков.
Последний сюжет совсем свежий и складывается из двух механизмов, каждый из которых по отдельности безобиден.
Первый живёт в pinSlow, и комментарий в исходниках объясняет всё:
// If GOMAXPROCS changes between GCs, we re-allocate the array and lose the old one. size := runtime.GOMAXPROCS(0) local := make([]poolLocal, size) atomic.StorePointer(&p.local, unsafe.Pointer(&local[0]))
Массив poolLocal создаётся под текущее значение GOMAXPROCS и заменяется целиком, как только горутина попадает на P с номером больше прежнего размера.
Старый массив со всем содержимым теряет последнюю ссылку. Двенадцать лет это никого не волновало: sync.Pool появился в Go 1.3 в июне 2014-го, и всё это время GOMAXPROCS выставляли один раз на старте — и забывали.
Второй механизм приехал в Go 1.25 от 12 августа 2025-го. Рантайм научился читать ограничение CPU у cgroup и брать его вместо числа логических ядер, если оно меньше. Дробные значения округляются вверх, всё, что меньше двух, поднимается до двух. И главное — GOMAXPROCS теперь перечитывается периодически и меняется на ходу, вслед за лимитом или числом доступных ядер. Отключается это парой GODEBUG: containermaxprocs=0 и updatemaxprocs=0.
Дальше эти два механизма встречаются. Кто‑то правит лимит CPU в манифесте, VPA пересчитывает реквесты по недельной статистике, узел меняет конфигурацию — GOMAXPROCS подрастает сам, без единой строчки в коде, и все пулы процесса до единого теряют содержимое. На пуле из пятисот объектов это выглядит так:
GOMAXPROCS 8 -> 9: из 500 объектов доступно 1 GOMAXPROCS 4 -> 4: из 500 объектов доступно 499
Происходит не мгновенно: массив пересоздаётся тогда, когда какая‑нибудь горутина впервые попадёт на P с большим номером. Под нагрузкой это случается сразу. Отсюда имеем всплеск аллокаций и времени ответа в момент, когда никакого деплоя не было, а поменялся только лимит на поде.
Обходить тут нечего, событие редкое и разовое. Знать про него стоит ради одного: чтобы не искать причину всплеска у себя в коде.
Шесть историй, шесть разных симптомов:
Что видно | Что происходит |
|---|---|
RSS вырос после пика и не вернулся | в пуле осели переросшие буферы, нет предела на возврате |
Пул не даёт выигрыша, хотя в бенчмарке давал | частота обращений ниже удвоенного интервала сборки |
Память держится за закрытыми соединениями | объект в пуле удерживает ссылку, не обнулённую перед |
В ответе чужие данные, | ссылка используется после |
Всплеск аллокаций без деплоя | поменялся лимит CPU, |
Паника на приведении типа на первом запросе | у пула не задан |
Ни один из этих симптомов не виден изнутри пула, поэтому счётчик вызовов New, поделённый на число Get, стоит завести заранее. Другого способа узнать реальный процент попаданий мне не видится.
В документации sync.Pool написано прямым текстом: любой элемент может быть удалён в любой момент без уведомления.
Пул ведёт себя в точности так, как обещано, и все шесть историй выше — про то, что от него ждут гарантий, которых он не давал.
Почти всё, что написано про пул, старше того момента, когда аллокатор со сборщиком в Go заметно подешевели.
Выигрыш в два раза, померенный в 2019 году, сегодня вполне может оказаться полутора процентами. Или убытком.

Оптимизация может отлично выглядеть в бенчмарке — и развалиться при встрече с реальной нагрузкой. Чтобы такие сюрпризы не доходили до продакшена, важно уметь проверять код в условиях, близких к боевым, и находить проблемы до пользователей.
Разобраться на практике можно на бесплатных открытых уроках OTUS:
22 октября в 20:00. «Spring Boot под нагрузкой: почему сервис работает локально и падает в production». Записаться.
5 ноября в 20:00. «Тесты в Go без боли: ловим баги раньше пользователей». Записаться.
Ещё больше бесплатных вебинаров октября собрали в дайджесте.