Go sync.Map vs RWMutex: когда какой примитив синхронизации выбрать
Если вы работали с Go и горутинами, то наверняка видели панику fatal error: concurrent map writes. Встроенный map в Go не потокобезопасен — любой конкурентный доступ без синхронизации приводит к краху. Вопрос в том, чем защищать: sync.RWMutex или sync.Map? Разберём оба подхода и поймём, когда какой выигрывает.
Проблема: concurrent map access panic
Go детектирует конкурентный доступ к map в рантайме и паникует:
m := make(map[string]int)
// Две горутины пишут одновременно — паника
go func() { m["a"] = 1 }()
go func() { m["b"] = 2 }()
// fatal error: concurrent map writes
Это не race condition, который можно «не заметить» — это гарантированный краш. Нужна синхронизация.
sync.RWMutex: базовый подход
Классическое решение — обернуть map в структуру с мьютексом:
type SafeMap struct {
mu sync.RWMutex
m map[string]int
}
func (s *SafeMap) Get(key string) (int, bool) {
s.mu.RLock()
defer s.mu.RUnlock()
v, ok := s.m[key]
return v, ok
}
func (s *SafeMap) Set(key string, value int) {
s.mu.Lock()
defer s.mu.Unlock()
s.m[key] = value
}
Плюсы RWMutex:
- Простой и понятный код
- Типизированный (generics или конкретный тип)
- Множественные читатели не блокируют друг друга (
RLock) - Предсказуемая производительность
Минусы:
- Писатель блокирует всех читателей
- При высоком contention (много ядер) — деградация из-за cache line bouncing на мьютексе
sync.Map: для каких паттернов оптимизирован
sync.Map — специализированная структура из стандартной библиотеки. Документация прямо говорит, для каких случаев она оптимизирована:
- Ключи стабильны: записываются один раз, читаются много раз (кеш, конфиг)
- Много горутин читают/пишут непересекающиеся наборы ключей (per-goroutine data)
var cache sync.Map
// Запись
cache.Store("user:42", userData)
// Чтение
if val, ok := cache.Load("user:42"); ok {
user := val.(UserData) // type assertion нужен
// ...
}
// Load or Store (атомарно)
actual, loaded := cache.LoadOrStore("user:42", defaultUser)
// Удаление
cache.Delete("user:42")
// Итерация (не атомарна!)
cache.Range(func(key, value any) bool {
fmt.Println(key, value)
return true // false — остановить
})
Внутри sync.Map использует два map: «чистый» (read-only, доступ без лока) и «грязный» (с мьютексом). При чтении сначала проверяется чистый map — если ключ там, лок не нужен. Это даёт выигрыш при read-heavy нагрузке.
Минусы sync.Map:
- Нет generics — всё через
any, нужны type assertions - При частых записях новых ключей — медленнее RWMutex
- Больше аллокаций (внутренние entry-обёртки)
- Range не атомарен и не гарантирует консистентный снапшот
Бенчмарк: RWMutex vs sync.Map
Тестируем на 8 ядрах, 1M ключей, разные соотношения чтение/запись:
func BenchmarkRWMutex_Read90(b *testing.B) {
sm := &SafeMap{m: make(map[string]int)}
// предзаполняем 1M ключей
for i := 0; i < 1_000_000; i++ {
sm.m[strconv.Itoa(i)] = i
}
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
if rand.Intn(10) == 0 {
sm.Set(strconv.Itoa(rand.Intn(1_000_000)), 42)
} else {
sm.Get(strconv.Itoa(rand.Intn(1_000_000)))
}
}
})
}
Результаты (ns/op, меньше — лучше):
RWMutex sync.Map
Read 99% / Write 1% 180 45 sync.Map в 4x быстрее
Read 90% / Write 10% 210 190 примерно равны
Read 50% / Write 50% 250 680 RWMutex в 2.7x быстрее
Write 100% (new keys) 230 1200 RWMutex в 5x быстрее
Вывод из бенчмарка: sync.Map выигрывает только при >95% чтений и стабильных ключах. При активной записи — проигрывает значительно.
Альтернативы: sharded map
Если RWMutex деградирует из-за contention, а sync.Map не подходит из-за частых записей — используйте шардированный map:
const numShards = 64
type ShardedMap struct {
shards [numShards]struct {
mu sync.RWMutex
m map[string]int
}
}
func (s *ShardedMap) getShard(key string) int {
h := fnv.New32a()
h.Write([]byte(key))
return int(h.Sum32()) % numShards
}
func (s *ShardedMap) Get(key string) (int, bool) {
shard := &s.shards[s.getShard(key)]
shard.mu.RLock()
defer shard.mu.RUnlock()
v, ok := shard.m[key]
return v, ok
}
Шардирование снижает contention в N раз (N = количество шардов). Библиотека github.com/puzpuzpuz/xsync предоставляет готовый xsync.MapOf[K, V] с generics и оптимальным шардированием.
Чеклист выбора
- sync.RWMutex + map — дефолтный выбор. Простой, типизированный, предсказуемый. Подходит для большинства случаев.
- sync.Map — кеш с редкой записью, конфиг, реестр синглтонов. Read >95%, ключи не меняются.
- Sharded map / xsync.MapOf — высокий contention на >16 ядрах, смешанная нагрузка read/write, нужна типизация.
- Каналы — если map является частью pipeline и доступ можно сериализовать через одну горутину-владельца.
Не оптимизируйте преждевременно. Начните с RWMutex, профилируйте через pprof, и только если видите contention на мьютексе — переходите к sync.Map или шардированию.