咱们知道,go 里边供给了 map 这种类型让咱们能够存储键值对数据,可是假如咱们在并发的状况下运用 map 的话,就会发现它是不支持并发地进行读写的(会报错)。
在这种状况下,咱们能够运用 sync.Mutex 来确保并发安全,可是这样会导致咱们在读写的时分,都需求加锁,这样就会导致功能的下降。
除了运用互斥锁这种相对低效的办法,咱们还能够运用 sync.Map 来确保并发安全,它在某些场景下有比运用 sync.Mutex 更高的功能。
本文就来探讨一下 sync.Map 中的一些咱们比较感兴趣的问题,比方为什么有了 map 还要 sync.Map?它为什么快?sync.Map 的适用场景(留意:不是一切状况下都快。)等。

关于 sync.Map 的设计与完成原理,会鄙人一篇中再做讲解。

map 在并发下的问题

假如咱们看过 map 的源码,就会发现其间有不少会引起 fatal 错误的地方,比方 mapaccess1(从 map 中读取 key 的函数)里边,假如发现正在写 map,则会有 fatal 错误。
(假如还没看过,能够跟着这篇 《go map 设计与完成》 看一下)

if h.flags&hashWriting != 0 {
    fatal("concurrent map read and map write")
}

map 并发读写异常的比如

下面是一个实践运用中的比如:

var m = make(map[int]int)
// 往 map 写 key 的协程
go func() {
   // 往 map 写入数据
    for i := 0; i < 10000; i++ {
        m[i] = i
    }
}()
// 从 map 读取 key 的协程
go func() {
   // 从 map 读取数据
    for i := 10000; i > 0; i-- {
        _ = m[i]
    }
}()
// 等候两个协程履行完毕
time.Sleep(time.Second)

这会导致报错:

fatal error: concurrent map read and map write

这是因为咱们一起对 map 进行读写,而 map 不支持并发读写,所以会报错。假如 map 答应并发读写,那么或许在咱们运用的时分会有很多紊乱的状况出现。
(具体如何紊乱,咱们能够比照多线程的场景考虑一下,本文不展开了)。

运用 sync.Mutex 确保并发安全

对于 map 并发读写报错的问题,其间一种解决方案便是运用 sync.Mutex 来确保并发安全,
可是这样会导致咱们在读写的时分,都需求加锁,这样就会导致功能的下降。

运用 sync.Mutex 来确保并发安全,上面的代码能够改成下面这样:

var m = make(map[int]int)
// 互斥锁
var mu sync.Mutex
// 写 map 的协程
go func() {
    for i := 0; i < 10000; i++ {
        mu.Lock() // 写 map,加互斥锁
        m[i] = i
        mu.Unlock()
    }
}()
// 读 map 的协程序
go func() {
    for i := 10000; i > 0; i-- {
        mu.Lock() // 读 map,加互斥锁
        _ = m[i]
        mu.Unlock()
    }
}()
time.Sleep(time.Second)

这样就不会报错了,可是功能会有所下降,因为咱们在读写的时分都需求加锁。(假如需求更高功能,能够继续读下去,不要急着运用 sync.Mutex

sync.Mutex 的常见的用法是在结构体中嵌入 sync.Mutex,而不是定义独立的两个变量。

运用 sync.RWMutex 确保并发安全

在上一小节中,咱们运用了 sync.Mutex 来确保并发安全,可是在读和写的时分咱们都需求加互斥锁。
这就意味着,就算多个协程进行并发读,也需求等候锁
可是互斥锁的粒度太大了,但实践上,并发读是没有什么太大问题的,应该被答应才对,假如咱们答应并发读,那么就能够进步功能

当然 go 的开发者也考虑到了这一点,所以在 sync 包中供给了 sync.RWMutex,这个锁能够答应进行并发读,可是写的时分仍是需求等候锁。
也便是说,一个协程在持有写锁的时分,其他协程是既不能读也不能写的,只能等候写锁开释才干进行读写

运用 sync.RWMutex 来确保并发安全,咱们能够改成下面这样:

var m = make(map[int]int)
// 读写锁(答应并发读,写的时分是互斥的)
var mu sync.RWMutex
// 写入 map 的协程
go func() {
    for i := 0; i < 10000; i++ {
        // 写入的时分需求加锁
        mu.Lock()
        m[i] = i
        mu.Unlock()
    }
}()
// 读取 map 的协程
go func() {
    for i := 10000; i > 0; i-- {
        // 读取的时分需求加锁,可是这个锁是读锁
        // 多个协程能够一起运用 RLock 而不需求等候
        mu.RLock()
        _ = m[i]
        mu.RUnlock()
    }
}()
// 别的一个读取 map 的协程
go func() {
    for i := 20000; i > 10000; i-- {
        // 读取的时分需求加锁,可是这个锁是读锁
        // 多个协程能够一起运用 RLock 而不需求等候
        mu.RLock()
        _ = m[i]
        mu.RUnlock()
    }
}()
time.Sleep(time.Second)

这样就不会报错了,并且功能也进步了,因为咱们在读的时分,不需求等候锁。

阐明:

  • 多个协程能够一起运用 RLock 而不需求等候,这是读锁。
  • 只要一个协程能够运用 Lock,这是写锁,有写锁的时分,其他协程不能读也不能写。
  • 持有写锁的协程,能够运用 Unlock 来开释锁。
  • 写锁开释之后,其他协程才干获取到锁(读锁或许写锁)。

也便是说,运用 sync.RWMutex 的时分,读操作是能够并发履行的,可是写操作是互斥的。
这样一来,比较 sync.Mutex 来说等候锁的次数就少了,天然也就能取得更好的功能了。

gin 框架里边就运用了 sync.RWMutex 来确保 Keys 读写操作的并发安全。

有了读写锁为什么还要有 sync.Map?

经过上面的内容,咱们知道了,有下面两种办法能够确保并发安全:

  • 运用 sync.Mutex,可是这样的话,读写都是互斥的,功能欠好。
  • 运用 sync.RWMutex,能够并发读,可是写的时分是互斥的,功能相对 sync.Mutex 要好一些。

可是就算咱们运用了 sync.RWMutex,也仍是有一些锁的开支。那么咱们能不能再优化一下呢?答案是能够的。那便是运用 sync.Map

sync.Map 在锁的基础上做了进一步优化,在一些场景下运用原子操作来确保并发安全,功能更好。

运用原子操作代替读锁

可是就算运用 sync.RWMutex,读操作依然还有锁的开支,那么有没有更好的办法呢?
答案是有的,便是运用原子操作来代替读锁。

举一个很常见的比如便是多个协程一起读取一个变量,然后对这个变量进行累加操作:

var a int32
var wg sync.WaitGroup
wg.Add(2)
go func() {
    for i := 0; i < 10000; i++ {
        a++
    }
    wg.Done()
}()
go func() {
    for i := 0; i < 10000; i++ {
        a++
    }
    wg.Done()
}()
wg.Wait()
// a 希望成果应该是 20000才对。
fmt.Println(a) // 实践:17089,并且每次都不一样

这个比如中,咱们希望的成果是 a 的值是 20000,可是实践上,每次运转的成果都不一样,并且都不会等于 20000
其间很简单粗犷的一种解决办法是加锁,可是这样的话,功能就欠好了,可是咱们能够运用原子操作来解决这个问题:

var a atomic.Int32
var wg sync.WaitGroup
wg.Add(2)
go func() {
    for i := 0; i < 10000; i++ {
        a.Add(1)
    }
    wg.Done()
}()
go func() {
    for i := 0; i < 10000; i++ {
        a.Add(1)
    }
    wg.Done()
}()
wg.Wait()
fmt.Println(a.Load()) // 20000

锁跟原子操作的功能差多少?

咱们来看一下,运用锁和原子操作的功能差多少:

func BenchmarkMutexAdd(b *testing.B) {
   var a int32
   var mu sync.Mutex
   for i := 0; i < b.N; i++ {
      mu.Lock()
      a++
      mu.Unlock()
   }
}
func BenchmarkAtomicAdd(b *testing.B) {
   var a atomic.Int32
   for i := 0; i < b.N; i++ {
      a.Add(1)
   }
}

成果:

BenchmarkMutexAdd-12       100000000          10.07 ns/op
BenchmarkAtomicAdd-12      205196968           5.847 ns/op

咱们能够看到,运用原子操作的功能比运用锁的功能要好一些。

也许咱们会觉得上面这个比如是写操作,那么读操作呢?咱们来看一下:

func BenchmarkMutex(b *testing.B) {
   var mu sync.RWMutex
   for i := 0; i < b.N; i++ {
      mu.RLock()
      mu.RUnlock()
   }
}
func BenchmarkAtomic(b *testing.B) {
   var a atomic.Int32
   for i := 0; i < b.N; i++ {
      _ = a.Load()
   }
}

成果:

BenchmarkMutex-12      100000000          10.12 ns/op
BenchmarkAtomic-12     1000000000          0.3133 ns/op

咱们能够看到,运用原子操作的功能比运用锁的功能要好很多。并且在 BenchmarkMutex 里边甚至还没有做读取数据的操作。

sync.Map 里边的原子操作

sync.Map 里边比较 sync.RWMutex,功能更好的原因便是运用了原子操作。
在咱们从 sync.Map 里边读取数据的时分,会先运用一个原子 Load 操作来读取 sync.Map 里边的 key(从 read 中读取)。
留意:这里拿到的是 key 的一份快照,咱们对其进行读操作的时分也能够一起往 sync.Map 中写入新的 key,这是确保它高功能的一个很要害的设计(相似读写分离)。

sync.Map 里边的 Load 办法里边就包含了上述的流程:

// Load 办法从 sync.Map 里边读取数据。
func (m *Map) Load(key any) (value any, ok bool) {
   // 先从只读 map 里边读取数据。
   // 这一步是不需求锁的,只要一个原子操作。
   read := m.loadReadOnly()
   e, ok := read.m[key]
   if !ok && read.amended { // 假如没有找到,并且 dirty 里边有一些 read 中没有的 key,那么就需求从 dirty 里边读取数据。
      // 这里才需求锁
      m.mu.Lock()
      read = m.loadReadOnly()
      e, ok = read.m[key]
      if !ok && read.amended {
         e, ok = m.dirty[key]
         m.missLocked()
      }
      m.mu.Unlock()
   }
   // key 不存在
   if !ok {
      return nil, false
   }
   // 运用原子操作读取
   return e.Load()
}

上面的代码咱们或许还看不懂,可是没关系,这里咱们只需求知道的是,从 sync.Map 读取数据的时分,会先做原子操作,假如没找到,再进行加锁操作,这样就削减了运用锁的频率了,天然也就能够取得更好的功能(但要留意的是并不是一切状况下都能取得更好的功能)。至于具体完成,鄙人一篇文章中会进行更加具体的剖析。

也便是说,sync.Map 之所以更快,是因为比较 RWMutex,进一步削减了锁的运用,而这也便是 sync.Map 存在的原因了

sync.Map 的根本用法

现在咱们知道了,sync.Map 里边是利用了原子操作来削减锁的运用。可是咱们如同连 sync.Map 的一些根本操作都还不了解,现在就让咱们再来看看 sync.Map 的根本用法。

sync.Map 的运用仍是挺简单的,map 中有的操作,在 sync.Map 都有,只不过区别是,在 sync.Map 中,一切的操作都需求经过调用其办法来进行。
sync.Map 里边几个常用的办法有(CRUD):

  • Store:咱们新增或许修正数据的时分,都能够运用 Store 办法。
  • Load:读取数据的办法。
  • Range:遍历数据的办法。
  • Delete:删去数据的办法。
var m sync.Map
// 写入/修正
m.Store("foo", 1)
// 读取
fmt.Println(m.Load("foo")) // 1 true
// 遍历
m.Range(func(key, value interface{}) bool {
    fmt.Println(key, value) // foo 1
    return true
})
// 删去
m.Delete("foo")
fmt.Println(m.Load("foo")) // nil false

留意:在 sync.Map 中,keyvalue 都是 interface{} 类型的,也便是说,咱们能够运用恣意类型的 keyvalue
而不像 map,只能存在一种类型的 keyvalue。从这个视点来看,它的类型相似于 map[any]any

别的一个需求留意的是,Range 办法的参数是一个函数,这个函数假如回来 false,那么遍历就会停止。

sync.Map 的运用场景

sync.Map 源码中,已经告知了咱们 sync.Map 的运用场景:

The Map type is optimized for two common use cases: (1) when the entry for a given
key is only ever written once but read many times, as in caches that only grow,
or (2) when multiple goroutines read, write, and overwrite entries for disjoint
sets of keys. In these two cases, use of a Map may significantly reduce lock
contention compared to a Go map paired with a separate Mutex or RWMutex.

翻译过来便是,Map 类型针对两种常见用例进行了优化:

  • 当给定 key 的条目只写入一次但读取屡次时,如在只会增长的缓存中。(读多写少)
  • 当多个 goroutine 读取、写入和掩盖不相交的键集的条目。(不同 goroutine 操作不同的 key)

在这两种状况下,与 Go map 与独自的 MutexRWMutex 配对比较,运用 sync.Map 能够明显削减锁竞赛(很多时分只需求原子操作就能够)。

总结

  • 普通的 map 不支持并发读写。
  • 有以下两种办法能够完成 map 的并发读写:
    • 运用 sync.Mutex 互斥锁。读和写的时分都运用互斥锁,功能比较 sync.RWMutex 会差一些。
    • 运用 sync.RWMutex 读写锁。读的锁是能够共享的,可是写锁是独占的。功能比较 sync.Mutex 会好一些。
  • sync.Map 里边会先进行原子操作来读取 key,假如读取不到的时分,才会需求加锁。所以功能比较 sync.Mutexsync.RWMutex 会好一些。
  • sync.Map 里边几个常用的办法有(CRUD):

    • Store:咱们新增或许修正数据的时分,都能够运用 Store 办法。
    • Load:读取数据的办法。
    • Range:遍历数据的办法。
    • Delete:删去数据的办法。
  • sync.Map 的运用场景,sync.Map 针对以下两种场景做了优化:

    • key 只会写入一次,可是会被读取屡次的场景。
    • 多个 goroutine 读取、写入和掩盖不相交的键集的条目。