前言
无法并发的map
map是程序员常用且喜爱的一种数据结构,能够实现读写为O(1)时间复杂度的功能,但可惜在Go中,map不能用于并发,在并发中使用会引发编译器的报错。
写个例子验证下:
func main() {
m := make(map[string]string)
// 写协程
go func() {
for {
m["key"] = "value"
}
}()
// 读协程
go func() {
for {
_ = m["key"]
}
}()
time.Sleep(20) // 防止退出进程
}
上面程序开了读协程和写协程,于是触发报错:并发映射读取和映射写入,说明map是无法并发使用的。
无法并发原因
(map结构不了解的可先查阅资料)
简单讲下,map在扩容时会产生新桶和旧桶,在更新map数据时,会将旧桶的数据迁移到新桶上。
若并发中有两协程A和B操作map,就可能出现以下情形:
协程A在读取旧桶数据时,协程B已执行驱逐数据的操作,将旧桶的数据迁移到了新桶,旧桶就没有数据了。这样就导致了协程A读取的数据错误。
如何解决
并发问题一般都是加锁嘛,Go就设计了一种结构,通过加锁来避免并发问题,即sync.Map
sync.Map
解决思路
map由于扩容而容易在并发中出现问题,而扩容是由于追加数据引起的,map的读写都不会引发扩容。 因此sync.Map使用了两个map,一个专门用于读写,一个用于追加数据时使用,在追加数据时会上锁。 这样的设计思想使得读写能有较高的性能,而追加数据时上了锁能解决并发问题。
sync.Map的结构
了解了原理,现在来看下源码结构
type Map struct {
mu Mutex // 锁
read atomic.Value // 实际指向readOnly,只用于读写
dirty map[any]*entry // 脏map,追加或读写操作使用此map
misses int // 未命中数
}
看图更容易理解
整体架构
整理一下源码结构,得出一张图
read结构成员一个是m,作为读写的map;一个是amended,表示m储存的数据是否完整。
dirty直接是一个map,用于追加和读写数据。
接下来看下读写和追加操作时sync.Map是怎么运作的。
正常读写
例如读取键值 "b" 时,从read这个map寻找,找到entry的结构体指针,里面存放着Pointer,于是返回Pointer指针所指向的值"B"。
更新操作也是如此,找到Pointer,将指针的值更新。
追加数据
例如要追加键值对"d":"D",首先从read进入,去查找有无"d"这个键,若没有,它便知道要追加该数据了。于是回到Map,把mu给锁上(解决并发问题),再对dirty这个map操作,将键值对加入dirty中。
结果如下:
追加完后,注意,read中的amended赋值成了true,表示m中储存的数据已经不完整了,read找不到数据时要去dirty中找了。
追加后读写
一旦amended=true,读写情况就要改变一下了
如上图要读取"d"数据,先走read,发现m中没有数据并且amended=true,于是将Map中的mu上锁,进入dirty中寻找,找到"D"后返回结果。之后misses+1,misses表示read读取数据未命中的个数。
dirty提升
当misses一直增加,直到等于dirty的长度时,Map就忍不了了。你想想,经常读取数据都得走read,而read中又没有想要的数据,还得走一趟dirty,路程就变长了,搞得read都没什么意义了。于是便将read的map重新指向dirty的map,而dirty指向nil,这过程就叫dirty提升。
之后还得还原状态,将amended=false, misses=0。
等到下次追加数据时,发现dirty为空,则会拷贝read的map,恢复最原始状态,如下图所示。
正常删除
删除操作有一些地方要注意的。
正常删除会到read去寻找,找到后并不是删除entry,而是将指针Pointer变为nil,值"C"由于没有指针指向它,会被垃圾回收给回收掉。
追加后删除
若要删除"d",这时候read的map中没有数据且amended=true,则会走下图流程
同样回到Map,将mu上锁,进入dirty,然后将Pointer指针置为nil。
结果都是和正常删除一样,那有什么区别呢?区别在于dirty提升和重建dirty的时候。
dirty提升时,会将nil的Pointer指针标记为expunged,expunged为删除的意思。
而当dirty要拷贝read的map时,就会过滤掉expunged的数据,不拷贝,节约了内存空间。
感悟
从sync.Map的实现我们可以类比下,如果自己定义的某个数据结构存在并发问题,则可以在外包装一层带有锁的大结构体,然后只针对存在并发问题的地方进行加锁,只读操作就使用正常方法,这样既能保证提高性能,又能保证并发安全。
以上即是sync.Map的解析过程,如有疑问或错误的地方,请指教。