为什么说并发场景不要乱用sync.map

7,089 阅读7分钟

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,2万元奖池等你挑战!

map 本身并发不安全的

我们都知道go的map是并发不安全的,当几个goruotine同时对一个map进行读写操作时,就会出现并发写问题fatal error: concurrent map writes

carbon-7.png

  1. 在程序一开始我们初始化一个map
  2. 子goroutine对m[a]赋值
  3. 主goroutine对m[a]赋值 理论上只要在多核cpu下,如果子goroutine和主gouroutine同时在运行,就会出现问题。我们不妨用go自带的-race 来检测下,可以运行 go run -race main.go

carbon-8.png 通过检测,我们可以发现,存在data race,即数据竞争问题。有人说这简单,加锁解决,加锁固然可以解决,但是你懂的,锁的开销问题。
撇开数据竞争的问题,我们可以通过看个例子来了解下锁的开销:

carbon-9.png

  1. BenchmarkAddMapWithUnLock 是测试无锁的
  2. BenchmarkAddMapWithLock 是测试有锁的 通过go test -bench .来跑测试,得出的结果如下:

carbon (22).png 可以发现无锁的平均耗时约6.6 ms,带锁的平均耗时约7.0 ms,虽说相差无几,但也反应加锁的开销。在一些复杂的案例中,可能会更明显。

sync.map

有人说,既然锁开销大,那么就用go内置的方法sync.map,它可以解决并发问题。sync.map确实可以解决并发map问题,但是它在读多写少的情况下,比较适合,可以保证并发安全,同时又不需要锁的开销,在写多读少的情况下反而可能会更差,主要是因为它的设计,我们从源码分析看看:

结构

carbon (23).png

  1. mutex锁,当涉及到脏数据(dirty)操作时候,需要使用这个锁
  2. read,读不需要加锁,就是从read中读的,read是atomic.Value类型,具体结构如下:

carbon (25).png read的数据存在readOnly.m中,也是个map,value是个entry的指针,entry是个结构体具体类型如下:

carbon (26).png 里面就一个p,当我们设置一个key的value时,可以理解为p就是指向这个value的指针(p就是value的地址)。
readOnly.amended = true的时候,表示read的数据不是最新的,dirty里面包含一些read没有的新key。
3. Map的dirty也是map类型,从命名来看它是脏的,可以理解某些场景新加kv的时候,会先加到dirty中,它比read要新。
4. Map的misses,当从read中没读到数据,且amended=true的时候,会尝试从dirty中读取,并且misses会加1,当misssed数量大于等于dirty的长度的时候,就会把dirty赋给read,同时重置missed和dirty。

举个例子

sync.map的核心思想就是空间换时间。
假设现在有个画展对外展示(read)n幅画,一群人来看,大家在这个画展上想看什么就看什么,不用等待、不用排队。这时上了副新画,但是由于画展现在在工作时间,不能直接挂上去,而且新画可能还要保养什么,暂时不放在画展(read)上,于是就先放在备份的仓库中(dirty),如果真有人要看这幅新画,那么只能领他到仓库中(dirty)中去看,假设这时来了个新画,此时仓库中有n+1副画了,这时有人来问:有没有这幅新画呀,经理说:有,你和我到仓库中去看下。这时又有人来问:有没有这幅新画呀,经理说:有,你和我到仓库中去看下。当问有没有这幅新画的次数达到了n+1的时候,这时画展的老板发现这幅新画要看的人还不少。于是对经理说:你去看下,等下没人看画展(read)的时候,把画展(read)的画全部下掉,把仓库(dirty)里面的画全部换上。当经理全部换结束后,此时画展(read)上已经是最全最新的画了。
sync.map的原理大概就类似上面的例子,在少量人对新画(新的k、v)感兴趣的时候,就带他去仓库(dirty)看,此时因为经理只有一个,所以每次只能带一个人(加锁),效率低,其他的画,在画展(read)上,随便看,效率高。

Store (新增或者更新一个kv)

carbon (27).png

  1. 当key存在read的时候,那么此时就是更新value,尝试去直接更新value,更新成功了就返回,不需要加锁。这里面有个tryStore:

carbon (29).png tryStore里面有判断p == expunged就返回false。p有三种类型:nil(read中的key被delete的时候其实软删除,只是把p设置成nil)、expunged(被删除的key(p==nil)会在read copy 到 dirty的时候再被设置成expunged)、其他正常的value的地址,这里如果是expunged就不选择更新value。

  1. 加锁,接下来都是线程安全的。
  2. 加锁的过程可能原本不存在的key,加完锁有了,所以要再check下,如果read中存在,且本来被dirty删除了,那么在dirty中还原下key,最后设置value。
  3. 如果read中没有key,但是dirty中有,那么直接修改value
  4. 如果read和dirty中都没有这个key,且dirty为nil的时候,尝试把read中未删除的copy到dirty中去,(read中删除不是真的删除,会把entry.p设置为nil,简单理解就是把key的value的地址设置为nil),这些都是在dirtyLocked中完成的:

carbon (30).png 然后在dirty中设置新的k、v。(这里可以发现新的k、v都是先加在dirty的map中的,read是没有的)。
6. 现在dirty是比较干净的数据了(已经清空了nil或expunged的key),设置amended=true(说明此时dirty不为空,且dirty中有新数据)
7. 解锁
总结:

  1. 可以发现对于更新,read和dirty因为value是指针,底层是一个value,这样都会被更新
  2. 对于新增的,会先加在dirty中,read中并不会新增
  3. 对于新增是要加锁的,所以假设存在一种极端的case:一直加新key,那么每次都是要加锁的,何况中间还有if else的分支判断。整体肯定是比常规map加锁性能要差的。

Load(获取一个kv)

carbon (28).png

  1. 当read中不存在这个key,且amenbed=true的时候(通过上面的store,说明此时dirty有新数据),加锁(dirty不是线程安全的)
  2. 因为加锁的过程,可能read发生变化,所以再次check下
  3. 去dirty中获取数据
  4. 通过misslock,不管有没有,先对misses +1,如果miss次数>=len(dirty),那么就把dirty copy给read,这样read的数据就是最新的了
  5. 重制dirty和misses。

carbon (31).png 6. 如果没有对应的key,就返回nil,有的话,就返回对应的value

总结:

  1. 如果read中有key,就不用加锁,直接返回,效率高,读多的场景友好
  2. 如果dirty有key的话,通过记录miss次数来反转read,忍受一段miss的带来的lock时间,对于新key最终还是读read。

Delete(删除一个k)

carbon (32).png

  1. 当read不存在这个key,且dirty有新数据的时候,加锁
  2. 因为加锁的过程,可能read发生变化,所以再次check下
  3. dirty中有新数据的时候,直接删除dirty中的k
  4. 如果read有,那么就软删除,设置p为nil

carbon (33).png 总结:当删除的key在read中,可以通过软删除来标记,这样本身read对应的map不会因为频繁删除而触发等量扩容,关于map的扩容规则可以参考map原理

回到题目

通过分析了sync.map我们发现,在读多写少的情况下,还是比较优秀的,相比常规map加锁那种肯定是更好的,但是写多读少的情况下,并不适合,因为还是涉及到频繁的加锁、read和dirty交换等开销,搞不好还比常规的map加锁性能更差。我们还是通过一个极端的例子来看:

carbon (34).png

  1. BenchmarkAddMapWithUnLock 是测试无锁的
  2. BenchmarkAddMapWithLock 是测试有锁的
  3. BenchmarkAddMapWithSyncMap 是测试sync.map 3个方法都是对一个map加10w条数据。

通过go test -bench .来跑测试,得出的结果如下:

carbon (35).png 可以看出sync.map的耗时是其他的两个的5倍左右。sync.map是个好东西,但是场景用错,反而适得其反。

image.png