基础筑基

在大多数语言中原始map都不是一个线程安全的数据结构,那如果要在多个线程或者goroutine中对线程进行更改就需要加锁,除了加1个大锁,不同的语言还有不同的优化方式, 像在java和go这种语言其实都采用的是链表法来进行map的实现,本文也主要分析这种场景

并发安全的map实现的三种方式

在go语言中实现多个goroutine并发安全访问修改的map的方式,主要有如下三种:

实现方式 原理 适用场景
map+Mutex 通过Mutex互斥锁来实现多个goroutine对map的串行化访问 读写都需要通过Mutex加锁和释放锁,适用于读写比接近的场景
map+RWMutex 通过RWMutex来实现对map的读写进行读写锁分离加锁,从而实现读的并发性能提高 同Mutex相比适用于读多写少的场景
sync.Map 底层通分离读写map和原子指令来实现读的近似无锁,并通过延迟更新的方式来保证读的无锁化 读多修改少,元素增加删除频率不高的情况,在大多数情况下替代上述两种实现

上面三种实现具体的性能差异可能还要针对不同的具体的业务场景和平台、数据量等因此来进行综合的测试,源码的学习更多的是了解其实现细节,以便在出现性能瓶颈的时候可以进行分析,找出解决解决方案

map的容量问题

image.png 在Mutex和RWMutex实现的并发安全的map中map随着时间和元素数量的增加、删除,容量会不断的递增,在某些情况下比如在某个时间点频繁的进行大量数据的增加,然后又大量的删除,其map的容量并不会随着元素的删除而缩小,而在sync.Map中,当进行元素从dirty进行提升到read map的时候会进行重建,可能会缩容

无锁读与读写分离

image.png

读写分离

并发访问map读的主要问题其实是在扩容的时候,可能会导致元素被hash到其他的地址,那如果我的读的map不会进行扩容操作,就可以进行并发安全的访问了,而sync.map里面正是采用了这种方式,对增加元素通过dirty来进行保存

无锁读

通过read只读和dirty写map将操作分离,其实就只需要通过原子指令对read map来进行读操作而不需要加锁了,从而提高读的性能

写加锁与延迟提升

image.png

写加锁

上面提到增加元素操作可能会先增加大dirty写map中,那针对多个goroutine同时写,其实就需要进行Mutex加锁了

延迟提升

上面提到了read只读map和dirty写map, 那就会有个问题,默认增加元素都放在dirty中,那后续访问新的元素如果都通过 mutex加锁,那read只读map就失去意义,sync.Map中采用一直延迟提升的策略,进行批量将当前map中的所有元素都提升到read只读map中从而为后续的读访问提供无锁支持

指针与惰性删除

image.png

map里面的指针

map里面存储数据都会涉及到一个问题就是存储值还是指针,存储值可以让 map作为一个大的的对象,减轻垃圾回收的压力(避免扫描所有小对象),而存储指针可以减少内存利用,而sync.Map中其实采用了指针结合惰性删除的方式,来进行 map的value的存储

惰性删除

惰性删除是并发设计中一中常见的设计,比如删除某个个链表元素,如果要删除则需要修改前后元素的指针,而采用惰性删除,则通常只需要给某个标志位设定为删除,然后在后续修改中再进行操作,sync.Map中也采用这种方式,通过给指针指向某个标识删除的指针,从而实现惰性删除

源码分析

数据结构分析

Map

type Map struct {
    mu Mutex
    // read是一个readOnly的指针,里面包含了一个map结构,就是我们说的只读map对该map的元素的访问
    // 不需要加锁,只需要通过atomic加载最新的指针即可
    read atomic.Value // readOnly

    // dirty包含部分map的键值对,如果要访问需要进行mutex获取
    // 最终dirty中的元素会被全部提升到read里面的map中
    dirty map[interface{}]*entry

   // misses是一个计数器用于记录从read中没有加载到数据
    // 尝试从dirty中进行获取的次数,从而决定将数据从dirty迁移到read的时机
    misses int
}

readOnly

只读map,对该map元素的访问不需要加锁,但是该map也不会进行元素的增加,元素会被先添加到dirty中然后后续再转移到read只读map中,通过atomic原子操作不需要进行锁操作

type readOnly struct {
    // m包含所有只读数据,不会进行任何的数据增加和删除操作
    // 但是可以修改entry的指针因为这个不会导致map的元素移动
    m       map[interface{}]*entry
    // 标志位,如果为true则表明当前read只读map的数据不完整,dirty map中包含部分数据
    amended bool 
}

entry

entry是sync.Map中值得指针,如果当p指针指向expunged这个指针的时候,则表明该元素被删除,但不会立即从map中删除,如果在未删除之前又重新赋值则会重用该元素

type entry struct {
  // 指向元素实际值得指针
    p unsafe.Pointer // *interface{}
}

数据的存储

image.png

2.2.1 元素存在只读map

元素如果存储在只读map中,则只需要获取entry元素,然后修改其p的指针指向新的元素就可以了,因为是原地操作所以map不会发生变化

    read, _ := m.read.Load().(readOnly)
    if e, ok := read.m[key]; ok && e.tryStore(&value) {
        return
    }

元素存在进行变更后的只读map中

如果此时发现元素存在只读 map中,则证明之前有操作触发了从dirty到read map的迁移,如果此时发现存在则修改指针即可

    read, _ = m.read.Load().(readOnly)
    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.
            // 如果key之前已经被删除,则这个地方会将key从进行nil覆盖之前已经删除的指针
            // 然后将它加入到dirty中
            m.dirty[key] = e
        }
        // 调用atomic进行value存储
        e.storeLocked(&value)
    }

元素存在dirty map中

如果元素存在dirty中其实同read map逻辑一样,只需要修改对应元素的指针即可

    } else if e, ok := m.dirty[key]; ok {
        // 如果已经在dirty中就会直接存储
        e.storeLocked(&value)
    } else {

元素之前不存在

如果元素之前不存在当前Map中则需要先将其存储在dirty map中,同时将amended标识为true,即当前read中的数据不全,有一部分数据存储在dirty中

        // 如果当前不是在修正状态
        if !read.amended {               
            // 新加入的key会先被添加到dirty map中, 并进行read标记为不完整
            // 如果dirty为空则将read中的所有没有被删除的数据都迁移到dirty中
            m.dirtyLocked()
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        m.dirty[key] = newEntry(value)

数据迁移

read map到dirty map的迁移

image.png 在刚初始化和将所有元素迁移到read中后,dirty默认都是nil元素,而此时如果有新的元素增加,则需要先将read map中的所有未删除数据先迁移到dirty中

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
        }
    }
}

dirty到read map的迁移

image.png 当持续的从read访问穿透到dirty中后,就会触发一次从dirty到read的迁移,这也意味着如果我们的元素读写比差比较小,其实就会导致频繁的迁移操作,性能其实可能并不如rwmutex等实现

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
}

数据加载

image.png

只读无锁

Load数据的时候回先从read中获取,如果此时发现元素,则直接返回即可

   read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]

加锁读取read和dirty

加锁后会尝试从read和dirty中读取,同时进行misses计数器的递增,如果满足迁移条件则会进行数据迁移

       read, _ = m.read.Load().(readOnly)
        e, ok = read.m[key]
        if !ok && read.amended {
            e, ok = m.dirty[key]
            // 这里将采取缓慢迁移的策略
            // 只有当misses计数==len(m.dirty)的时候,才会将dirty里面的数据全部晋升到read中
            m.missLocked()
        }

数据删除

image.png 数据删除则分为两个过程,如果数据在read中,则就直接修改entry的标志位指向删除的指针即可,如果当前read中数据不全,则需要进行dirty里面的元素删除尝试,如果存在就直接从dirty中删除即可

func (m *Map) Delete(key interface{}) {
    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 {
            delete(m.dirty, key)
        }
        m.mu.Unlock()
    }
    if ok {
        e.delete()
    }
}

mutex与加锁后的read map重复读

因为mutex互斥的是所有操作,包括dirty map的修改、数据的迁移、删除,如果在进行m.lock的时候,已经有一个提升dirty到read操作在进行,则执行完成后dirty实际上是没有数据的,所以此时要再次进行read的重复读