lenec ru

← все посты

Go sync.Map vs RWMutex: когда какой примитив синхронизации выбрать

13K

Если вы работали с 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 или шардированию.

Комментарии 0

  • Будьте первым, кто оставит комментарий.

Войдите, чтобы оставить комментарий.