为什么说并发场景不要乱用sync.map
map 本身并发不安全的
我们都知道go的map是并发不安全的,当几个goruotine同时对一个map进行读写操作时,就会出现并发写问题fatal error: concurrent map writes
- 在程序一开始我们初始化一个map
- 子goroutine对m[a]赋值
- 主goroutine对m[a]赋值
理论上只要在多核cpu下,如果子goroutine和主gouroutine同时在运行,就会出现问题。我们不妨用go自带的
-race
来检测下,可以运行go run -race main.go
通过检测,我们可以发现,存在
data race
,即数据竞争问题。有人说这简单,加锁解决,加锁固然可以解决,但是你懂的,锁的开销问题。
撇开数据竞争的问题,我们可以通过看个例子来了解下锁的开销:
BenchmarkAddMapWithUnLock
是测试无锁的BenchmarkAddMapWithLock
是测试有锁的 通过go test -bench .
来跑测试,得出的结果如下:
可以发现无锁的平均耗时约
6.6 ms
,带锁的平均耗时约7.0 ms
,虽说相差无几,但也反应加锁的开销。在一些复杂的案例中,可能会更明显。
sync.map
有人说,既然锁开销大,那么就用go内置的方法sync.map,它可以解决并发问题。sync.map确实可以解决并发map问题,但是它在读多写少的情况下,比较适合,可以保证并发安全,同时又不需要锁的开销,在写多读少的情况下反而可能会更差,主要是因为它的设计,我们从源码分析看看:
结构
- mutex锁,当涉及到脏数据(dirty)操作时候,需要使用这个锁
- read,读不需要加锁,就是从read中读的,read是atomic.Value类型,具体结构如下:
read的数据存在readOnly.m中,也是个map,value是个entry的指针,entry是个结构体具体类型如下:
里面就一个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)
- 当key存在read的时候,那么此时就是更新value,尝试去直接更新value,更新成功了就返回,不需要加锁。这里面有个tryStore:
tryStore里面有判断
p == expunged
就返回false。p有三种类型:nil
(read中的key被delete的时候其实软删除,只是把p设置成nil)、expunged
(被删除的key(p==nil)会在read copy 到 dirty的时候再被设置成expunged)、其他正常的value的地址
,这里如果是expunged就不选择更新value。
- 加锁,接下来都是线程安全的。
- 加锁的过程可能原本不存在的key,加完锁有了,所以要再check下,如果read中存在,且本来被dirty删除了,那么在dirty中还原下key,最后设置value。
- 如果read中没有key,但是dirty中有,那么直接修改value
- 如果read和dirty中都没有这个key,且dirty为nil的时候,尝试把read中未删除的copy到dirty中去,(read中删除不是真的删除,会把entry.p设置为nil,简单理解就是把key的value的地址设置为nil),这些都是在dirtyLocked中完成的:
然后在dirty中设置新的k、v。(这里可以发现新的k、v都是先加在dirty的map中的,read是没有的)。
6. 现在dirty是比较干净的数据了(已经清空了nil或expunged的key),设置amended=true(说明此时dirty不为空,且dirty中有新数据)
7. 解锁
总结:
- 可以发现对于更新,read和dirty因为value是指针,底层是一个value,这样都会被更新
- 对于新增的,会先加在dirty中,read中并不会新增
- 对于新增是要加锁的,所以假设存在一种极端的case:一直加新key,那么每次都是要加锁的,何况中间还有
if else
的分支判断。整体肯定是比常规map加锁性能要差的。
Load(获取一个kv)
- 当read中不存在这个key,且amenbed=true的时候(通过上面的store,说明此时dirty有新数据),加锁(dirty不是线程安全的)
- 因为加锁的过程,可能read发生变化,所以再次check下
- 去dirty中获取数据
- 通过misslock,不管有没有,先对misses +1,如果miss次数>=len(dirty),那么就把dirty copy给read,这样read的数据就是最新的了
- 重制dirty和misses。
6. 如果没有对应的key,就返回nil,有的话,就返回对应的value
总结:
- 如果read中有key,就不用加锁,直接返回,效率高,读多的场景友好
- 如果dirty有key的话,通过记录miss次数来反转read,忍受一段miss的带来的lock时间,对于新key最终还是读read。
Delete(删除一个k)
- 当read不存在这个key,且dirty有新数据的时候,加锁
- 因为加锁的过程,可能read发生变化,所以再次check下
- dirty中有新数据的时候,直接删除dirty中的k
- 如果read有,那么就软删除,设置p为nil
回到题目
通过分析了sync.map我们发现,在读多写少的情况下,还是比较优秀的,相比常规map加锁那种肯定是更好的,但是写多读少的情况下,并不适合,因为还是涉及到频繁的加锁、read和dirty交换等开销,搞不好还比常规的map加锁性能更差。我们还是通过一个极端的例子来看:
BenchmarkAddMapWithUnLock
是测试无锁的BenchmarkAddMapWithLock
是测试有锁的BenchmarkAddMapWithSyncMap
是测试sync.map 3个方法都是对一个map加10w条数据。
通过go test -bench .
来跑测试,得出的结果如下:
可以看出sync.map的耗时是其他的两个的5倍左右。sync.map是个好东西,但是场景用错,反而适得其反。
转载自:https://juejin.cn/post/6983646719909036039