Golang本地缓存选型,看这篇就够了
?golang并发安全k-v缓存库源码分析、对比。包括Golang map、sync.map、concurrent-map技术选型对比、深度分析。
golang map
1. 并发读写测试
在golang中原生map 在并发场景下,同时读写是线程不安全的,无论key是否一样。以下是测试代码
package main
import "time"
func main() {
testMapReadWriteDiffKey()
}
func testMapReadWriteDiffKey() {
m := make(map[int]int)
go func() {
for {
m[100] = 100
}
}()
go func() {
for {
_ = m[12]
}
}()
select {}
}
如上图的demo,并发读写map的不同key,运行结果如下
map读的时候会检查hashWriting标志, 如果有这个标志,就会报并发错误。写的时候会设置这个标志:h.flags |= hashWriting.设置完之后会取消这个标记。map的并发问题不是那么容易被发现, 可以利用-race参数来检查。map并发读写冲突检测机制不是本文的重点,不过感兴趣的同学可以通过以下链接深入了解下。这是源码,文章分析看这里。编译时的选项-race,为何能分析出并发问题,详见:go官方博客,文章分析,视频讲解
2. map+读写锁
在官方库sync.map没出来前,Go maps in action推荐的做法是使用map+RWLock,比如定义一个匿名struct变量,其包含map、RWLock,如下所示
var counter = struct{
sync.RWMutex
m map[string]int
}{m: make(map[string]int)}
可以这样从counter中读数据
counter.RLock()
n := counter.m["some_key"]
counter.RUnlock()
fmt.Println("some_key:", n)
可以这样往counter中写数据
counter.Lock()
counter.m["some_key"]++
counter.Unlock()
那Go 1.9版本实现的sync.map和上面的这种实现方式有什么不同?它适用于哪些场景呢?它在哪些方面做了性能优化呢?
sync.map
sync.map是用读写分离实现的,其思想是空间换时间。和map+RWLock的实现方式相比,它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求(增删改查遍历),那就不用去操作write map(它的读写都要加锁),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式。
接下来着重介绍下sync.map的源码,以了解其运作原理
1. 变量介绍
1.1 结构体Map
type Map struct {
mu Mutex
read atomic.Value
dirty map[interface{}]*entry
misses int
}
1.2 结构体readOnly
type readOnly struct {
m map[interface{}]*entry
amended bool
}
1.1的结构read存的就是readOnly,m是一个map,key是interface,value是指针entry,其指向真实数据的地址,amended等于true代表dirty中有readOnly.m中不存在的entry
1.3 结构体entry
type entry struct {
p unsafe.Pointer
}
entry中的指针p指向真正的value所在的地址,dirty和readOnly.m存的值类型就是*entry。这里的nil和expunged有什么作用呢?只要nil不可以吗?对于这些问题后面会一一解读。
2. 函数介绍
下面介绍下sync.Map的四个方法:Store、Load、Delete、Range
2.1 Load方法
- 图解
- 源码分析
- Load方法用来加载sync.Map中的值,入参是key,返回值是对应的value以及value存在与否
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
m.missLocked()
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load()
}
Map.dirty是如何提升为Map.read的呢?让我们来看下missLocked方法
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
m.read.Store(readOnly{m: m.dirty})
m.dirty = nil
m.misses = 0
}
小结:
- Load方法会优先无锁访问readOnly,未命中后如果Map.dirty中可能存在这个数据就会加锁访问Map.dirty
- Load方法如果访问readOnly中不存在但dirty中存在的key,就要加锁访问Map.dirty从而带来额外开销。
2.2 Store方法
- 图解
- 源码解析
- Store方法往Map里添加新的key和value或者更新value
func (m *Map) Store(key, value interface{}) {
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
m.dirty[key] = e
}
e.storeLocked(&value)
} else if e, ok := m.dirty[key]; ok {
e.storeLocked(&value)
} else {
if !read.amended {
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value)
}
m.mu.Unlock()
}
tryStore函数如下:
func (e *entry) tryStore(i *interface{}) bool {
for {
p := atomic.LoadPointer(&e.p)
if p == expunged {
return false
}
if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
return true
}
}
}
unexpungeLocked函数如下:
func (e *entry) unexpungeLocked() (wasExpunged bool) {
return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}
dirtyLocked函数如下:
func (m *Map) dirtyLocked() {
if m.dirty != nil {
return
}
read, _ := m.read.Load().(readOnly)
m.dirty = make(map[interface{}]*entry, len(read.m))
for k, e := range read.m {
if !e.tryExpungeLocked() {
m.dirty[k] = e
}
}
}
小结:
- Store方法优先无锁访问readOnly,未命中会加锁访问dirty
- Store方法中的双重检测机制在下面的Load、Delete、Range方法中都会用到,原因是:加锁前Map.dirty可能已被提升为Map.read,所以加锁后还要再次检查key是否存在于Map.read中
- dirtyLocked方法在dirty为nil(刚被提升成readOnly或者Map初始化时)会从readOnly中拷贝数据,如果readOnly中数据量很大,可能偶尔会出现性能抖动。
- sync.map不适合用于频繁插入新key-value的场景,因为此操作会频繁加锁访问dirty会导致性能下降。更新操作在key存在于readOnly中且值没有被标记为删除(expunged)的场景下会用无锁操作CAS进行性能优化,否则也会加锁访问dirty
2.3 Delete方法
func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
delete(m.dirty, key)
m.missLocked()
}
m.mu.Unlock()
}
if ok {
return e.delete()
}
return nil, false
}
func (e *entry) delete() (value interface{}, ok bool) {
for {
p := atomic.LoadPointer(&e.p)
if p == nil || p == expunged {
return nil, false
}
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return *(*interface{})(p), true
}
}
}
小结:
- 删除readOnly中存在的key,可以不用加锁
- 如果删除readOnly中不存在的或者Map中不存在的key,都需要加锁。
2.4 Range方法
-
图解 -
源码解析 -
Range方法可遍历Map,参数是个函数(入参:key和value,返回值:是否停止遍历Range方法) func (m *Map) Range(f func(key, value interface{}) bool) {
read, _ := m.read.Load().(readOnly)
if read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if read.amended {
read = readOnly{m: m.dirty}
m.read.Store(read)
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
if !f(k, v) {
break
}
}
}
小结:
- Range方法Map的全部key都存在于readOnly中时,是无锁遍历的,性能最高
- Range方法在readOnly只存在Map中的部分key时,会一次性加锁拷贝dirty的元素到readOnly,减少多次加锁访问dirty中的数据
3. sync.map总结
3.1 使用场景
sync.Map更适合读多更新多而插入新值少的场景(appendOnly模式,尤其是key存一次,多次读而且不删除的情况),因为在key存在的情况下读写删操作可以不用加锁直接访问readOnly 不适合反复插入与读取新值的场景,因为这种场景会频繁操作dirty,需要频繁加锁和更新read【此场景github开源库orcaman/concurrent-map更合适】
3.2 设计点:expunged
entry.p取值有3种,nil、expunged和指向真实值。那expunged出现在什么时候呢?为什么要有expunged的设计呢?它有什么作用呢?
-
什么时候expunged会出现呢? 当用Store方法插入新key时,会加锁访问dirty,并把readOnly中的未被标记为删除的所有entry指针复制到dirty,此时之前被Delete方法标记为软删除的entry(entry.p被置为nil)都变为expunged,那这些被标记为expunged的entry将不会出现在dirty中。 -
反向思维,如果没有expunged,只有nil会出现什么结果呢?
- 直接删掉entry==nil的元素,而不是置为expunged:在用Store方法插入新key时,readOnly数据拷贝到dirty时直接把为ni的entry删掉。但这要对readOnly加锁,sync.map设计理念是读写分离,所以访问readOnly不能加锁。
- 不删除entry==nil的元素,全部拷贝:在用Store方法插入新key时,readOnly中entry.p为nil的数据全部拷贝到dirty中。那么在dirty提升为readOnly后这些已被删除的脏数据仍会保留,也就是说它们会永远得不到清除,占用的内存会越来越大。
- 不拷贝entry.p==nil的元素:在用Store方法插入新key时,不把readOnly中entry.p为nil的数据拷贝到dirty中,那在用Store更新值时,就会出现readOnly和dirty不同步的状态,即readOnly中存在dirty中不存在的key,那dirty提升为readOnly时会出现数据丢失的问题。
4. sync.map的其他问题
为什么sync.map不实现len方法?个人觉得还是成本和收益的权衡。
- 实现len方法要统计readOnly和dirty的数据量,势必会引入锁竞争,导致性能下降,还会额外增加代码实现复杂度
- 对sync.map的并发操作导致其数据量可能变化很快,len方法的统计结果参考价值不大。
orcanman/concurrent-map
orcaman/concurrent-map的适用场景是:**反复插入与读取新值,**其实现思路是:对go原生map进行分片加锁,降低锁粒度,从而达到最少的锁等待时间(锁冲突) 它的实现比较简单,截取部分源码如下
1. 数据结构
var SHARD_COUNT = 32
type ConcurrentMap []*ConcurrentMapShared
type ConcurrentMapShared struct {
items map[string]interface{}
sync.RWMutex
}
func New() ConcurrentMap {
m := make(ConcurrentMap, SHARD_COUNT)
for i := 0; i < SHARD_COUNT; i++ {
m[i] = &ConcurrentMapShared{items: make(map[string]interface{})}
}
return m
}
2. 函数介绍
2.1 GET方法
func (m ConcurrentMap) Get(key string) (interface{}, bool) {
shard := m.GetShard(key)
shard.RLock()
val, ok := shard.items[key]
shard.RUnlock()
return val, ok
}
2.2 SET方法
func (m ConcurrentMap) Set(key string, value interface{}) {
shard := m.GetShard(key)
shard.Lock()
shard.items[key] = value
shard.Unlock()
}
2.3 Remove方法
func (m ConcurrentMap) Remove(key string) {
shard := m.GetShard(key)
shard.Lock()
delete(shard.items, key)
shard.Unlock()
}
2.4 Count方法
func (m ConcurrentMap) Count() int {
count := 0
for i := 0; i < SHARD_COUNT; i++ {
shard := m[i]
shard.RLock()
count += len(shard.items)
shard.RUnlock()
}
return count
}
2.5 Upsert方法
func (m ConcurrentMap) Upsert(key string, value interface{}, cb UpsertCb) (res interface{}) {
shard := m.GetShard(key)
shard.Lock()
v, ok := shard.items[key]
res = cb(ok, v, value)
shard.items[key] = res
shard.Unlock()
return res
}
后续
当然在其他业务场景中,我们可能更需要的是本地kv缓存组件库并要求它们支持键过期时间设置、淘汰策略、存储优化、gc优化等。 这时候可能我们就需要去了解freecache、gocache、fastcache、bigcache、groupcache等组件库了。
参考链接
https://stackoverflow.com/questions/45585589/golang-fatal-error-concurrent-map-read-and-map-write/45585833 https://github.com/golang/go/issues/20680 https://github.com/golang/go/blob/master/src/sync/map.go https://github.com/orcaman/concurrent-map
干货将在我的微信公众号:小梁编程汇 持续更新。喜欢的话可以关注我微信公众哦
|