Golang - sync.map 设计思想和底层源码分析
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,然后经过storeLocked更新value值,p从指向nil,改为指向正常
- (p->expunged) =====> (p->nil) =====> (p->正常)
- 在Store增加时,如果需要从read中刷新dirty数据,会将read中未删除的元素加入dirty,此时会将所有指向nil的p指针,改为指向expunged
- (p->nil) =====> (p->expunged)
我们再来看看第二个操作:
- 在Delete时,删除value时,p从指向正常值,改为指向nil
- (p->正常) =====> (p->nil)
p的状态转换如下:
从上图我们可以看出
- update时:p的状态从expunged转为nil,然后又转为正常值
- add时:当需要刷新dirty,p的状态从nil转为expunged
- delete时:p的状态从正常值转为nil
三. sync.map 源码分析
1. 基础数据结构
- entry
// entry 键值对中的值结构体
type entry struct {
p unsafe.Pointer // 指针,指向实际存储value值的地方
}
sync.map中key和value是分开存放的,key通过内置map指向entry,entry通过指针,指向value实际内存地址
- Map
// Map 并发安全的map结构体
type Map struct {
mu sync.Mutex // 锁,保护read和dirty字段
read atomic.Value // 存仅读数据,原子操作,并发读安全,实际存储readOnly类型的数据
dirty map[interface{}]*entry // 存最新写入的数据
misses int // 计数器,每次在read字段中没找所需数据时,+1
// 当此值到达一定阈值时,将dirty字段赋值给read
}
// readOnly 存储mao中仅读数据的结构体
type readOnly struct {
m map[interface{}]*entry // 其底层依然是个最简单的map
amended bool // 标志位,标识m.dirty中存储的数据是否和m.read中的不一样,flase 相同,true不相同
}
需要注意的地方:
-
read在进行非读操作时,需要锁mu进行保护
-
写入的数据,都是直接写到dirty,后面根据read miss次数达到阈值,会进行read和dirty数据的同步
-
readOnly中专门有一个标志位,用来标注read和dirty中是否有不同,以便进行read和dirty数据同步
2. sync.map中查找 k-v
// Load 查询key是否存在
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
// 1.先在read中查找key
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 2. 在read中没有找到,并且read和dirty数据不一样(dirty中有read中不存在的数据,因为写数据是直接往dirty中写的)
if !ok && read.amended {
m.mu.Lock() // 锁住,因为要操作dirty中数据
// 3.双检查机制,再次在read中查找key,因为有可能read从dirty中更新了数据
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
// 4.在read中还是没有找到,并且read和dirty数据仍然不一致
if !ok && read.amended {
e, ok = m.dirty[key] // 直接从dirty获取数据
// read 不命中次数 +1,到达阈值后,为避免read命中率太低,会从dirty中更新read数据
m.missLocked()
}
m.mu.Unlock() // 解锁,后续不再操作dirty数据
}
// 5.最后仍然没有找到key,说明key在map中确实不存在,返回nil
if !ok {
return nil, false
}
// 6.找到key了,返回value
return e.load()
}
// missLocked readmiss次数+1 ,并且判断dirty是否需要晋升(dirty置给read)
func (m *Map) missLocked() {
m.misses++ // read 没命中次数统计+1
if m.misses < len(m.dirty) {
return
}
// dirty 置给read ,因为read没有命中的次数太多了,原子操作
m.read.Store(readOnly{m: m.dirty})
m.dirty = nil // dirty 也置空
m.misses = 0
}
通过对源码的分析,我们可以在宏观上总结一下搜索的流程:先在read中搜,搜不到再去dirty中搜,但是这个太宏观了,有些东西没有讨论到,比如
-
双检查机制
-
read miss次数达到阈值,刷新read数据
上面两项操作,其实归根结底都是为了提升搜索的效率,比如read miss的统计和read数据的刷新,都是为了让直接可以在read中找到key,尽可能不去dirty中找,因为read并发读是安全的,性能很高,而去dirty中找,则需要加锁,耗时就增加了
调用Load或LoadOrStore函数时,如果在read中没有找到key,则会将miss值原子增加1,当miss值增加到和dirty长度相等时,会将dirty提升为read,以期望减少 "读 miss"。
3. sync.map中添加或修改 k-v
// Store 添加/修改 key-value
func (m *Map) Store(key, value interface{}) {
// 1. 在read中查找key,找到了则尝试更新value
read, _ := m.read.Load().(readOnly)
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
m.mu.Lock() // 操作dirty,锁住先
// 2. 双检查机制,再次在read中查找key
read, _ = m.read.Load().(readOnly)
// 3. key在read中存在
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() { // key被标记为已删除,则将k/v加入dirty中
m.dirty[key] = e
}
e.storeLocked(&value) // 无论key是否为已删除状态,都要更新key的value值
} else if e, ok := m.dirty[key]; ok {
// 4. key在dirty中存在,则直接在dirty中更新value值
e.storeLocked(&value)
} else {
// 5. key在read和dirty中都不存在,则走新增逻辑
// read和dirty中数据相同,则从read中刷新dirty的数据(因为dirty为nil,有可能是初始化或dirty之前提升过了),并将amended标识为read和dirty不相同,因为后面即将走新增逻辑
if !read.amended {
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
m.dirty[key] = newEntry(value) // 新增逻辑,直接在dirty中加入kv键值对
}
m.mu.Unlock() // 不再操作dirty数据,解锁啦
}
// tryStore 尝试更新value 原子操作
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 判断是否指向expunged,如果指向expunged则修改为指向nil
func (e *entry) unexpungeLocked() (wasExpunged bool) {
// 之所以需要将指向expunged的修改为指向nil ,是因为后续会将k/v加入dirty中,都已经加入dirty中,并且不是未删除状态,当然需要指向nil啦
// 此value在read中暂时指向nil,但后续会更新value值,这样read中和dirty中都是指向同一个value的 ( Store中第四步,更新value值)
return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}
// storeLocked 更新指向的value值
func (e *entry) storeLocked(i *interface{}) {
atomic.StorePointer(&e.p, unsafe.Pointer(i))
}
// dirtyLocked 刷新dirty数据逻辑,将read中未删除的数据加入到dirty中
func (m *Map) dirtyLocked() {
// 此函数仅在以下情况会执行: read和dirty相同时,比如初始化或dirty刚提升到read,dirty肯定是nil
// dirty 非nil,则没必要走刷新dirty数据逻辑
if m.dirty != nil {
return
}
read, _ := m.read.Load().(readOnly)
m.dirty = make(map[interface{}]*entry, len(read.m)) // dirty 申请内存空间
// 1.遍历read,将read中未删除元素加入dirty中(加入的其实不是真正的底层数据副本,而是指向底层数据的指针)
for k, e := range read.m {
if !e.tryExpungeLocked() { // 保证加入dirty中都是read中未删除的元素,read中被删除状态的元素则没必要加入dirty
m.dirty[k] = e
}
}
}
// tryExpungeLocked 判断元素是否为被删除状态
func (e *entry) tryExpungeLocked() (isExpunged bool) {
// 进入此函数的指针,有三种指向: 指向正常value,指向nil,指向expunged,本函数的目的就是在判断是否指向expunged之余,将指向nil的都改为指向expunged
p := atomic.LoadPointer(&e.p) // 原子操作,载入指针
// 将指向nil的指针,改为指向expunged
for p == nil {
if atomic.CompareAndSwapPointer(&e.p, nil, expunged) { // 原子操作,比较和交换
return true
}
p = atomic.LoadPointer(&e.p) // 原子操作,重新载入指针
}
// 有可能是正常元素,判断是否指向expunged
return p == expunged
}
大致总结一下上述流程:
-
在read中查找key,找到了则通过原子操作,尝试更新value
-
key在read中存在,但是被标记为已删除,则kv加入dirty中,并更新value值
-
key在read中不存在,但在dirty中存在,则直接在dirty中更新value
-
key在read和dirty中都不存在,则直接在dirty中加入kv,需要注意的是,此时dirty可能为nil(因为之前可能没有初始化或之前dirty提升过),需要将read中未删除的元素加入dirty
新写入的key会保存到dirty中,如果此时dirty为nil,就会先创建一个dirty,并将read中未删除的数据拷贝到dirty
当dirty为nil时,read就代表map所有数据,当dirty不为nil的时候,dirty才代表map所有数据。
4. sync.map中删除 k-v
// Delete 删除元素
func (m *Map) Delete(key interface{}) {
// 1.先在read中查找key
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
// 2.在read中没有找到key,并且read和dirty中数据不相同(即dirty中有read中没有的数据,因为插入数据都是直接插入到dirty中的,read还来不及根据dirty数据进行刷新)
if !ok && read.amended {
m.mu.Lock() // 操作dirty,锁住先
// 3.双检查机制,继续在read中查找key
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
// 4. 在read中没有找到key,并且read和dirty中数据不相同,则在dirty中删除key
if !ok && read.amended {
delete(m.dirty, key)
}
m.mu.Unlock() // 解锁,不再操作dirty
}
// 5. 通过key,找到了value,则删除value
if ok {
e.delete()
}
}
// delete 删除value
func (e *entry) delete() (hadValue bool) {
for {
p := atomic.LoadPointer(&e.p) // 原子操作方式加载指针
if p == nil || p == expunged { // p 指向nil或已删除元素,删除失败
return false
}
// 将p指向nil
// 为何不将p设置为expunged ? 因为p为expunged时,表示其已经不在dirty中了,这是由p的状态机决定的!
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return true
}
}
}
大致总结一下删除操作的流程:
-
在read中查key,找到了则直接删除value(修改entry的指针p,改为指向nil,因为是指针,所以在read和dirty中都是可见的)。
-
在read中没有找到key,但read数据和dirty数据有不同,则去dirty中直接删除key(不管dirty中有无key,都是直接删除,不会返回任何响应),最后也是entry的delete直接删除value。
此函数的特点就是不会有任何的返回值,存在就删除了,没存在就不会删,也删不了,这些对函数外部的调用者都是不可见的
5. sync.map中遍历 k-v
// Range 回调方式遍历map
func (m *Map) Range(f func(key, value interface{}) bool) {
read, _ := m.read.Load().(readOnly)
// 1.dirty中有新数据,则提升dirty,然后再遍历
if read.amended {
m.mu.Lock() //操作dirty,锁住
read, _ = m.read.Load().(readOnly)
if read.amended { // 双检查机制,再次检测dirty中是否有新数据
read = readOnly{m: m.dirty} // 提升dirty为read,重置dirty和miss计数器
m.read.Store(read)
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
// 到这就代表,read中的数据和dirty中数据是一致的,直接遍历read即可
// 2.回调的方式遍历read
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
if !f(k, v) {
break
}
}
}
注意事项:
- 底层遍历的其实是read,而如果dirty中有不同于read的新数据,则需要先提升dirty再进行遍历,这样数据才能一致
四.sycn.map的使用
package main
import (
"fmt"
"sync"
)
func main() {
var m sync.Map
// 1. 写入
m.Store("qcrao", 18)
m.Store("stefno", 20)
// 2. 读取
age, _ := m.Load("qcrao")
fmt.Println(age.(int))
// 3. 遍历
m.Range(func(key, value interface{}) bool {
name := key.(string)
age := value.(int)
fmt.Println(name, age)
return true
})
// 4. 删除
m.Delete("qcrao")
age, ok := m.Load("qcrao")
fmt.Println(age, ok)
// 5. 读取或写入
m.LoadOrStore("stefno", 100)
age, _ = m.Load("stefno")
fmt.Println(age)
}
- 第 1 步,写入两个 k-v 对;
- 第 2 步,使用 Load 方法读取其中的一个 key;
- 第 3 步,遍历所有的 k-v 对,并打印出来;
- 第 4 步,删除其中的一个 key,再读这个 key,得到的就是 nil;
- 第 5 步,使用 LoadOrStore,尝试读取或写入 "Stefno",因为这个 key 已经存在,因此写入不成功,并且读出原值。
程序输出:
18
stefno 20
qcrao 18
<nil> false
20
注意事项:
sycn.map 适用于读多写少的场景,对于读很多的场景,会导致read map缓存失效,需要加锁,导致竞争变多,而且未命中的read map次数过多,导致dirty map被提升为read map,这是一个O(N)操作,会进一步降低性能。
五.小结
- sync.map 是并发安全的.
- 通过读写分离,降低锁时间来提高效率,适用于读多写少的场景。
- sync.map底层其实是两个map,一个read map,一个dirty map,read map 并发读安全,所有读操作优先read map,所有写操作直接在dirty map中,read map和dirty map在需要时间会进行数据同步。
参考文章: