Go 图解sync.Map

665 阅读4分钟

前言

无法并发的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) // 防止退出进程
}

image.png

上面程序开了读协程和写协程,于是触发报错:并发映射读取和映射写入,说明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 // 未命中数
}

看图更容易理解

image.png

整体架构

整理一下源码结构,得出一张图

image.png

read结构成员一个是m,作为读写的map;一个是amended,表示m储存的数据是否完整。

dirty直接是一个map,用于追加和读写数据。

接下来看下读写和追加操作时sync.Map是怎么运作的。

正常读写

image.png

例如读取键值 "b" 时,从read这个map寻找,找到entry的结构体指针,里面存放着Pointer,于是返回Pointer指针所指向的值"B"

更新操作也是如此,找到Pointer,将指针的值更新。

追加数据

image.png

例如要追加键值对"d":"D",首先从read进入,去查找有无"d"这个键,若没有,它便知道要追加该数据了。于是回到Map,把mu给锁上(解决并发问题),再对dirty这个map操作,将键值对加入dirty中。

结果如下:

image.png

追加完后,注意,read中的amended赋值成了true,表示m中储存的数据已经不完整了,read找不到数据时要去dirty中找了。

追加后读写

一旦amended=true,读写情况就要改变一下了

image.png

如上图要读取"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提升

image.png

之后还得还原状态,将amended=false, misses=0image.png

等到下次追加数据时,发现dirty为空,则会拷贝read的map,恢复最原始状态,如下图所示。

image.png

正常删除

删除操作有一些地方要注意的。

image.png

正常删除会到read去寻找,找到后并不是删除entry,而是将指针Pointer变为nil,值"C"由于没有指针指向它,会被垃圾回收给回收掉。

追加后删除

若要删除"d",这时候read的map中没有数据且amended=true,则会走下图流程

image.png

同样回到Map,将mu上锁,进入dirty,然后将Pointer指针置为nil

结果都是和正常删除一样,那有什么区别呢?区别在于dirty提升重建dirty的时候。

image.png

dirty提升时,会将nil的Pointer指针标记为expungedexpunged为删除的意思。

而当dirty要拷贝read的map时,就会过滤掉expunged的数据,不拷贝,节约了内存空间。

感悟

从sync.Map的实现我们可以类比下,如果自己定义的某个数据结构存在并发问题,则可以在外包装一层带有锁的大结构体,然后只针对存在并发问题的地方进行加锁,只读操作就使用正常方法,这样既能保证提高性能,又能保证并发安全。

以上即是sync.Map的解析过程,如有疑问或错误的地方,请指教。