一文搞懂sync.Map的前世今生

数据结构 同时被 2 个专栏收录
2 篇文章 0 订阅
1 篇文章 0 订阅

一、从并发不安全的map聊起

在golang的官方的文档中已经提到内建的map不是线程(goroutine)安全的。

典型的场景:2个协程同时进行读和写

func main() {
	m := make(map[int]int)
	go func() {				//开一个协程写map
		for i := 0; i < 1000; i++ {
			m[i] = i
		}
	}()

	go func() {				//开一个协程读map
		for i := 0; i < 1000; i++ {
			fmt.Println(m[i])
		}
	}()
	for {
		;
	}
}
fatal error: concurrent map read and map write

哪怕是读写的键不相同,而且map也没有"扩容"等操作,代码还是会报错。

例如:一个协程只读一个键,另外一个协程只写另外一个键

func main() {
	m := make(map[int]int)
	go func() {
		for {
			_ = m[0]
		}
	}()
	go func() {
		for {
			m[1] = 1
		}
	}()
	//
	time.sleep(time.Second*20)
}

二、go #1.9之前的解决方案

在golang 1.9之前的解决方案是额外绑定一个锁,封装成一个新的struct或者单独使用互斥锁都可以。

在Go官方blog的Go maps in action一文中,提供了嵌入map的读写锁的解决方案。

var counter = struct{
    sync.RWMutex
    m map[string]int
}{m: make(map[string]int)}
//读数据
counter.RLock()
n := counter.m["key"]
counter.RUnlock()
//写数据
counter.Lock()
counter.m["key"]=value
counter.Unlock()

三、sync.Map的引入 (golang1.9后的新特性)

go 1.9之后,新增了sync.Map,是并发安全的,效率也很高,适合读多写少的业务场景
阅读sync.Map的源码,发现 具有以下的优点,

  • 通过冗余的手段,冗余两个数据结构(read、dirty)优化加锁对性能的影响
  • 内部引入大量double checking(双重检测)的机制
  • 动态调整,当read miss次数达到len(dirty)后,将dirty提升为read
  • 延迟删除,删除某个key时,先进行逻辑删除(标记),只有dirty提升为read时才真正进行物理删除
  • 优化read的读取、更新,对read的读取不加锁

四、sync.Map的源码分析

4.1 sync.Map的数据结构

    type Map struct {
    //互斥锁,用于dirty数据操作
	mu Mutex

	//只读的数据结构,不会有读写冲突
	// Entries stored in read may be updated concurrently without mu, but updating a previously-expunged entry requires that the entry be copied to the dirty
	//当entries处于未删除状态(unexpunged),不需要加锁,如果entries已经删除(previously-expunged ),需要加锁,以便下次更新dirty
	read atomic.Value // readOnly

	//1、dirty数据包含了当前map最新的entries
	//2、提升dirty为read时不需要一个个复制,而是利用原子操作将这个数据结构作为read的一部分
	//3、对于dirty的操作需要加锁,对它的操作可能面临读写竞争
	//4、dirty为空时,可能是刚初始化或者刚被提升,下一次操作将从read中处于未删除状态的数据复制到dirty中。
	dirty map[interface{}]*entry
	
	//当从read中读取数据时,如果read不包含相应的数据,将尝试加锁从dirty中读取,无论读取是否成功,miss均加一
	//当len(dirty)==misses,将dirty提升变成read
	misses int
}

总而言之,该数据结构以空间换时间,冗余了read、dirtydirty中包含read已经逻辑删除的entries,以及新加入的entries。

read对应的DataStruct为:

    // readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
	m       map[interface{}]*entry
	//amended为true时,说明dirty中有readOnly未包含的数据,所以如果从read找不到数据的话,设置该值为true,要进一步到dirty中查找。
	amended bool // true if the dirty map contains some key not in m.
}

这里有个问题可能你也想到了,如果数据量过于庞大,由于冗余将占用过多的空间。
实际上,尽管read、dirty之间互相冗余,但是两者数据之间通过指针指向同一块内存地址,减少了内存的浪费。

    // An entry is a slot in the map corresponding to a particular key.
type entry struct {
	p unsafe.Pointer // *interface{}
}

readOnly.mdirty存储的value类型是*entry,它包含一个指针p, 指向用户存储的value值,节约了内存的消耗。

4.2 聊聊Double-Checking

前面说到,sync.Map内部引入大量double checking(双重检测)的机制,这里简单介绍一下。关于double checking,最典型的是java中的单例模式的双重检测实现,如下:

public class Instance{
	private Instance(){}
	private static Instance instance;
	public static volatile Instance getInstance(){
	if(instance!=null){
		return instance;
	synchronized(Instance.class){
		if(instance == null){
			instance=new Instance();
			}
		}
	}
	
}

为什么需要双重检测,原因在于,如果只是简单地进行一次判断

if(instance==null){
		synchronized{

这两个语句并不是原子的,同时从另外一个角度考量
外层的判断语句直接返回对象也可以避免在多线程情况下,当变量已经初始化时,直接返回对象,避免其他线程等待的问题。

if(instance!=null){
return instance;

4.3 sync.Map的load函数:加载数据

// Load returns the value stored in the map for a key, or nil if no
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
//首先依赖atomic的原子操作Load获取一个只读的readOnly数据结构
	read, _ := m.read.Load().(readOnly)
	//查找readOnly中的数据,不需要加锁。
	e, ok := read.m[key]
	//如果没找到,且amended为true即dirty中有新数据,从dirty中查找,并且加锁。
	if !ok && read.amended {
		m.mu.Lock()
		//再次获取readOnly,double-checking,避免加锁的时候m.dirty被提升为m.read,这个时候m.read已经不对应。
		read, _ = m.read.Load().(readOnly)
		e, ok = read.m[key]
		if !ok && read.amended {
			e, ok = m.dirty[key]
			//不管e是否存在,misses计数均加一,当满足条件后提升dirty
			m.missLocked()
		}
		m.mu.Unlock()
	}
	if !ok {
		return nil, false
	}
	return e.load()
}

需要注意的是:

e, ok := read.m[key]
if !ok && read.amended {
		m.mu.Lock()

这几个语句也不是原子执行的,在高并发的情况下,存在一种场景:当!ok && read.amended条件满足,但是在加锁之前,m.dirty可能被提升为m.read,此时read已经被改变,所以加锁后还要再检查m.read。

4.4 missLocked函数:dirty如何提升为read

func (m *Map) missLocked() {
	m.misses++
	if m.misses < len(m.dirty) {
		return
	}
	//当满足一定条件时,调用store函数进行提升
	m.read.Store(readOnly{m: m.dirty})
	//m.dirty清空
	m.dirty = nil
	//重新计数
	m.misses = 0
}

4.5 store函数:更新/增加一个entry

// Store sets the value for a key.
func (m *Map) Store(key, value interface{}) {
//获取readOnly
	read, _ := m.read.Load().(readOnly)
//如果m.read存在这个键,并且这个entry没有被标记删除,直接存储。
	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() {
			// The entry was previously expunged, which implies that there is a non-nil dirty map and this entry is not in it.
			//标记成未被删除,往dirty中添加数据
			m.dirty[key] = e
		}
		//更新
		e.storeLocked(&value)
		//dirty中存在这个k,更新数据
	} else if e, ok := m.dirty[key]; ok {
		e.storeLocked(&value)
	} else {
	//dirty中无新的数据,往dirty中加入一个新key
		if !read.amended {
	//从read中复制数据	
			m.dirtyLocked()
			m.read.Store(readOnly{m: read.m, amended: true})
		}
		//将entry加入到dirty中
		m.dirty[key] = newEntry(value)
	}
	//解锁
	m.mu.Unlock()
}

4.6 Delete函数:删除一个entry

    // Delete deletes the value for a key.
func (m *Map) Delete(key interface{}) {
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]
	//从dirty中删除数据,直接删除
	if !ok && read.amended {
		m.mu.Lock()
		read, _ = m.read.Load().(readOnly)
		e, ok = read.m[key]
		//double-checking
		if !ok && read.amended {
			delete(m.dirty, key)
		}
		m.mu.Unlock()
	}
	//从read删除数据,打标记
	if ok {
		e.delete()
	}
}

从read删除数据的情况

func (e *entry) delete() (hadValue bool) {
	for {
		p := atomic.LoadPointer(&e.p)
		//检测是否已经标记过
		if p == nil || p == expunged {
			return false
		}
		//原子操作,e.p标记为nil
		if atomic.CompareAndSwapPointer(&e.p, p, nil) {
			return true
		}
	}
}

4.7 Range函数:遍历

使用sync.Map 无法利用内建特性for... range进行遍历,只能通过函数进行遍历

func (m *Map) Range(f func(key, value interface{}) bool) {
	read, _ := m.read.Load().(readOnly)
	//如果m.dirty中有新数据,先升级dirty后再遍历
	if read.amended {
		// m.dirty contains keys not in read.m. Fortunately, Range is already O(N)
		m.mu.Lock()
		read, _ = m.read.Load().(readOnly)
		//double-checking双重检测
		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
		}
	}
}

五、总结

1、 sync.Map的核心思想是用空间换时间,用两个map来存储数据(read和dirty),read支持原子操作,操作一般不加锁,可以看作是dirty 的cache;
2、 sync.Map的4种操作
**Load:**先到read中读取,如果有则直接返回结果,如果没有或者是被删除,则到dirty加锁中读取,如果有则返回结果,无论是否存在均更新miss
Store:

  • [ 1] 更新:先到read中看看有没有,如果有直接更新key,如果没有则到dirty中更新
  • [ 2]增加key:直接增加到dirty中
    Delete:先到read中看看有没有,如果有则直接更新为nil,如果没有则到dirty中直接删除
    Range::遍历sync.Map,可能涉及提升操作

3、map、sync.Map,map(RWMutex)

  • go中的map不是并发安全的
  • go1.9版本之前,可以使用map+mutex的方式实现并发安全,但是每次操作,无论读取都要加锁,性能不太好,可以将map和mutex(rwmutex)包装成一个结构体,优化性能。
  • go 1.9之后,新增了sync.Map,是并发安全的,效率较高,适用于读多写少的场景,用两个map来存储数据(read和dirty),read支持原子操作,操作一般不加锁,可以看作是dirty 的cache,dirty加锁。

六、分段锁

  • 熟悉java的程序员大概都知道juc中的ConcurrentHashMap, 内部使用多个锁,每个区间共享一把锁,这样减少了全局共享一把锁带来的性能影响。
  • 实际上,sync.Map并不优雅,尽管用两个数据结构实现了读写分离,但是全局只有一把锁,在高并发的写场景下多个线程(goroutine)争抢一把锁,无异于直接粗暴地给map直接加mutex,
  • 故类似java的ConcurrentHashMap的实现(shard的思想),可以对key进行分段,一个段内使用一个锁,这样操作不同的key时,避免锁的阻塞开 销,大大提高效率,orcaman提供了这个思路的一个实现: concurrent map,感兴趣的同学可以移步github

但是对于go官方,目前还没有支持这样一种数据结构。

  • 0
    点赞
  • 0
    评论
  • 1
    收藏
  • 一键三连
    一键三连
  • 扫一扫,分享海报

©️2020 CSDN 皮肤主题: 书香水墨 设计师:CSDN官方博客 返回首页
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、C币套餐、付费专栏及课程。

余额充值