“我正在参加「掘金·启航计划」”
Sync.Map
Map 是并发安全的吗?
在上一篇文章《搞懂Go的map,哪需要写什么源码啊!!!》中,我们了解到了 Map 的基本实现原理。那如果在不加锁的前提下,Map 能否并发的读写呢?比如来看这一段代码,你认为可行吗?
func TestMap(t *testing.T) {
m := make(map[int]string)
// 写操作
go func() {
for {
m[2] = "写操作"
}
}()
// 读操作
go func() {
for {
_ = m[2]
}
}()
time.Sleep(2 * time.Second)
}
在上面的代码中,定义了一个 Map,用两个协程分别对这个 Map 同时进行写操作和读操作,看似井水不犯河水的两个协程,能够成功运行吗?来看看结果:
=== RUN TestMap
fatal error: concurrent map read and map write
# 其它异常信息.....
可以看到,这段程序出现了致命的错误。而且 Go 的 Runtime 很友好,它认为这个是一个很危险的操作,直接不让用户这样使用,还给出了友善的提醒:并发读写 Map 是一个致命的错误!!!
所以我们可以得到一个结论:在 Go 语言中,map 类型是并发不安全的。在多个 Goroutine 之间进行并发的读写操作时,如果没有适当的锁机制,可能会导致数据竞争和不一致性的问题。为了减少因用户不当使用而引发的并发问题,Go 语言可能会使程序强制退出。可 map 为什么是并发不安全的呢?
为什么呢?如何解决
这是因为 map 的内部实现涉及一系列复杂的操作,比如渐进驱逐式扩容,这些操作需要保证原子性和一致性。如果在没有锁机制的情况下,多个 Goroutine 并发执行这些操作,可能会破坏 map 的内部结构,导致严重的错误。
然而,并发读写操作是我们经常需要的。我们又该如何避免出现并发问题呢?
为了避免这种并发问题,通常有两种常见的思路。一种是使用诸如 Mutex 和 RWMutex
之类的锁,避免出现资源竞争。确保在同一时间只有一个 Goroutine 可以对 map 进行写操作。
比如将刚刚的代码添加上互斥锁 Mutex
,这样就不会有并发问题了。当然,对于读多写少的场景,也可以使用读写锁 RWMutex
。
func TestMap(t *testing.T) {
m := make(map[int]string)
// 准备一把互斥锁
var mu sync.Mutex
// 写操作
go func() {
for {
// 加锁后才能操作
mu.Lock()
m[2] = "写操作"
// 操作一次后释放锁
mu.Unlock()
}
}()
// 读操作
go func() {
for {
// 加锁后才能操作
mu.Lock()
_ = m[2]
// 操作一次后释放锁
mu.Unlock()
}
}()
time.Sleep(2 * time.Second)
}
虽然添加上了锁等同步机制,倒是不会有并发问题了,但是这会引入额外的锁开销。并且需要手动释放锁,稍有不慎可能也会导致死锁等问题。这里为了说明 map 并发读写的问题,业务很简单。若对于更复杂的业务来说,协程持锁的时机也是一大需要考虑的点。太短可能出现隐藏的问题,太长又会降低业务的吞吐量。
那么有没有更好的解决方案呢?对于这个问题,其实官方也给出了一种解决方案,它是如何解决的呢?
官方的解决方案
初识 sync.Map
对于这种常用的数据结构,Go 官方提供了 sync.Map
,它是一个并发安全的 map。使用 sync.Map 可以避免资源竞争等并发问题,而且不需要用户手动管理 map 并发读写的问题。
那么 sync.Map 是如何做到并发安全的呢?它的底层实现包括两个 Map、一把互斥锁、一个追加标记和一个未命中计数器,如下图所示:
上图所示的两个 Map 的底层,都是这样的键值对 map[any]*entry
,其中的键可以认为是任意的类型,值是一个 entry 指针
,也可认为是一个万能指针。而且要注意,这里的 read
字段,并不直接是一个 map,它是一个包含了设置追加状态的字段和上述 map 的结构体。所以如果更加细化一点,底层是这样表示的:
但对于上图,不必太过于纠结。只需要了解到,这两个 Map 它们引用的值是通过 entry 指针指向的内存,所以它们的 Value 是相同的。
如上图所示,两个 Map 最终引用的值其实是相同的。在下文中,方便表述,我将简称底层的两个 Map 为 readMap
和 dirtyMap
。但不代表它们是读写分离的两个 Map,更像是追加分离的两个 Map。下面我将省略许多 map 本身的操作细节,来看一下它是如何保证并发安全的。
若需要查看更多关于 map 本身的细节,可查看:
读取操作
在进行读取操作时,首先会访问 readMap。如果无法在其中找到键值对,则会检查该 Map 是否处于追加状态。如果是的话,会加锁并访问 dirtyMap,并将未命中计数器加一。如果在 dirtyMap 中仍然找不到,则表示键不存在。只要在其中一个 Map 中找到了对应的键,那么就将其底层引用的值返回。
把上述思路用图片描述出来:
在上图中可以看到,首先会访问 readMap,若找不到则尝试去 dirtyMap 中查找。但是想要访问 dirtyMap,必须要上锁保证安全。并且能来到 dirtyMap 中,说明在 readMap 中没有找到对应的 key,也就是需要将未命中的计数器 +1。那这个计数器用来干嘛的呢?
不论什么操作,只要访问了 dirtyMap,就会使未命中计数器 + 1,计数增加后就会检查 dirtyMap 的长度是否与未命中次数相等。如果相等,则说明 dirtyMap 中有太多的键不在 readMap 中,需要加锁来继续查找。因此会直接将 dirtyMap 提升为 readMap,并且暂时将 dirtyMap 设置为 nil。
如上图所示,上面的 misses 和 dirtyMap 的长度
都等于 4 了,那么此时意味着在 readMap 未命中 4 次了,可能有很多键或者访问很频繁的键在 dirtyMap
中,每次来访问 dirtyMap 都会加锁,开销较大,那么直接将 dirtyMap 提升为 readMap,并短暂的将 dirtyMap 置为 nil
。
提升后 dirtyMap 为 nil 了,什么时候会重建 dirtyMap 呢?在此之前,先来看看插入和删除的操作。
插入和删除
如果熟悉了查找的流程,插入和删除其实就简单了。
Store(key, value)
在进行插入和更新操作时,也会先进行查找。如果在 readMap 中找到了键,则说明该键以前存在,只需要覆盖其底层引用的值即可。如果在 readMap 中未找到,则将未命中计数器加一。如果当前已经处于追加状态,则会加锁去 dirtyMap 中查找该键。如果找到了,则直接覆盖其值,否则添加一个键值对。如果不是追加状态,则会基于当前的 readMap 重新构建 dirtyMap,并将 readMap 中值为 nil 的键设置为特殊的删除标记。然后将键值对插入 dirtyMap 中。
把上述思路用图片描述出来:
看完了思路,来看一个例子:
在上图中,添加了 K2 和 K5
两个键。K2 是原先就存在并且能直接在 readMap 中找到,那么直接覆盖它底层引用的值即可。K5 是新添加的建,在 readMap 中找不到时,检查状态加锁尝试去 dirtyMap 中查找,遗憾的是 dirtyMap 中也没有,那么新建一个键值对插入 dirtyMap 中。并且将未命中计数器 +1。
趁热打铁,再来看看删除又是怎样操作的。
Delete(key)
对于删除操作,同样会先进行查找。如果在 readMap 中找到了键,则删除其底层引用的值。但删除时发现值为 nil 或 expunged
,说明已经被删除过,直接返回即可。否则,将值设置为 nil 后返回。
exunged 是一个任意指针,用于标记已从 dirtyMap 中删除的条目,可能在重建 dirtyMap 时被设置。
定义如下:var expunged = unsafe.Pointer(new(any))
如果在 readMap 中未找到键,且该 Map 处于追加状态,则会加锁尝试从 dirtyMap 中删除该键。只要确认键不在 readMap 中且处于追加状态,就会从 dirtyMap 中获取该键,无论成功与否,都会尝试删除 dirtyMap 中的键和值,并将未命中计数器加一。最后只要在两个 Map 的其中一个查找到该键时,会再次尝试删除其值。与在 readMap 中找到了键的删除操作相同。
把上述思路用图片描述出来:
看完了思路,来看一个例子:
删除 K2 时,直接从 readMap 中获取到了对应的键,将其值设置为 nil
即可。
而删除 K5 时,在 readMap 中未查找到,并且此时的 Map 是追加状态,那么加锁后再次尝试从 readMap 中获取 K5,并且再次判断是否处于追加状态。这里做了一个 double check
,因为防止有协程获取到互斥锁前,已经将 dirtyMap 提升为 readMap 了。在这里第二次 check 还是没有从 readMap 中获取到 K5。那么尝试从 dirtyMap 中获取 K5 这个 key,然后直接使用 delete()
关键字删除 K5 和 *entry5
这个键值对。
Expunged
作完上述操作后,还需要将未命中计数器 +1,现在的计数变为 4,与 dirtyMap 的长度又相等了,那么需要将其 dirtyMap 提升为 readMap,变成了如下所示的结构:
提升的操作在上面已经了解了,这里不再赘述了。刚才删除的时候,我们有提到一种特殊的删除标记 expunged
,这种标记如何产生呢?当我们再添加一个键值对,看看会发生什么:
从图中可以看到,我们添加了 K6 和 V6
这个键值对,首先访问 readMap,发现不存在 K6 这个键,并且此时 Map 不处于追加状态。那么将其设置为 追加状态后,开始利用 readMap 重建 dirtyMap。其实就是新建一个 dirtyMap,然后遍历 readMap将其赋值。在遍历时会查看每一个键对应的值是否为 nil
,为 nil 则将其 value 标记为 expunged
,并且不添加到 dirtyMap 中。否则将其添加到 dirtyMap 里面。 当重建好 dirtyMap 后,添加操作其实就比较简单了,我这里不再赘述,可以看看前面的章节。
并不是读写分离
了解了上述操作,可发现,只要访问到 dirtyMap,都会加锁并使未命中计数器加一。删除和覆盖操作也有可能直接在 readMap 中进行。因此,并不能说 sync.Map 的底层是完全的读写分离的两个 Map,而更像是追加分离的 Map,并且随着追加的数量增加,会将 dirtyMap 提升为 readMap。
其核心思想是有 readMap 和 dirtyMap
两条快慢路径。因为操作 readMap 不需要加锁,所以是快路径;而在 dirtyMap 中的操作,都需要加锁,会有额外的开销,所以是慢路径。
但不论什么操作,都会先尝试在 readMap 这条快速路径中完成。如果完成不了,那么再到 dirtyMap 这条慢路径去完成。并且在 sync.Map 中,真正添加和删除键值对的操作只会在 dirtyMap 中做,那么底层 map 的扩容也就只会在 dirtyMap 中做。而在操作 dirtyMap 的时候,首先就会将它保护起来,所以不会产生竞争。
最后用一幅图结束本篇的旅程:
本篇收获
- map 是并发读写是不安全的,需要使用类似于锁的同步机制保证并发安全。
- 也可以使用官方提供的 sync.Map,它是一个并发安全的 Map。
- sync.Map 底层有两个 Map、一把锁、一个未命中计数器和是否处于追加状态的字段。
- 它的两个 Map 并不是读写分离的 Map,但可以说是追加分离的两个 Map。
- 在 sync,Map 中的操作,会优先考虑快速的 readMap,如果操作不了,才会尝试去 dirtyMap 中操作。
- 必须要获取锁后才能访问 dirtyMap,并且访问一次就需要将 misses 计数器 +1。
- 当
misses == len(dirtyMap)
时,会将 dirtyMap 提升为 readMap,并且短暂的将 dirtyMap 设置为 nil。 - 若需要尝试去 dirtyMap 中操作,但发现 dirtyMap 为 nil 时,会利用 readMap 重建 dirtyMap。