The Daily Newsstand · Free, Always
Thursday, October 8, 2026

sync.Pool теряет то, что вы хотели сохранить, и держит то, что хотели выбросить

Translate

С sync.Pool знакомство обычно начинается одинаково: начинаешь разбираться с профилированием и оптимизацией Go‑приложения, открываешь профиль — а там сервис четверть процессорного времени кормит сборщик мусора.

Дальше — как по написанному:

  1. Нашли горячий объект.

  2. Завернули в пул.

  3. Накатали бенчмарк.

  4. Бенчмарк нарисовал двух‑трёхкратный выигрыш.

  5. Код уехал в прод

В стандартной библиотеке пул торчит из каждого утюга — значит, приём одобрен сверху, значит, всё делаем правильно.

А подвох сидит прямо в названии.

Слово «пул» намекает на контейнер: есть содержимое, есть размер, есть время жизни, и всем этим рулит тот, кто контейнер завёл.

С 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 мс:

Пауза между Get

Попаданий

100 мс

93%

300 мс

57%

700 мс

37%

900 мс

10%

1,1 с

0%

3 с

0%

Ноль получается при удвоенном интервале сборки. То есть страдает не «слабо нагруженный сервис» как таковой, а конкретный пул, до которого руки доходят реже пары раз в секунду.

На практике это выглядит примерно так: свой пул у экспорта отчётов, свой у ночного обходчика, свой у админки — и так далее. Каждый Get там проваливается в New, а сверху вы ещё доплачиваете за pin и обход чужих очередей.

Из той же арифметики следует ещё одна вещь: кэш на пуле не построить. Распарсенные шаблоны, реестр подготовленных запросов, любая таблица, которую дорого наполнять, — всё это испарится между двумя пиками трафика. Для кэшей с Go 1.24 есть weak.Pointer и runtime.AddCleanup.

А вот пул соединений не соберёшь ни на том, ни на другом: соединению нужны понятное владение и закрытие в нужный момент, и сборщик тут совершенно не помощник.

Цена одного всплеска — 256 мегабайт на много циклов вперёд

Обратная сторона видна на обычном пуле буферов: берём срез, растим под размер ответа, возвращаем. 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‑разработчиков.

GOMAXPROCS теперь сам по себе

Последний сюжет совсем свежий и складывается из двух механизмов, каждый из которых по отдельности безобиден.

  • Первый живёт в 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 вырос после пика и не вернулся

в пуле осели переросшие буферы, нет предела на возврате

Пул не даёт выигрыша, хотя в бенчмарке давал

частота обращений ниже удвоенного интервала сборки

Память держится за закрытыми соединениями

объект в пуле удерживает ссылку, не обнулённую перед Put

В ответе чужие данные, -race в CI молчит

ссылка используется после Put, а в тестах нет параллельности

Всплеск аллокаций без деплоя

поменялся лимит CPU, GOMAXPROCS подрос, массив пулов пересоздан

Паника на приведении типа на первом запросе

у пула не задан New, Get вернул nil

Ни один из этих симптомов не виден изнутри пула, поэтому счётчик вызовов New, поделённый на число Get, стоит завести заранее. Другого способа узнать реальный процент попаданий мне не видится.

Не пул, а подсказка сборщику

В документации sync.Pool написано прямым текстом: любой элемент может быть удалён в любой момент без уведомления.

Пул ведёт себя в точности так, как обещано, и все шесть историй выше — про то, что от него ждут гарантий, которых он не давал.

Почти всё, что написано про пул, старше того момента, когда аллокатор со сборщиком в Go заметно подешевели.

Выигрыш в два раза, померенный в 2019 году, сегодня вполне может оказаться полутора процентами. Или убытком.

Оптимизация может отлично выглядеть в бенчмарке — и развалиться при встрече с реальной нагрузкой. Чтобы такие сюрпризы не доходили до продакшена, важно уметь проверять код в условиях, близких к боевым, и находить проблемы до пользователей.

Разобраться на практике можно на бесплатных открытых уроках OTUS:

  • 22 октября в 20:00. «Spring Boot под нагрузкой: почему сервис работает локально и падает в production». Записаться.

  • 5 ноября в 20:00. «Тесты в Go без боли: ловим баги раньше пользователей». Записаться.

Ещё больше бесплатных вебинаров октября собрали в дайджесте.

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.