Привет, Хабр!

Сегодня разбираемся, почему sync.Map — выглядит аппетитно, но почти всегда оказывается не тем, чем вы ожидали.

Откуда взялся sync.Map и зачем он был нужен

К середине 2010-х стало очевидно: дефолтный подход map + sync.RWMutex не справляется с задачами, где тысячи горутин читают данные одновременно, а записи происходят редко. Особенно это чувствовалось в телеметрии, логировании, сборе метрик — в тех местах, где важно не терять данные, не аллоцировать лишнее и не мешать соседним потокам. В таких системах узким местом становился не алгоритм, а именно блокировки. Классические мьютексы начинали душить производительность под высокой конкуренцией, даже если запись шла раз в полминуты, а чтения — десятками тысяч в секунду.

Так в Go появилась идея выделить специализированный тип мапы, который будет давать приоритет именно чтениям. Структура sync.Map с самого начала проектировалась как оптимизация для узких, но критически важных сценариев: когда большая часть операций — это Load, и ключи редко изменяются. Она устроена особым образом: делит данные на две мапы, одна из которых только для чтения, работает без мьютексов и хранится в atomic.Value. Вторая — так называемая dirty map — используется для новых ключей и записей. Такая конструкция позволяла добиться высокой пропускной способности и низкой латентности на чтениях, при этом не блокируя другие потоки.

Однако с самого момента появления sync.Map разработчики Go настойчиво предупреждали: «Не трогайте, пока не убедились, что обычный map + RWMutex реально тормозит». Это был не инструмент на каждый день, а скорее очень тонкий инструментик — вытащили, использовали по назначению и убрали обратно. Но реальность оказалась другой: как только новички узнавали, что есть якобы «потокобезопасная мапа», она тут же начинала использоваться везде — от кэширования до сессий пользователей. В итоге появилось множество плохо работающих решений, где sync.Map не только не ускорял, но и приводил к регрессии.

Анатомия sync.Map: две карты, один мьютекс и промахи как триггер

Внутри sync.Map — нестандартная организация памяти. Структура устроена вокруг двух отдельных хеш-таблиц: одна только для чтения, вторая для всех новых ключей и записей. Доступ к ним регулируется через один sync.Mutex, но используется он только в случае записи или промаха — поэтому на чистых чтениях мьютекс не блокирует конкуренцию.

type Map struct {
    mu    sync.Mutex      // защита dirty и miss-счётчика
    read  atomic.Value    // быстрая карта только для чтений
    dirty map[any]entry   // временное хранилище новых ключей
    miss  int             // количество промахов по read
}

Как это работает:

Чтение всегда первым делом обращается к read. Эта карта обёрнута в atomic.Value, что делает доступ безмьютексным и безопасным для конкурентных горутин.

Если ключ найден в read — отлично, возврат значения без единого системного вызова.

Если ключ не найден — выполняется промах. Тогда под мьютексом запускается поиск в dirty. Если и там нет — возвращается nil. А если есть — счётчик miss увеличивается.

Когда miss > len(dirty), считается, что read уже сильно устарела, и запускается процедура «промоушена»: вся dirty копируется в read, а старая read отбрасывается. Вот здесь и возникает лаг — поток, обнаруживший промах, блокирует других, пока не завершит копирование. Это и есть узкое место, создающее пики латентности при большом количестве уникальных ключей.

Фича в том, что read не обновляется на каждую запись, а только когда промахов становится слишком много. Это позволяет снизить overhead при частом чтении и редких записях. Но в случаях с постоянно меняющимися ключами (id_1, id_2, id_3, ...) read всё время неактуальна, и промоушены происходят постоянно. Именно здесь sync.Map превращается из оптимизации в проблему: постоянное копирование карты под мьютексом создаёт контеншн, а график профайлера уходит в красную зону.

К слову, из-за этой архитектуры в 2024–2025 годах активно обсуждаются альтернативные реализации. На GitHub уже лежат прототипы Hash-Trie-реализаций, которые устраняют промоушены как класс, работая через lock-free узлы и структурную перезапись. На микро-бенчмарках такие мапы показывают до −35 % по латентности и заметное снижение аллокаций. Но пока в sync.Map — всё по-старому: две карты, один мьютекс, и промах как сигнал на тяжелую операцию.

Когда sync.Map действительно хорош

Есть очень узкий, но важный класс задач, под который sync.Map и создавался — так называемые read-mostly сценарии. Это такие ситуации, где:

  • Чтения происходят постоянно, в сотнях и тысячах потоков одновременно.

  • Записи — крайне редки.

  • Набор ключей стабилен.

  • Пропуски случаются редко или вообще не случаются.

Классический пример: система метрик или логгирования, где по ходу работы приложения нужно отслеживать счётчики событий, тайминги, фичи — и всё это без тормозов, аллокаций и мьютексов.

var metrics sync.Map // ключ — имя метрики, значение — atomic.Int64

func inc(name string, delta int64) {
    // Если метрика уже есть — возвращает указатель, иначе сохраняет новую
    v, _ := metrics.LoadOrStore(name, new(atomic.Int64))
    v.(*atomic.Int64).Add(delta)
}

LoadOrStore сначала ищет ключ в read. Если он есть — возврат указателя на atomic.Int64. Если ключа нет (что бывает только при первом обращении) — мьютекс блокируется, новый указатель записывается в dirty, и, возможно, read обновляется позже. Далее весь трафик обращается к уже готовому значению в read, и Add() — это atomic-операция, которая вообще не требует блокировок.

Поскольку ключи не меняются (например, "http_requests_total" или "login_failures"), и новые появляются только при старте — dirty-карта практически не используется. Это означает, что промахов по read не происходит, а значит и промоушенов не будет. Именно в этом случае sync.Map раскрывает себя как быстрая мапа без аллокаций и блокировок на чтении.

Главное условие успеха в том, что вы не создаёте новые ключи во время выполнения сервиса. Если же ключи генерируются динамически (например, на основе user_id или request_id), то этот паттерн рушится. При каждом новом ключе будет промах, и каждый промах — это шаг к тому самому катастрофическому промоушену, из-за которого sync.Map начинает тормозить.

Итого: sync.Map хорош только тогда, когда ваш набор ключей стабилен, а все операции — это Load.

Почему можно получать баги и регресс в 3 строки кода

«Динамические» ключи

var cache sync.Map // BAD!

for i := 0; i < N; i++ {
    id := strconv.Itoa(i)          // каждый раз — новый ключ
    cache.Store(id, bigPayload(i)) // большое значение
}

Если ваш код каждый раз записывает новые ключи — вы ломаете архитектуру sync.Map. read-карта постоянно промахивается, потому что не знает про ключи заранее. Каждый промах — это поход в dirty, а через определённое количество таких промахов происходит полная перезапись read-карты. Этот процесс синхронизирован через мьютекс и блокирует всех читателей. В результате:

  • растёт латентность;

  • падает throughput;

  • аллоцируется куча памяти, особенно если значения объёмные;

  • профайлер становится кроваво-красным в sync.(*Map).Load.

Суть: если ключей много и они постоянно новые — sync.Map превращается в источник проблем.

Отсутствие типобезопасности

v, _ := m.Load("key")
if v.(User).IsAdmin { ... } // panic: interface conversion

Классика: вы загрузили объект из карты, и — здравствуй, interface conversion panic. Почему? Потому что sync.Map работает с типом any, а значит компилятор не проверяет ничего. Один раз не туда записали *User вместо User, и весь сервис ложится. Никаких подсказок, никаких предупреждений — просто смерть в рантайме.

Да, с Go 1.18 появилась возможность писать свои типобезопасные обёртки на дженериках. А в 2025 году обсуждается полноценный sync/v2, в котором Map[K, V] уже встроен и не требует кастов. Но до тех пор:

  • либо пишите обёртки сами;

  • либо ловите паники на бою.

Побочные состояния и гонки на «невидимом» уровне

u, _ := sessions.Load(id)
u.(*User).Balance -= charge // атомарно? увы, нет...

Это одна из самых коварных ошибок. Вы вроде бы получили объект потокобезопасно, но потом модифицировали его внутреннее состояние. И думаете, что раз Load() не дал гонку — значит и всё остальное хорошо? Нет.

  • sync.Map не делает никакой синхронизации по полям полученного объекта;

  • если два потока делают Load() одного и того же ключа, а потом мутируют поле — вы получаете data race;

  • стандартный -race поймает далеко не все случаи: особенно если речь о инкрементах, а не полной перезаписи.

Даже если sync.Map вам вернул объект безопасно — это не означает, что сам объект безопасен. Вам нужно дополнительно использовать atomic, мьютексы или CAS.

Альтернативы:

map + sync.RWMutex: классика

type SafeMap struct {
    mu sync.RWMutex
    m  map[string]User
}

func (s *SafeMap) Load(key string) (User, bool) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    val, ok := s.m[key]
    return val, ok
}

func (s *SafeMap) Store(key string, value User) {
    s.mu.Lock()
    defer s.mu.Unlock()
    s.m[key] = value
}

Прямая типизация — никакого any.Дешево и достаточно эффективно до ~10k операций в секунду

Sharded map: если нужно много параллелизма

Разбиваете мапу на 16–32 сегмента и распределяете ключи по хешу:

type shard[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
}

type ShardedMap[K comparable, V any] struct {
    shards []shard[K, V]
    count  int
}

func NewShardedMap[K comparable, V any](n int) *ShardedMap[K, V] {
    s := make([]shard[K, V], n)
    for i := 0; i < n; i++ {
        s[i].m = make(map[K]V)
    }
    return &ShardedMap[K, V]{shards: s, count: n}
}

Снижаем контеншн и даем предсказуемое поведение даже под большой нагрузкой.

Типобезопасные обёртки с дженериками

Go 1.18+:

type ConcurrentMap[K comparable, V any] struct {
    mu sync.RWMutex
    m  map[K]V
}

// Реализация аналогична SafeMap, но типизирована

Выглядит аккуратно, работает стабильно, не требует кастов.


Заключение

sync.Map полезен в строго ограниченном числе случаев. Он отлично справляется с задачами телеметрии, где ключей немного, они не меняются, а потоков — сотни.

В 2025 году Go-сообщество всё ещё обсуждает судьбу sync.Map. Возможно, его заменят на sync/v2 с дженериками и более надёжной архитектурой. Но пока этого не произошло — думайте перед тем, как использовать.


Если вы хотите глубже понять работу с многозадачностью, синхронизацией данных и улучшить производительность в своих Go‑проектах, то не пропустите эти открытые уроки:

  1. «Стили взаимодействия микросервисов: 5 секретов, которые изменят ваш подход к backend‑разработке» — 11 июня, 20:00

  2. «Как системному аналитику не допустить Spaghetti Code и других проблем в архитектуре» — 9 июня, 20:00

  3. «Jenkins Job Builder: автоматизируем развёртывание jobs и упрощаем CI/CD процесс» — 10 июня, 20:00

Также не забудьте заглянуть в наш каталог курсов. Там — курсы по программированию, архитектуре приложений и IT-инфраструктуре, которые помогут вам выйти на новый профессиональный уровень.

Комментарии (1)


  1. starwalkn
    06.06.2025 06:32

    Когда miss > len(dirty)

    Тут небольшое уточнение - когда miss >= len(dirty), если судить по исходникам:

    func (m *Map) missLocked() {
    	m.misses++
    	if m.misses < len(m.dirty) {
    		return
    	}
    	m.read.Store(&readOnly{m: m.dirty})
    	m.dirty = nil
    	m.misses = 0
    }

    И начиная, кажется, с 1.19 - read стал atomic.Pointer[readOnly] вместо atomic.Value.