七叶笔记 » golang编程 » Go语言学习——sync.map源码剖析

Go语言学习——sync.map源码剖析

1.简介
最近看了下Sync包,详读了sync.map 源码 ,感觉源码实现还是比较巧妙的,有不少可以学习的地方;在讲源码前,先看下sync.map的”历史”,从网上搜资料,sync.map是Go语言在1.9版本才引入的并发安全的map,对此,有些同学心中可能会有个疑问,如果是支持并发,为什么不采取锁map的方式,为啥还要在单独搞个sync.map结构呢?我们先看下锁map存在的问题:

参考:go语言中文文档:www.topgoer.com

转自:#reply0
1)mutex + map
最简单的方案就是在map上加个锁,针对map的所有操作都要提前加锁,其存在问题也很明显,锁竞争会非常频繁;
2)rwmutex + map
优化一点,依据场景,如果是读操作多于写操作,可以把mutex换成rwmutex,相比方案一,有一定优化、至少读读之间不会存在互斥,不过,读写之间还会存在阻塞;
根据锁map的优化迭代方案可知,在读读场景下,rwmutex + map可以并发、不存在阻塞,但是,读写还是存在阻塞,而sync.map要做的事情就是能进一步优化:对于map的各种操作,尽可能不阻塞;为此,sync.map采用了两级缓存实现,一级缓存做无锁并发, 二级缓存 做有锁并发,如:

对上图说明两点:
1)针对sync.map的各种操作,都先经过一级缓存,一级缓存采用无锁的方式,只要不出现击穿,即key都在一级缓存中可以找到,则就不会访问到二级缓存;
2)一级缓存和二级缓存之间存在数据同步,二级缓存数据相对更全一些,所以当一级缓存数据比较久时,可以将二级缓存数据同步一下,该情况是在读击穿时处理;在不击穿的前提下,一级缓存中可能有数据删除,数据移除情况也要同步给二级缓存,清除废弃数据、减少空间占用,该情况是在写击穿并且是一、二级缓存都不存在键的情况处理,总之,同步的原则是:一级缓存数据尽可能新;一级缓存数据只能是二级缓存的子集;

2.实现
sync.map的优势是理想情况下以无锁代替有锁、提高性能,但存在击穿后不得不加锁的问题,一旦击穿进入二级缓存,就要进行锁操作了,所以sync.map不太适用于写多读少以及频繁创建新键的情况;因为要考虑击穿问题,所以sync.map的实现也是围绕击穿考虑的。 2.1读操作
读操作比较简单,步骤是:
1)查看一级缓存中是否有key,有就返回对应value;
2)如果没有则进入读击穿,加锁后,在复看一级缓存中是否有key(复看是因为存在二级缓存向一级缓存同步数据的情况),有就返回对应value;
3)如果没有则看二级缓存中有没有,有就返回对应value,此时出现读击穿,会进入读击穿保护机制——击穿达到一定次数,会将二级缓存数据同步到一级缓存;
需要注意的是,在返回value时要检测value的有效性,如果已经废弃(expunged状态),则不用返回。
2.2写操作
写操作相对复杂,根据key是否存在的情况,可以分为create和update,步骤是:
1)查看一级缓存中是否有key,有就尝试更新,之所以是尝试是因为还要检查数据是否已经废弃,如果已经废弃,即使key在一级缓存中存在,也是击穿效果,因为二级缓存中没有;
2)如果一级缓存操作失败,加锁后,在复看一级缓存,如果有key,则更新value,并检测value是否为废弃状态,如果是,则将key、value写入二级缓存;
3)如果一级缓存中一直没有key,但二级缓存中有,则直接更新数据;
4)如果一级缓存和二级缓存都没有key,则将key、value写入二级缓存,此时会尝试将一级缓存数据同步给二级缓存,用于删除废弃数据(将一级缓存中的删除数据设置为expunged状态),因为只有该情况下,一级缓存数据可能是二级缓存数据的子集,所以当插入全新的key时,才会尝试更新缓存数据、移除废弃数据;
2.3删除操作
删除采取的是延迟删除操作,对于待删除数据,其value先设置为nil,优先从一级缓存删除,如果一级缓存没有,再去二级缓存中删除。
2.4源码
以1.14.4版本为例,处理源码是:

 func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]

        // 一级缓存没有查到key,加锁、复查,amended用于判断一级缓存和二级缓存是否一致
    if !ok && read.amended {
        m.mu. Lock ()

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

                // 一级缓存还是没有查到key,则击穿进入二级缓存
        if !ok && read.amended {
            e, ok = m.dirty[key]
            m.missLocked()     // 读击穿保护,根据击穿次数决定是否要同步数据
        }

        m.mu.Unlock()
    }

    if !ok {
        return nil, false
    }

    return e.load()
}

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 {
        // 一级缓存中有key,则更新value,同时,还要查看value是否已经废弃,如果废弃还要将数据写入二级缓存,确保下次同步前,二级缓存数据的完整性,因为操作到二级缓存,所以需要放在锁操作下;这也是为什么tryStore只是尝试存储
        if e.unexpungeLocked() {
            m.dirty[key] = e
        }
        e.storeLocked(&value)
    } else if e, ok := m.dirty[key]; ok {
        e.storeLocked(&value)
    } else {
        // 对于key完全不存在的情况,尝试数据同步,从一级缓存到二级缓存
        if !read.amended {
            m.dirtyLocked()        // 数据同步时,废弃数据不会同步,废弃数据会设置为expunged状态
            m.read.Store(readOnly{m: read.m, amended: true})
        }
        m.dirty[key] = newEntry(value)
    }
    m.mu.Unlock()
}

// Delete部分相对简单,主要是将value设置为nil
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()
    }
}

func (e *entry) delete() (hadValue bool) {
    for {
        p := atomic.LoadPointer(&e.p)
        if p == nil || p == expunged {
            return false
        }
        if atomic.CompareAndSwapPointer(&e.p, p, nil) {
            return true
        }
    }
}
  

3.总结
sync.map是以无锁操作一级缓存的方式支持并发、提高性能,而根据其实现可知,sync.map适用于读多、更新多、新建少的场景(新建情况下,可能会带来较大的开销,比如:读击穿、数据刚从二级缓存同步到一级缓存后,又要新建key,数据又要反向同步一次)。

相关文章