sync.Map и sync.Pool#
Модуль: Concurrency · Уровень: Senior
TL;DR#
sync.Map — специализированная конкурентная мапа для двух конкретных сценариев: ключ пишется один раз и потом много читается, либо разные горутины работают с непересекающимися наборами ключей. В остальных случаях обычная map под RWMutex (или шардированная мапа) быстрее и типобезопаснее. sync.Pool — пул временных объектов для снижения давления на GC; содержимое пула очищается сборщиком мусора (с Go 1.13 через victim cache объект переживает один цикл), поэтому это буфер переиспользования, а не кэш и не пул соединений. Оба типа нельзя копировать после первого использования; оба не дают строгих гарантий наличия данных, и оба стоит применять только после того, как профиль показал проблему.
Простыми словами#
sync.Map — конкурентная мапа для двух узких сценариев, sync.Pool — ящик для переиспользования временных объектов. Оба часто берут не по назначению.
sync.Map — это мапа, в которую можно писать из нескольких горутин без мьютекса. Звучит как «просто лучшая мапа», но это не так: она заметно медленнее обычной на смешанной нагрузке и хранит any вместо конкретных типов, то есть теряет проверку компилятора. Она выигрывает ровно в двух ситуациях, под которые её и делали: когда ключ записали один раз и дальше только читают, и когда горутины работают каждая со своими ключами. Всё остальное — обычная map плюс sync.RWMutex.
sync.Pool — это ящик, куда складывают уже ненужные временные объекты, чтобы взять их снова вместо создания новых. Классика — буферы для сериализации. Главное, что надо про него знать: сборщик мусора чистит пул. Положенный объект может исчезнуть в любой момент, поэтому пул годится для временных буферов и категорически не годится для кэша или пула соединений — там нужны гарантии, которых он не даёт.
Если ты с другого языка. sync.Map — примерно ConcurrentHashMap, но с гораздо более узкой областью применения, и в Go нет культуры «всегда бери конкурентную коллекцию». sync.Pool ближе всего к пулам объектов из мира высоконагруженной Java, только с автоматической очисткой при сборке мусора.
Словарь этой статьи:
- contention — конкуренция горутин за один мьютекс;
- шардирование мапы — разбиение на N мап со своими мьютексами по хешу ключа;
- victim cache — «предбанник» пула, благодаря которому объект переживает один цикл сборки мусора;
Put/Get— вернуть объект в пул и взять из пула.
Теория#
sync.Map: зачем и когда#
Обычная map не потокобезопасна: конкурентная запись роняет процесс с fatal error: concurrent map writes, причём это не паника и recover не спасает. Стандартное решение — мьютекс:
type Cache struct {
mu sync.RWMutex
m map[string]*Item
}
func (c *Cache) Get(k string) (*Item, bool) {
c.mu.RLock()
defer c.mu.RUnlock()
v, ok := c.m[k]
return v, ok
}Это работает и в большинстве случаев быстрее sync.Map. Проблема появляется на многоядерной машине при высокой частоте обращений: даже RLock — это атомарная операция над общим счётчиком, и кэш-линия с ним начинает «летать» между ядрами.
Документация sync.Map прямо называет два сценария, где она выигрывает:
- Запись один раз, чтения много. Ключ добавляется и больше не меняется — например реестр обработчиков, кэш скомпилированных шаблонов, метаданные типов.
- Непересекающиеся наборы ключей. Каждая горутина читает и пишет свои ключи, пересечений почти нет.
В обоих случаях чтение идёт по атомарно читаемой копии без блокировок вообще.
var registry sync.Map
registry.Store("json", jsonCodec) // записать
v, ok := registry.Load("json") // прочитать
v, loaded := registry.LoadOrStore("x", c) // прочитать или записать атомарно
registry.Delete("json")
registry.Range(func(k, v any) bool { // обход; false прерывает
return true
})Ключевой метод — LoadOrStore: он решает классическую задачу «создать значение, если его ещё нет» без гонки, когда две горутины одновременно обнаружили отсутствие ключа.
Внутреннее устройство (полезно понимать, а не заучивать): исторически внутри лежали две мапы — атомарно читаемая read и защищённая мьютексом dirty. Чтение существующего ключа шло по read без блокировок, промахи вели в dirty и учитывались счётчиком; при накоплении промахов dirty становилась новой read. В Go 1.24 реализацию переписали на HashTrieMap, что заметно улучшило поведение при частых записях, но рекомендации по применению не изменились.
Что обычно лучше: шардированная мапа#
Если нагрузка не подходит под два сценария выше, а мьютекс стал узким местом, стандартный ответ — шардирование:
const shards = 32
type ShardedMap struct {
parts [shards]struct {
mu sync.RWMutex
m map[string]*Item
_ [40]byte // паддинг против false sharing
}
}
func (s *ShardedMap) shard(k string) int {
return int(fnv32(k)) % shards
}Конкуренция падает примерно в число шардов, типизация сохраняется, поведение предсказуемо. Именно так устроены популярные библиотеки конкурентных кэшей.
sync.Pool: устройство и границы применимости#
var bufPool = sync.Pool{
New: func() any { // вызывается, когда пул пуст
return new(bytes.Buffer)
},
}
func handle(w io.Writer, data any) error {
buf := bufPool.Get().(*bytes.Buffer)
defer func() {
buf.Reset() // обязательно очистить перед возвратом
bufPool.Put(buf)
}()
if err := json.NewEncoder(buf).Encode(data); err != nil {
return err
}
_, err := w.Write(buf.Bytes())
return err
}Что происходит внутри: у каждого P (логического процессора) есть своя приватная ячейка и локальная очередь, поэтому в типичном случае Get/Put обходятся без блокировок и без конкуренции между ядрами.
Главное свойство, определяющее область применения: пул очищается при сборке мусора. С Go 1.13 очистка двухступенчатая — содержимое сначала переезжает в victim cache и удаляется только на следующем цикле, что сгладило провалы производительности сразу после сборки. Но принцип остался: положенный объект не гарантированно доживёт до следующего Get.
Отсюда прямые следствия:
- пул соединений на
sync.Poolстроить нельзя — соединения будут исчезать, а их надо ещё и закрывать; - кэш на
sync.Poolстроить нельзя — данные пропадут в произвольный момент; - подходит он ровно для одного: короткоживущие однотипные буферы в горячем пути.
Ловушка с большими буферами#
// ПЛОХО: пул постепенно накапливает гигантские буферы
buf := bufPool.Get().(*bytes.Buffer)
buf.Write(hugePayload) // буфер вырос до 100 МБ
buf.Reset() // Reset НЕ уменьшает ёмкость
bufPool.Put(buf) // в пул вернулся объект на 100 МБReset обнуляет длину, но не ёмкость. Один аномально большой запрос навсегда раздувает буфер, и пул начинает удерживать память, которую больше никто не использует. Стандартное лечение — не возвращать переростков:
const maxKeep = 64 << 10 // 64 КБ
if buf.Cap() <= maxKeep {
buf.Reset()
bufPool.Put(buf)
}Ровно эту проблему в своё время чинили в самом net/http, и на собеседовании её любят.
Вторая ловушка: ссылка на возвращённый объект#
buf := bufPool.Get().(*bytes.Buffer)
defer bufPool.Put(buf)
...
return buf.Bytes() // БАГА: слайс смотрит в память, которую переиспользуютПосле Put объект принадлежит пулу, и его содержимое может измениться в любой момент из другой горутины. Возвращать наружу нужно копию.
Когда пул вообще оправдан#
Не всегда. sync.Pool сам по себе имеет стоимость, и на редких вызовах он проигрывает обычной аллокации: молодой мусор в Go и так убирается недорого.
Разумный порядок действий: сначала бенчмарк с -benchmem, потом попытка избавиться от аллокации вовсе (предразмер слайса, strings.Builder, работа на месте), и только если аллокации крупные, частые и однотипные — пул. Проверять результат обязательно тем же бенчмарком.
Подводные камни / gotchas#
sync.Map— не «быстрая мапа». На смешанной нагрузке она проигрываетmap+RWMutex. Применяй только под два описанных сценария.sync.Mapне типизирована.anyвнутри означает приведение типов при каждом чтении и потерю проверок компилятора.Rangeне даёт снимок. Он видит состояние на момент обхода и может как увидеть, так и не увидеть параллельные изменения.- Пул чистится сборщиком мусора. Никаких гарантий сохранности: не кэш, не пул соединений, не хранилище состояния.
Resetне уменьшает ёмкость. Без ограничения размера пул раздувается от единичных больших запросов.- Ссылка на объект после
Put— гонка данных с непредсказуемым результатом. - Забыть очистить объект перед
Put— утечка данных между запросами; в худшем случае чужие данные попадут в чужой ответ. - Копирование. Ни
sync.Map, ниsync.Poolнельзя копировать после использования; передавай указателем.go vetэто ловит.
Вопросы на собеседовании#
В: Когда стоит брать sync.Map вместо мапы с мьютексом?
О: В двух случаях: ключ записывается один раз и дальше в основном читается, либо горутины работают с непересекающимися наборами ключей. В остальных сценариях map под RWMutex быстрее и удобнее за счёт типизации.
В: Что произойдёт при конкурентной записи в обычную мапу?
О: Рантайм обнаружит это и завершит процесс с fatal error: concurrent map writes. Это не паника, recover не помогает — сознательное решение авторов, чтобы гонка не превращалась в тихое повреждение данных.
В: Зачем нужен LoadOrStore?
О: Чтобы атомарно получить существующее значение или записать своё. Без него две горутины могут одновременно обнаружить отсутствие ключа и создать два разных значения.
В: Что происходит с содержимым sync.Pool при сборке мусора?
О: Оно очищается. С Go 1.13 объекты сначала переезжают в victim cache и удаляются на следующем цикле, поэтому один цикл они переживают. Гарантий сохранности нет, и на этом нельзя строить кэш.
В: Почему на sync.Pool нельзя сделать пул соединений?
О: Потому что соединения будут молча исчезать при сборке мусора, а закрывать их окажется некому. Пулу соединений нужны учёт, проверка живости и ограничение количества — ничего этого sync.Pool не даёт.
В: Какая типичная утечка памяти связана с sync.Pool?
О: Возврат в пул буфера, разросшегося на аномально большом запросе: Reset не уменьшает ёмкость, и пул навсегда удерживает лишнюю память. Лечится проверкой Cap() перед Put.
В: Как измерить пользу от пула?
О: Бенчмарком с -benchmem до и после, глядя на allocs/op и B/op, и профилем кучи. На редких вызовах пул часто проигрывает обычной аллокации.
На что копают на senior+#
- Умение объяснить, почему у
sync.Mapтакая узкая ниша, и предложить шардирование как основную альтернативу. - Понимание связи
sync.Poolсо сборщиком мусора, включая роль victim cache и причину его появления. - Осознание, что оба примитива — оптимизация, а не выбор по умолчанию: сначала измерение, потом применение.
- Знание типовых аварий: раздувание пула, ссылка на возвращённый объект, утечка данных между запросами.
- Follow-up: «Почему конкурентная запись в мапу —
fatal error, а не паника?», «Как устроенsync.PoolотносительноPи почемуGet/Putпочти бесплатны?», «Что изменилось вsync.Mapв Go 1.24?».