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
}

大致总结一下上述流程:

  1. 在read中查找key,找到了则通过原子操作,尝试更新value

  2. key在read中存在,但是被标记为已删除,则kv加入dirty中,并更新value值

  3. key在read中不存在,但在dirty中存在,则直接在dirty中更新value

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

大致总结一下删除操作的流程:

  1. 在read中查key,找到了则直接删除value(修改entry的指针p,改为指向nil,因为是指针,所以在read和dirty中都是可见的)。

  2. 在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在需要时间会进行数据同步。

参考文章:

posted @ 2022-01-24 13:00  西*风  阅读(1464)  评论(2编辑  收藏  举报