前言
上一篇博文我们讲解了Go语言中map的底层实现,然而就像Java中的HashMap是并发不安全的,Go语言的map也是不支持并发场景的。
如果要在并发场景下使用map,就需要用到我们即将介绍的sync.Map。
map的并发问题
首先,我们先来理解一下为什么Go语言的map会存在并发问题。
让我们从一个程序开始,说明这个问题。在下面这个程序中,我们开启了两个协程,其中1个负责读map,另一个负责修改map的值,而且他们修改的key不是同一个。
所以按理说,这个程序应该可以正常运行,我们可以尝试跑一下。
go
func main() {
hash := make(map[int]int, 10)
go func() {
for {
_ = hash[1]
}
}()
go func() {
for {
hash[2] = 2
}
}()
select {}
}
程序运行结束后,我们得到了下面的报错信息:
arduino
fatal error: concurrent map read and map write
可见,Go语言是不允许并发对map进行读和写的。从上一篇博文中我们知道,map是有扩容机制的。我们假设这样一种情况:A协程在桶中读数据时,读的是一个未被驱逐的老桶;恰好此时,B协程要修改这个桶的数据,按照扩容的原理,B协程就会顺便驱逐了这个老桶的数据。
这就可能导致A协程读到错误的老桶的数据或者找不到数据。
那怎么解决呢?一般来说,Map并发问题的解决方案有两种,第一种,直接对map加锁。这种方法效率比较低;第二种就是使用下面要介绍的sync.map
。
sync.Map的底层实现
打开sync/map.go
,可以看到sync.Map的实现代码如下:
go
type Map struct {
mu Mutex
read atomic.Pointer[readOnly]
dirty map[any]*entry
misses int
}
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
m map[any]*entry
amended bool // true if the dirty map contains some key not in m.
}
type entry struct {
p atomic.Pointer[any]
}
我们可以画图来表示整个sync.Map
的结构:
sync.Map的读写流程和追加流程,就是紧紧围绕着这个底层结构来的。
sync.Map的读写流程与追加流程
正常读写流程 (以m["a"] = "AAA"
为例子):
- 走read结构体,访问read里面的map,按照map的寻找算法找到key
- 找到entry指针指向的万能指针Pointer,修改Pointer指向的值。
追加流程 (以m["d"]="D"
为例子):
- 首先,访问read里面的map,发现read里面的map中没有这个key。
- 此时将
mu
上锁,锁住dirty map。 - 进入dirty map,追加新的key和value(其中value是一个万能指针Pointer)
- 将read的amended设置为true,表示read数据不完整。
即正常读写走read,追加走dirty并加锁。
追加后读写:
- 访问read里面的map,发现map中没有这个key并发现amended=true
- 访问dirty里面的map,读取对应的值。
- 将misses的值增加1,表示上面的值未命中一个。
- 随着线程的推进,misses会一直增加,如果
misses=len(dirty)
,需要做dirty提升 - dirty里面的map提升,变成新的read里面的map。同时dirty变成nil。misses=0。
- 要追加的时候会重建dirty,继续执行上述步骤
sync.Map的删除流程
sync.Map
的删除相比于查询、修改和新增,是最麻烦的。它可以分为正常删除和追加后删除。同时,删除后的key还需要经过特殊处理。
正常删除
当read中的map和dirty中的map是一样的时候,删除为正常删除。
- 找到read中的map,找到对应key
- 将key对应的value,即万能指针Pointer设置为nil
- GC会自动回收这个value
追加后删除
dirty还未提升到read,dirty刚刚追加了新的key,此时需要删除。
- 先从read中的map寻找,发现没有。
- 对
mu
加锁。 - 从dirty中的map寻找,找到对应的key的value,即万能指针Pointer设置为nil。
- dirty提升后,原先被删除的键不会被重建,其value被指向为
expunged
(表示这个键被删除,且在dirty中没有)。提醒协程如果要删除这个key,只需要直接删除即可,无须设置为nil。
总的来说,sync.Map做到了【读写】和【追加】分离。
小结
- map在扩容时候会有并发问题,主要表现在map扩容时会驱逐数据,造成协程之间读写不一致的问题。
- sync.Map底层使用了两个map,分离了扩容的问题。对于不会引发扩容的操作,例如查询和修改,使用read map进行。对于可能引发扩容的操作,比如新增,使用dirty map。
- 适用场景:sync.Map在写多读多但是追加少的情况下,性能比直接加锁的map好。