Go语言的Map
一.基本操作回顾
1.1 基本操作
sync.Map是一个并发版本的map,是并发安全的,如果之前看过map的同学都应该知道,它是并发不安全的。
接下来介绍一下一些基本操作
- 使用Store(interface {},interface {}) 添加元素。
- 使用Load(interface {}) interface {} 检索元素。
- 使用Delete(interface {}) 删除元素。
- 使用LoadOrStore(interface {},interface {}) (interface {},bool)检索或添加之前不存在的元素。如果键之前在map中存在,则返回的布尔值为true。
- 使用Range遍历元素,这个需要传入一个函数,看下它的结构。
func (m *Map) Range(f func(key, value any) bool)
package mainimport ("fmt""sync"
)func main() {m := &sync.Map{}// 添加元素m.Store(1, "one")m.Store("2", "two")// 检索,返回两个,第一个是元素,第二个是是否存在// 存在返回true,不存在返回falsevalue, _ := m.Load(1)fmt.Println(value)value2, _ := m.Load("2")fmt.Println(value2)
}
package mainimport ("fmt""sync"
)func main() {m := &sync.Map{}// 添加元素m.Store(1, "one")m.Store("2", "two")// 获取元素value, _ := m.Load(1)fmt.Println(value)value2, c := m.Load("2")if c {fmt.Println(value2)}// 有这个键,就把对应的值返回// 否则把指定的键值存储到map中value3, loaded := m.LoadOrStore(3, "three")if !loaded {fmt.Printf("%s\n", value3.(string))}// 删除3m.Delete(3)// 迭代所有元素m.Range(func(key, value interface{}) bool {fmt.Println(key, value)return true})
}
上述就是一个并发Map的基本操作
说一下Range具有的垄断机制
什么意思?就是当结果是返回的是false的时候,就会直接终止这个Range,
还要补充一点,这个map是随机的,所以遍历是没有顺序的,一旦触发false,就会直接垄断,直接结束。
package mainimport ("fmt""sync"
)func main() {m := &sync.Map{}// 添加元素m.Store(1, "one")m.Store("2", "two")m.Store("3", "three")// 迭代所有元素m.Range(func(key, value interface{}) bool {fmt.Println(key, value)return value != "two"})
}
我们应该在什么时候使用sync.Map而不是在普通的map上使用sync.Mutex?
- 当我们对map有频繁的读取和不频繁的写入时。
- 当多个goroutine读取,写入和覆盖不相交的键时。具体是什么意思呢?例如,如果我们有一个分片实现,其中包含一组4个goroutine,每个goroutine负责25%的键(每个负责的键不冲突)。在这种情况下,sync.Map是首选。
1.2 加锁实现并发安全的map
实现很简单,就是直接加一个读写锁,不就解决了这个问题,看下我简单实现的一个代码
package mainimport "sync"type MyConcurrentMap struct {mp map[string]interface{}mu sync.RWMutex
}func NewMyConcurrentMap() *MyConcurrentMap {return &MyConcurrentMap{mp: make(map[string]interface{}),}
}// Get 获取键值,存在返回值和true,否则返回nil和false
func (m *MyConcurrentMap) Get(key string) (interface{}, bool) {m.mu.RLock() // 读锁,允许多个读defer m.mu.RUnlock()val, ok := m.mp[key]return val, ok
}func (m *MyConcurrentMap) Set(key string, val interface{}) {m.mu.Lock()defer m.mu.Unlock()m.mp[key] = val
}func (m *MyConcurrentMap) Del(key string) {m.mu.Lock()defer m.mu.Unlock()delete(m.mp, key)
}func (m *MyConcurrentMap) Range(f func(key string, value interface{}) bool) {m.mu.RLock()defer m.mu.RUnlock()for k, v := range m.mp {if !f(k, v) {break}}
}
二.解析底层
2.1 核心数据结构
type Map struct {mu Mutex //锁read atomic.Pointer[readOnly] // 只读mapdirty map[any]*entry misses int
}
来看一下它的四个字段:
- mu:第一个字段就是一把锁,实现dirty和misses的并发管理
- read:第二字段是一个无锁化的只读的map(你可能会好奇,他咋就是一个map呢?原子操作(atomic)中有介绍这个类型,也就是实现一个自定义的原子操作的map)
- dirty:加锁处理的读写map
- misses :记录访问read的失效次数,累计达到阈值时,会进行read map/dirty map的更新轮换
看完了这些字段,我们来总结一下
看完之后,会发现sync.Map的特点是冗余map:read map和dirty map ,后续的所介绍的交互流程也是和这两个map息息相关,基本可以分为两条路线:
- 主线一:首先基于无锁操作访问 read map;倘若 read map 不存在该 key,则加锁并使用 dirty map 兜底。
- 主线二:read map 和 dirty map 之间会交替轮换更新。
看下核心数据结构里面一些数据结构
type readOnly struct {m map[any]*entryamended bool
}type entry struct {p atomic.Pointer[any]
}
entry
可以看出他是原子操作的任意类型的指针,这里通过看底层,会发现他是一个泛型。简单看一下:
type Pointer[T any] struct {_ [0]*T_ noCopyv unsafe.Pointer
}
说一下它的作用:
就是这个dirty map记录的value都是指针,这样就很清晰了吧。
说一下这个entry.p的指向分为三种情况:
- I 存活态:正常指向元素;
- II 软删除态:指向 nil;
- III 硬删除态:指向固定的全局变量 expunged.
var expunged = new(any)
- 存活态很好理解,即 key-entry 对仍未删除;
- nil 态表示软删除,read map 和 dirty map 底层的 map 结构仍存在 key-entry 对,但在逻辑上该 key-entry 对已经被删除,因此无法被用户查询到;
- expunged 态表示硬删除,dirty map 中已不存在该 key-entry 对.
readOnly
type readOnly struct {m map[any]*entryamended bool
}
点开这个只读map的结构,发现原来它的map在这里啊。
那来说一下这两个成员属性吧:
- m:真正意义上的 read map,实现从 key 到 entry 的映射;
- amended:标识 read map 中的 key-entry 对是否存在缺失,需要通过 dirty map 兜底.
2.2 读流程
简单说一下读流程的过程:
首先就是先访问read map里面有没有我们想要的key,如果不存在并且amended为true的话,就说明read map里面并没有数据,这个时候就会加锁访问dirty map来获取(首先还会向read map在尝试一次,避免期间出现写入的问题,之后才会向dirty map访问)。如果不存在,但是没有缺少数据,则直接返回false,最后的就是read map存在,这样的话就直接返回了。
接着看一下看下misses字段,每一次的访问read map失败,都会使其自增,到达一定程度之后就会把read map升级(把dirty map的数据给read map,并把dirty map改为nil),并把amended改为false。
func (m *Map) loadReadOnly() readOnly {if p := m.read.Load(); p != nil {return *p}return readOnly{}
}func (m *Map) Load(key any) (value any, ok bool) {read := m.loadReadOnly()e, ok := read.m[key]if !ok && read.amended {m.mu.Lock()read = m.loadReadOnly()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()
}func (e *entry) load() (value any, ok bool) {p := e.p.Load()if p == nil || p == expunged {return nil, false}return *p, true
}func (m *Map) missLocked() {m.misses++if m.misses < len(m.dirty) {return}m.read.Store(&readOnly{m: m.dirty})m.dirty = nilm.misses = 0
}
其中的missLock函数可以特别注意一下
2.3 写流程
来说一下写流程,这里的写是宏观的写,可以是更新。
首先当你写入一个key,会首先判断read map里面有没有这个这个key,如果有,判断其是不是硬删除,如果是则需要重新写入(后续会说),如果是软删除,则会通过CAS来进行更改,然后返回,如果是硬删除,或者就没有这key,就需要写入。
这个时候就会先加锁,然后重新访问一遍read map,但是过程稍有不同,我们来看看,如果他是硬删除,则就直接向dirty map写入,如果是软删除,则会直接更改entry指针下key对应的value值。这就是和之前read map操作不同的地方,这里已经加锁,所以不用自选修改。接着如果read map没有就会访问dirty map,如果dirty map有的话,就和软删除的操作一样,如果没有的话,会优先判断这个read map是不是缺数据了,如果有数据缺失,那说明就是没有,就会开一个新的entry写入,如果没有数据,就会重新把read map复制给dirty map(还记得之前吗,如果两个map数据一样,则会释放dirty map,还会把软删除的状态改为硬删除)同时把amended改为true。
为什么要拷贝来拷贝去呢?
这里这样做的目的是为了更新软硬删除的状态,清理软删除的内存空间。
简单说就是为了清除软删除的内存空间。因为如果使用delete会有并发安全的问题,使用这个轮换的方式就可以规避掉这个问题。把read map复制到dirty map的过程会把read map里面的软删除改为硬删除,拷贝到dirty map上。
注意事项
这个dirtylock会引发一个性能抖动的问题,因为这个复制的过程是线性的。
这样是为什么在写多的场景下,不如直接map加锁的原因。
func (e *entry) trySwap(i *any) (*any, bool) {for {p := e.p.Load()if p == expunged {return nil, false}if e.p.CompareAndSwap(p, i) {return p, true}}
}
// Store sets the value for a key.
func (m *Map) Store(key, value any) {_, _ = m.Swap(key, value)
}func (m *Map) Swap(key, value any) (previous any, loaded bool) {read := m.loadReadOnly()if e, ok := read.m[key]; ok {if v, ok := e.trySwap(&value); ok {if v == nil {return nil, false}return *v, true}}m.mu.Lock()read = m.loadReadOnly()if e, ok := read.m[key]; ok {if e.unexpungeLocked() {// The entry was previously expunged, which implies that there is a// non-nil dirty map and this entry is not in it.m.dirty[key] = e}if v := e.swapLocked(&value); v != nil {loaded = trueprevious = *v}} else if e, ok := m.dirty[key]; ok {if v := e.swapLocked(&value); v != nil {loaded = trueprevious = *v}} else {if !read.amended {// We're adding the first new key to the dirty map.// Make sure it is allocated and mark the read-only map as incomplete.m.dirtyLocked()m.read.Store(&readOnly{m: read.m, amended: true})}m.dirty[key] = newEntry(value)}m.mu.Unlock()return previous, loaded
}func (m *Map) dirtyLocked() {if m.dirty != nil {return}read := m.loadReadOnly()m.dirty = make(map[any]*entry, len(read.m))for k, e := range read.m {if !e.tryExpungeLocked() {m.dirty[k] = e}}
}
2.4 删流程
删除操作,也是首先访问read map 如果存在,如果不存在并且没有缺少数据,则会直接加锁,然后再次读取read map,如果也是存在并且没有缺少,就直接删除(这里是删除的dirty map里面的),在执行missLocked函数(因为没有在read里面找到,所以要让misses +1)然后解锁,然后再判断这个key存不存在,如果存在就删除,不存在就返回失败。(这里删除的read map)
如果存在,且缺失数据。这个时候也会直接删除,这里的删除和上面的删除是一个删除,都是需要进行e.delete
会判断是软删除还是硬删除,如果是硬删除或者nil,就会直接返回错误,然后通过CAS自旋将其删除。
func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {read := m.loadReadOnly()e, ok := read.m[key]if !ok && read.amended {m.mu.Lock()read = m.loadReadOnly()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
}// Delete deletes the value for a key.
func (m *Map) Delete(key any) {m.LoadAndDelete(key)
}func (e *entry) delete() (value any, ok bool) {for {p := e.p.Load()if p == nil || p == expunged {return nil, false}if e.p.CompareAndSwap(p, nil) {return *p, true}}
}
2.5 遍历流程
(1)在遍历过程中,倘若发现 read map 数据不全(amended flag 为 true),会额外加一次锁,并使用 dirty map 覆盖 read map;
(2)遍历 read map(通过步骤(1)保证 read map 有全量数据),执行用户传入的回调函数,倘若某次回调时返回值为 false,则会终止全流程.
这里就不再展开了,直接大家看源码吧。
func (m *Map) Range(f func(key, value any) bool) {// We need to be able to iterate over all of the keys that were already// present at the start of the call to Range.// If read.amended is false, then read.m satisfies that property without// requiring us to hold m.mu for a long time.read := m.loadReadOnly()if read.amended {// m.dirty contains keys not in read.m. Fortunately, Range is already O(N)// (assuming the caller does not break out early), so a call to Range// amortizes an entire copy of the map: we can promote the dirty copy// immediately!m.mu.Lock()read = m.loadReadOnly()if read.amended {read = readOnly{m: m.dirty}copyRead := readm.read.Store(©Read)m.dirty = nilm.misses = 0}m.mu.Unlock()}for k, e := range read.m {v, ok := e.load()if !ok {continue}if !f(k, v) {break}}
}
三.思考
3.1 entry的expunged态
为什么需要使用 expunged 态来区分软硬删除呢?仅用 nil 一种状态来标识删除不可以吗?
首先需要明确,无论是软删除(nil)还是硬删除(expunged),都表示在逻辑意义上 key-entry 对已经从 sync.Map 中删除,nil 和 expunged 的区别在于:
• 软删除态(nil):read map 和 dirty map 在物理上仍保有该 key-entry 对,因此倘若此时需要对该 entry 执行写操作,可以直接 CAS 操作;
• 硬删除态(expunged):dirty map 中已经没有该 key-entry 对,倘若执行写操作,必须加锁(dirty map 必须含有全量 key-entry 对数据).
复用 nil 态软删除的数据
设计 expunged 和 nil 两种状态的原因,就是为了优化在 dirtyLocked 前,针对同一个 key 先删后写的场景. 通过 expunged 态额外标识出 dirty map 中是否仍具有指向该 entry 的能力,这样能够实现对一部分 nil 态 key-entry 对的解放,能够基于 CAS 完成这部分内容写入操作而无需加锁.
3.2 read map和dirty map的数据流转
sync为什么要拿两个map实现
sync.Map 由两个 map 构成:
- read map:访问时全程无锁;
- dirty map:是兜底的读写 map,访问时需要加锁.
之所以这样处理,是希望能根据对读、删、更新、写操作频次的探测,来实时动态地调整操作方式,希望在读、更新、删频次较高时,更多地采用 CAS 的方式无锁化地完成操作;在写操作频次较高时,则直接了当地采用加锁操作完成.
因此, sync.Map 本质上采取了一种以空间换时间 + 动态调整策略的设计思路,下面对两个 map 间的数据流转过程进行详细介绍:
3.2.1 两个 map
- 总体思想,希望能多用 read map,少用 dirty map,因为操作前者无锁,后者需要加锁;
- 除了 expunged 态的 entry 之外,read map 的内容为 dirty map 的子集;
3.2.2 dirty map -> read map
dirty map 覆写 read map
- 记录读/删流程中,通过 misses 记录访问 read map miss 由 dirty 兜底处理的次数,当 miss 次数达到阈值,则进入 missLocked 流程,进行新老 read/dirty 替换流程;此时将老 dirty 作为新 read,新 dirty map 则暂时为空,直到 dirtyLocked 流程完成对 dirty 的初始化;
3.2.3 read map -> dirty map
遍历 read map 填充 dirty map
- 发生 dirtyLocked 的前置条件:I dirty 暂时为空(此前没有写操作或者近期进行过 missLocked 流程);II 接下来一次写操作访问 read 时 miss,需要由 dirty 兜底;
- 在 dirtyLocked 流程中,需要对 read 内的元素进行状态更新,因此需要遍历,是一个线性时间复杂度的过程,可能存在性能抖动;
- dirtyLocked 遍历中,会将 read 中未被删除的元素(非 nil 非 expunged)拷贝到 dirty 中;会将 read 中所有此前被删的元素统一置为 expunged 态.
3.3 适用场景
- sync.Map 适用于读多、更新多、删多、写少的场景;
- 倘若写操作过多,sync.Map 基本等价于互斥锁 + map;
- sync.Map 可能存在性能抖动问题,主要发生于在读/删流程 miss 只读 map 次数过多时(触发 missLocked 流程),下一次插入操作的过程当中(dirtyLocked 流程).