Почему sync.Map — почти всегда плохая идея
- суббота, 7 июня 2025 г. в 00:00:13
Привет, Хабр!
Сегодня разбираемся, почему sync.Map — выглядит аппетитно, но почти всегда оказывается не тем, чем вы ожидали.
К середине 2010-х стало очевидно: дефолтный подход map + sync.RWMutex не справляется с задачами, где тысячи горутин читают данные одновременно, а записи происходят редко. Особенно это чувствовалось в телеметрии, логировании, сборе метрик — в тех местах, где важно не терять данные, не аллоцировать лишнее и не мешать соседним потокам. В таких системах узким местом становился не алгоритм, а именно блокировки. Классические мьютексы начинали душить производительность под высокой конкуренцией, даже если запись шла раз в полминуты, а чтения — десятками тысяч в секунду.
Так в Go появилась идея выделить специализированный тип мапы, который будет давать приоритет именно чтениям. Структура sync.Map с самого начала проектировалась как оптимизация для узких, но критически важных сценариев: когда большая часть операций — это Load, и ключи редко изменяются. Она устроена особым образом: делит данные на две мапы, одна из которых только для чтения, работает без мьютексов и хранится в atomic.Value. Вторая — так называемая dirty map — используется для новых ключей и записей. Такая конструкция позволяла добиться высокой пропускной способности и низкой латентности на чтениях, при этом не блокируя другие потоки.
Однако с самого момента появления sync.Map разработчики Go настойчиво предупреждали: «Не трогайте, пока не убедились, что обычный map + RWMutex реально тормозит». Это был не инструмент на каждый день, а скорее очень тонкий инструментик — вытащили, использовали по назначению и убрали обратно. Но реальность оказалась другой: как только новички узнавали, что есть якобы «потокобезопасная мапа», она тут же начинала использоваться везде — от кэширования до сессий пользователей. В итоге появилось множество плохо работающих решений, где 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 и создавался — так называемые 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.
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.
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 операций в секунду
Разбиваете мапу на 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-инфраструктуре, которые помогут вам выйти на новый профессиональный уровень.