Внутреннее устройство sync.Map, сравнение производительности с map + RWMutex
- пятница, 9 августа 2024 г. в 00:00:05
Привет, Хабр! Эта статья для тех, кто хочет понять, когда стоит использовать sync.Map, а когда достаточно обычной map с мьютексом.
В Каруне этот вопрос иногда возникал на код ревью, поэтому такая статья мне показалась полезной. TLDR: 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
, sync.Map
сначала пытается найти значение в read
мапе. Если значение найдено, оно возвращается без какой-либо блокировки. Это очень быстрая операция.
Если значение не найдено в read
мапе, увеличивается счетчик misses
, и sync.Map
проверяет dirty
мапу, захватывая мьютекс. Если значение найдено в dirty
мапе, оно возвращается.
При выполнении 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, что ускоряет последующие операции чтения.
Теперь давайте сравним производительность 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.