Привет, Хабр!
Сегодня разбираемся, почему 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‑проектах, то не пропустите эти открытые уроки:
«Стили взаимодействия микросервисов: 5 секретов, которые изменят ваш подход к backend‑разработке» — 11 июня, 20:00
«Как системному аналитику не допустить Spaghetti Code и других проблем в архитектуре» — 9 июня, 20:00
«Jenkins Job Builder: автоматизируем развёртывание jobs и упрощаем CI/CD процесс» — 10 июня, 20:00
Также не забудьте заглянуть в наш каталог курсов. Там — курсы по программированию, архитектуре приложений и IT-инфраструктуре, которые помогут вам выйти на новый профессиональный уровень.
starwalkn
Тут небольшое уточнение - когда miss >= len(dirty), если судить по исходникам:
И начиная, кажется, с 1.19 - read стал
atomic.Pointer[readOnly]
вместоatomic.Value
.