golang sync.Map 源码剖析

223 阅读10分钟

是什么

sync.Map 是 Go 语言标准库 sync 包中的一个线程安全的映射数据结构,用于在多个 goroutine 之间安全地存储和检索键值对

怎么用

package main

import (
	"fmt"
	"sync"
)

func main() {
	// 创建一个 sync.Map
	var m sync.Map

	// 向 sync.Map 中存储键值对
	m.Store("key1", "value1")
	m.Store("key2", "value2")

	// 从 sync.Map 中检索值
	val, ok := m.Load("key1")
	if ok {
		fmt.Println("Value for key1:", val)
	}

	// 删除键值对
	m.Delete("key2")

	// 检查键是否存在
	_, ok = m.Load("key2")
	if !ok {
		fmt.Println("Key2 not found")
	}

	// 使用 Range 迭代所有键值对
	m.Range(func(key, value interface{}) bool {
		fmt.Printf("Key: %v, Value: %v\n", key, value)
		return true // 返回 true 继续迭代,返回 false 停止迭代
	})
}

为什么(解析)

我们首先声明一个该类型的变量,那么这个变量底层的数据结构是如何呢?

sync.Map的数据结构如下

type Map struct{
mu Mutext    //dirty操作的时候需要使用这个锁
read atomic.Value //只读的数据
dirty map[interface{}]*entry//新写的值都放在dirty里,和read有冗余的嫌疑
misses int//read没命中值的次数
}

read的数据结构是

type readOnly struct{
m map[interface{}]*entry
amended bool // 是否有新数据写入dirty,两者数据是否不一致
}

entry的数据结构 里面是一个指针,指向用户存储的value值

type entry struct{
p unsafe.Pointer
}

虽然read 和 dirty 有冗余数据,但这readOnly.m和Map.dirty存储的值类型是*entry,是一个指针,指向同一个数据,所以尽管map的value很大,冗余的空间占用还是有限的(疑问:直接指向value的指针不就可以了,为什么还要结构体再包一层,里面套个指针)
后续:解答上面的为什么包一层,是因为需要read和dirty两者指向同一个地方,这个地方里面的信息在一方修改状态的时候,另一方有感知,假设这里不包一层,read 和dirty分别直接指向value的地址,如果删除的时候,read直接把key对应的p改成nil,说明删除了,但是dirty里面影响不了,还是指向value的地址,后续如果提升dirty到read就会有问题,这个key正常应该是nil删除了,但是dirty赋值给read之后又’活过来‘了,这样显然有问题,所以如果包一层,两个map一直都在监听这个位置,如果read把里面的p置为nil,因为dirty的entry地址和read一样(实际read是从dirty复制来的),所以dirty也感知key删除了,即使提升到read也是给的entry的值,里面的信息和read是一致的,不会有问题

image.png


声明变量之后,往里面存储数据,这一步store方法底层干了什么呢?

store方法的源码如下

// Store sets the value for a key.
func (m *Map) Store(key, value interface{}) {
	read, _ := m.read.Load().(readOnly)
	if e, ok := read.m[key]; ok && e.tryStore(&value) {//如果read里面有或者只是软删除,更新value值
		return
	}

	m.mu.Lock()//到这里有两种情况 1read里面没有 2read里面有key但是硬删除了
	read, _ = m.read.Load().(readOnly)//double check
	if e, ok := read.m[key]; ok {//第2种情况 read有但已经硬删除
		if e.unexpungeLocked() {//这里把硬删除改为nil的软删除
			m.dirty[key] = e//dirty重新增加这个key
		}
		e.storeLocked(&value)//更新e里面的指针指向value的地址
	} else if e, ok := m.dirty[key]; ok {//read里面没有值,dirty里面有值
		e.storeLocked(&value)//直接更新value值
	} else {//read和dirty里面都没有值
		if !read.amended {//这种情况一般是missLocked执行之后,dirty为nil了
			m.dirtyLocked()//把read里面没有删除的数据复制给dirty,清理了删除的数据
			m.read.Store(readOnly{m: read.m, amended: true})//read要标记不一致
		}
		m.dirty[key] = newEntry(value)//把新的key value放到dirty里面
	}
	m.mu.Unlock()
}

总结一下就是先看read,1.如果read有或者软删除就直接更新相应entry里的值,指向新的value的地址2.否则就加锁继续往下,如果read有硬删除,就修改为软删除标记,dirty增加这个key,read和dirty的entry都重新指向新值的地址3.如果read没有,dirty有,更新相应entry对应的值;4.如果都没有,直接在dirty新增,大致这样,当然还有一些细节
Store可能会在某种情况下(初始化或者m.dirty刚被提升后)从m.read中复制数据,如果这个时候m.read中数据量非常大,可能会影响性能,这部分逻辑在dirtyLocked方法里面
storeLocked的源码,就是把value值放在entry结构体中

// The entry must be known not to be expunged.
func (e *entry) storeLocked(i *interface{}) {
	atomic.StorePointer(&e.p, unsafe.Pointer(i))
}
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
		}
	}
}

dirtyLocked这个方法很重,因为里面是会遍历整个map把read里面没有被删除的数据重新复制回dirty里面,走到这个方法的条件有两个 1.missLocked刚执行完,把dirty置为nil了&&2.又来插入一个新的键值对 这两个条件容易出现在写多读少的场景,比如1一般是由大量的插入在dirty里面,read读不到才会miss到阈值,所以说sync.Map不适合写多读少的场景,这个方法的主要作用还有就是清除删除的数据,read里面一些软删除的数据就变为硬删除了,dirty里面不会存放了

如果read确实有并且没有删除,更新read里面key对应的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
      }
   }
}

存储之后通过load方法去检索是否有值

load 源码如下

// Load returns the value stored in the map for a key, or nil if no
// value is present.
// The ok result indicates whether value was found in the map.
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]
	if !ok && read.amended {//如果read里面没有,并且dirty和read数据不一致,可能有
		m.mu.Lock()//加锁,去dirty里面寻找
		read, _ = m.read.Load().(readOnly)//双检查,再次看read里面有没有,因为加锁期间可能read数据有变化
		e, ok = read.m[key]
		if !ok && read.amended {//read没有,dirty可能有
			e, ok = m.dirty[key]
			m.missLocked()//这是一个额外操作,和取值无关,因为read miss了一次,所以这个方法miss++ ,到一定程度,迁移dirty数据到read上,具体看下面方法详解
		}
		m.mu.Unlock()//取完解锁
	}
	if !ok {//有几种情况 1read没取到,dirty没有数据,只查看了read 2read没取到,dirty数据不一致(amended为true)查了dirty也没取到
		return nil, false
	}
	return e.load()//取到,还要看具体value的情况返回,具体看下面发放解释
}

missLocked的源码如下,主要是miss如果过多了,说明每次都是先找read找不到再去dirty寻找,这样找两次,而且还加锁,效率很低,所以需要把dirty的数据给read,这样每次可以直接read找到返回

func (m *Map) missLocked() {
	m.misses++//去dirty找一次,miss加1
	if m.misses < len(m.dirty) {//miss的个数还没有达到dirty中元素的个数,直接返回
		return
	}
	m.read.Store(readOnly{m: m.dirty})//miss已经过多,read存储dirty中的数据
	m.dirty = nil//dirty清空 这里清空是一个必须操作吗为啥
	m.misses = 0//miss清空
}

*entry 的load方法源码如下

func (e *entry) load() (value interface{}, ok bool) {
	p := atomic.LoadPointer(&e.p)//取出指针
	if p == nil || p == expunged {//虽然可以找到key对应的value的地址,但是地址内的数据已经删除了
		return nil, false//返回false表示没有找到
	}
	return *(*interface{})(p), true//返回找到并且返回具体值
}

当想要删除某个key的时候

delete 的源码

// Delete deletes the value for a key.
func (m *Map) Delete(key interface{}) {
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]
	if !ok && read.amended {//如果read没有但是dirty可能有
		m.mu.Lock()
		read, _ = m.read.Load().(readOnly)
		e, ok = read.m[key]
		if !ok && read.amended {//双检查 如果read还是没有 dirty可能有
			delete(m.dirty, key)//删除dirty里面的值,这个应该是硬删除,从这里可以看出如果删除一个不存在的值也不会报错,直接返回的,因为已经加锁了,所以这里可以直接删除掉key,不用考虑临界情况
		}
		m.mu.Unlock()
	}
	if ok {//read里面有值
		e.delete()//软删除,将这个值对应的entry里面的p更新为nil
	}
}
func (e *entry) delete() (value interface{}, ok bool) {
   for {
      p := atomic.LoadPointer(&e.p)
      if p == nil || p == expunged {
         return nil, false
      }
      if atomic.CompareAndSwapPointer(&e.p, p, nil) {
         return *(*interface{})(p), true
      }
   }
}

还有一个迭代功能

range遍历源码如下

func (m *Map) Range(f func(key, value interface{}) bool) {
	read, _ := m.read.Load().(readOnly)
	if read.amended {//如果dirty有新数据
		m.mu.Lock()
		read, _ = m.read.Load().(readOnly)
		if read.amended {//双检查
			read = readOnly{m: m.dirty}//把dirty 的值给read,这里amended默认是false,两者数据一致
			m.read.Store(read)
			m.dirty = nil//清空dirty
			m.misses = 0
		}
		m.mu.Unlock()
	}

	for k, e := range read.m {
		v, ok := e.load()
		if !ok {
			continue
		}
		if !f(k, v) {
			break
		}
	}
}

sync.Map没有Len方法,并且目前没有迹象要加上,所以如果想得到当前Map中有效的entries的数量,需要使用Range方法遍历一次

概括

sync.Map的原理很简单,使用了空间换时间策略,通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。  
通过引入两个map将读写分离到不同的map,其中read map提供并发读和已存元素原子写,而dirty map则负责读写。  
这样read map就可以在不加锁的情况下进行并发读取,当read map中没有读取到值时,再加锁进行后续读取,并累加未命中数。  
当未命中数大于等于dirty map长度,将dirty map上升为read map。  
从结构体的定义可以发现,虽然引入了两个map,但是底层数据存储的是指针,指向的是同一份值。

image.png

思考
1.比普通的map好在哪里
举例 如果对map同时进行读a写b的操作
普通map因为不能并发读取,所以即使是对不同元素进行读写操作,也要在同一个队列里面。通过加锁来实现读写顺序执行
sync.map 优化点在于这是两个不相干的行为,通过底层两个map分离开同时进行,两个同时请求的时候,a在read里面读到直接返回,写b在read里面读不到,(同时都是读操作,这里不会阻塞)就去dirty里面写,即使加锁也不影响a

2.假设一个map一直只写,那么逻辑是 read里面没有,dirty里面也没有,最后加锁写在dirty里面read里面没有东西,和普通map差不多
3.假设一半读一半写,读read没有就去dirty读,miss次数达到条件,就是把dirty提升到read里面,这样读都从read里面取,完全不会被锁阻塞(这里凸显比普通map优秀的一点),除非读的是新写进dirty的值,这些还是和普通map一样需要进入读写加锁阻塞的流程,但是miss达到阈值,提升到read,这些元素又可以读写操作锁free了,如此反复
记住
1.只要涉及dirty,就和普通map一样要读写加锁了
2.只要上升到read,读取和更新值 都实现了锁free \

image.png

image.png

image.png 软删除的好处是重新插入的时候不需要加锁,代价比较小,数据在dirty里面还是存在的更新一下指向的value地址就可以,硬删除是需要加锁的

image.png 可以看出软删除可以通过无锁化的cas来完成更新,CAS就是compareandswap,而expunged硬删除dirty已经没有这个key了,不具有指向该entry的能力了

底层有闭环的两个map相互复制的情况,dirty在读miss达到阈值的时候会复制给read,dirty置nil,这个在missLocked方法里面

image.png

当插入新数据,dirty为nil时,需要遍历read把不删除的数据复制给dirty,并且软删除设置为硬删除,新数据插入到dirty中,这一步有一个作用就是清除那些删除的数据内存

image.png

image.png

这个视频讲的很透彻 www.bilibili.com/video/BV1uk…