Golang - sync.map 设计思想和底层源码分析
一.引言
-
在Go v1.6之前,内置map是部分goroutine安全的,并发读没有问题,并发写可能有问题
-
在Go v1.6之后,并发读写内置map会报错,在一些知名的开源库都有这个问题,所以在Go v1.9之前,解决方案是加一个额外的大锁,锁住map。
-
在Go v1.9中,go官方提供了并发安全的map,sync.map。
本文Go版本:v1.14.4
二. sync.map的设计思想
在map内数据非常大的时候,采用一个大锁,会使得锁的竞争十分激烈,存在性能问题
- Java内的解决方案是分段锁机制,比如ConcurrentHashMap,内部使用多个锁,每个区间共用一把锁,这样锁的粒度更小了,减少了数据共享一把大锁带来的性能影响
但是由于其实现的复杂性和其他因素,Go官方并没有采用上述方案,而是另辟蹊径,采用读写分离的形式,来实现了一个并发安全的map
后续笔者会考虑自己实现一个分段锁机制的map,然后和sync.map进行一下比较,观察在不同场景下的性能差异,敬请期待
1. 空间换时间
如果采用传统的大锁方案,其锁的竞争十分激烈,也就意味着需要花在锁上的时间很多,我们要尽可能的减少时间消耗,针对耗时太长的情况,算法中有一种常见的解决方案,空间换时间,采用冗余的数据结构,来减少时间的消耗。
sync.map中冗余的数据结构就是dirty和read,二者存放的都是key-entry,entry其实是一个指针,指向value,read和dirty各自维护一套key,key指向的都是同一个value,也就是说,只要修改了这个entry,对read和dirty都是可见的
那空间换时间策略在sync.map中到底是如何体现的呢?到底在哪些地方减少了耗时?
- 遍历操作:只需遍历read即可,而read是并发读安全的,没有锁,相比于加锁方案,性能大为提升
- 查找操作:先在read中查找,read中找不到再去dirty中找
核心思想就是一切操作先去read中执行,因为read是并发读安全的,无需锁,实在在read中找不到,再去dirty中。read在sycn.map中是一种冗余的数据结构,因为read和dirty中数据有很大一部分是重复的,而且二者还会进行数据同步
2.读写分离
sync.map中有专门用于读的数据结构:read,将其和写操作分离开来,可以避免读写冲突
而采用读写分离策略的代价就是冗余的数据结构,其实还是空间换时间的思想。
3.双检查机制
通过额外的一次检查操作,来避免在第一次检查操作完成后,其他的操作使得检查条件产生突然符合要求的可能。
在sync.map中,每次当read不符合要求要去操作dirty前,都会上锁,上锁后再次判断是否符合要求,因为read有可能在上锁期间,产生了变化,突然又符合要求了,read符合要求了,尽量还是在read中操作,因为read并发读安全。
4. 延迟删除
在删除操作中,删除kv,仅仅只是先将需要删除的kv打一个标记,这样可以尽快的让delete操作先返回,减少耗时,在后面提升dirty时,再一次性的删除需要删除的kv
5.read优先
需要进行读取,删除,更新操作时,优先操作read,因为read无锁的,更快,实在在read中得不到结果,再去dirty中
read的修改操作需要加锁,read只是并发读安全,并发写并不安全
6. 状态机机制
entry的指针p,是有状态的,nil,expunged(指向被删除的元素),正常,三种状态.
那其状态在sync.map各个操作间又是怎么变化的呢?
主要是两个操作会引起p状态的变化:Store(新增/修改) 和 删除
我们先来看看第一个操作 Store(新增/修改)
- 在Store更新时,如果key在read中存在,并且被标记为已删除,会将kv加入dirty,此时read中key的p指向的是expunged,经过unexpungeLocked函数,read中的key的p指向会从expunged改为nil