golang

Внутреннее устройство sync.Map, сравнение производительности с map + RWMutex

  • пятница, 9 августа 2024 г. в 00:00:05
https://habr.com/ru/companies/karuna/articles/834400/

Привет, Хабр! Эта статья для тех, кто хочет понять, когда стоит использовать 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.