Привет, Хабр! Эта статья для тех, кто хочет понять, когда стоит использовать 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.
flx0
sync.Map очень противно использовать из-за полного отсутствия типобезопасности. Достаешь оттуда какие-то
interface{}
, и либо проверяешь их, либо рискуешь подорваться в рантайме. Казалось бы, уже давно есть генерики в языке, завезите в стандартную либу типобезопасный sync.Map!Хотя как по мне, потоко-небезопасная стандартная map в языке, где "поток" создается в две буквы - уже стрельба в ногу. Кто-то в своем модуле заиспользовал, а тебе потом это ловить в рантайме и все вызовы этого модуля мьютексами обмазывать...
varanio Автор
я точно видел пропозалы сделать типизированные sync.Map и прочие штуки