Привет, Хабр! Эта статья для тех, кто хочет понять, когда стоит использовать sync.Map, а когда достаточно обычной map с мьютексом.


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


Внутреннее устройство sync.Map


sync.Map — это потокобезопасная реализация мапы в Go, оптимизированная для определенных сценариев использования.


Основная структура sync.Map выглядит примерно так:


type Map struct {
    mu Mutex
    read atomic.Value // readOnly
    dirty map[interface{}]*entry
    misses int
}

type readOnly struct {
    m       map[interface{}]*entry
    amended bool
}

type entry struct {
    p unsafe.Pointer // *interface{}
}

Здесь мы видим несколько ключевых полей:


mu — мьютекс для защиты доступа к dirty мапе
read — атомарное значение, содержащее readOnly структуру
dirty — обычная Go мапа, содержащая все актуальные значения
misses — счетчик промахов при чтении из read мапы


Основная идея sync.Map заключается в использовании двух внутренних map: read (только для чтения) и dirty (для записи и чтения). Это позволяет оптимизировать операции чтения, которые часто не требуют блокировки.


Операция Load


При выполнении операции Load, sync.Map сначала пытается найти значение в read мапе. Если значение найдено, оно возвращается без какой-либо блокировки. Это очень быстрая операция.


Если значение не найдено в read мапе, увеличивается счетчик misses, и sync.Map проверяет dirty мапу, захватывая мьютекс. Если значение найдено в dirty мапе, оно возвращается.


Операция Store


При выполнении Store sync.Map сначала проверяет, существует ли ключ в read мапе. Если да, она пытается обновить значение атомарно. Если это не удаётся (например, ключ был удалён), она переходит к обновлению dirty мапы.
Если ключ не существует в read мапе, sync.Map захватывает мьютекс и обновляет dirty мапу.


Когда dirty заменяет read


Интересный момент происходит, когда количество промахов при чтении из read мапы (misses) превышает длину dirty мапы. В этом случае sync.Map выполняет операцию "продвижения":


  • Захватывается мьютекс
  • Содержимое dirty мапы копируется в новую read мапу
  • dirty мапа очищается
  • Счетчик misses сбрасывается

Это выглядит примерно так:


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
}

Такой подход позволяет адаптироваться к паттернам использования: если происходит много чтений после серии записей, dirty мапа продвигается в read, что ускоряет последующие операции чтения.


Сравнение производительности с map + RWMutex


Теперь давайте сравним производительность sync.Map с обычной map, защищенной sync.RWMutex
.
Обычная потокобезопасная мапа может выглядеть так:



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

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

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

Производительность этих двух подходов будет зависеть от конкретного сценария использования:


  • Если у вас преимущественно операции чтения, sync.Map может быть быстрее, особенно если ключи стабильны (мало добавлений новых ключей).
  • Если у вас много операций записи, особенно добавления новых ключей, map + RWMutex может показать лучшую производительность.
  • При небольшом количестве горутин, работающих с мапой, разница может быть незначительной, и простота map + RWMutex может быть предпочтительнее.
  • При большом количестве горутин, особенно на многоядерных системах, sync.Map может показать лучшую масштабируемость.

Заключение


sync.Map — это мощный инструмент в арсенале Go-разработчика, но это не серебряная пуля. Её внутреннее устройство оптимизировано под определённые сценарии использования, особенно когда есть много операций чтения и относительно мало записей.


Обычная map с RWMutex может быть более эффективной в сценариях с частыми записями или когда количество конкурирующих горутин невелико.


Статья написана по мотивам поста из канала Cross Join.

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


  1. flx0
    09.08.2024 07:45

    sync.Map очень противно использовать из-за полного отсутствия типобезопасности. Достаешь оттуда какие-то interface{}, и либо проверяешь их, либо рискуешь подорваться в рантайме. Казалось бы, уже давно есть генерики в языке, завезите в стандартную либу типобезопасный sync.Map!
    Хотя как по мне, потоко-небезопасная стандартная map в языке, где "поток" создается в две буквы - уже стрельба в ногу. Кто-то в своем модуле заиспользовал, а тебе потом это ловить в рантайме и все вызовы этого модуля мьютексами обмазывать...


    1. varanio Автор
      09.08.2024 07:45

      я точно видел пропозалы сделать типизированные sync.Map и прочие штуки