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 прямо называет два сценария, где она выигрывает:

  1. Запись один раз, чтения много. Ключ добавляется и больше не меняется — например реестр обработчиков, кэш скомпилированных шаблонов, метаданные типов.
  2. Непересекающиеся наборы ключей. Каждая горутина читает и пишет свои ключи, пересечений почти нет.

В обоих случаях чтение идёт по атомарно читаемой копии без блокировок вообще.

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?».