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

「本文已参与好文召集令活动,点击查看:后端、大前端双赛道投稿,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,即数据竞争问题。有人说这简单,加锁解决,加锁当然能够解决,可是你懂的,锁的开销问题。
撇开数据竞争的问题,咱们能够经过看个例子来了解下锁的开销:markdown

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问题,可是它在读多写少的状况下,比较适合,能够保证并发安全,同时又不须要锁的开销,在写多读少的状况下反而可能会更差,主要是由于它的设计,咱们从源码分析看看:post

结构

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)上,随便看,效率高。atom

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

相关文章
相关标签/搜索