Golang sync.Map 原理解析

1,438 阅读12分钟

一起养成写作习惯!这是我参与「掘金日新计划 · 4 月更文挑战」的第20天,点击查看活动详情

之前的文章中我们了解了 Mutex 的原理,其实互斥锁基本可以认为是并发的根基,sync 包下的很多能力都是基于锁来支持的,只不过单独一把 Mutex 未免粗暴,粒度太大。我们更希望在不同的场合把锁的粒度拆细,这样才能提供更高的性能。RWMutex 就是在这个 Mutex 上针对读锁进行优化的产物。

今天我们来看另一个案例 sync.Map 实现的原理。

我们知道,Golang 原生的 map 是不支持并发的,如果你硬要用多个 goroutine 并发读写 map,会得到 panic。

比如下面这个case:

func main() {
   m := make(map[int]int)
   go func() {
      for {
         _ = m[1]
      }
   }()
   go func() {
      for {
         m[2] = 2
      }
   }()
   select {} // 维持主goroutine
}

执行这段程序,你会得到:fatal error: concurrent map read and map write

对此常见的解决方案有两种

  • 使用 Go 1.9 之后引入的 sync.Map 实现,针对读多写少的场景做了优化;
  • 原生 map 加上一把锁(Mutex 或 RWMutex)。

直接加锁的方案容易粒度过大,导致性能问题(底层大量的 readerCount,readerWait 更新,在多核的场景下开销不小)。今天我们来看看 sync.Map 是怎么解决并发问题的。

原生 map 如何识别并发

原理很简单:map 内置了标志位,语义是当前有一个goroutine正在写入,

hashWriting  = 4 // a goroutine is writing to the map

在多个涉及 map 读逻辑处均进行了校验:

if h.flags&hashWriting != 0 {
        throw("concurrent map read and map write")
}

而在 mapassign 的逻辑中,h.flags 会被标记上 hashWriting。

if h.flags&hashWriting != 0 {
        throw("concurrent map writes")
}
hash := t.hasher(key, uintptr(h.hash0))

// Set hashWriting after calling t.hasher, since t.hasher may panic,
// in which case we have not actually done a write.
h.flags ^= hashWriting

此处这个 h 就是 hmap 的指针,即 Golang 原生 map 对应的底层结构体。

接口能力

sync.Map 提供了下面几个接口:

type mapInterface interface {
      Load(interface{}) (interface{}, bool)
      Store(key, value interface{})
      LoadOrStore(key, value interface{}) (actual interface{}, loaded bool)
      LoadAndDelete(key interface{}) (value interface{}, loaded bool)
      Delete(interface{})
      Range(func(key, value interface{}) (shouldContinue bool))
}
  • Load: 读操作,可以理解为 Get。传入一个 key,返回 Map 中存储的值,如果没有找到则返回 nil;
  • Store: 相当于一个 Set。将键值对存入 Map;
  • LoadOrStore:若 key 存在就返回对应的 value,若不存在则将入参的值存入 value;
  • LoadAndDelete: 删除指定的键值对并返回原来的 value;
  • Delete: 删除键值对;
  • Range:遍历器,允许开发者传入一个func(key, value any) bool 的函数,遍历键值对。

适合的场景

image.png 从 Go 官方文档可见,两种场景最适合用 sync.Map,可以显著减小锁竞争(与Mutex和 RWMute相比)

  • 写入一次后读取多次,如缓慢增长的 map;
  • 并发读写不相交的键值对。

设计思路

存储结构

type Map struct {
    mu Mutex
    read atomic.Value // readOnly
    dirty map[interface{}]*entry
    misses int
}
type readOnly struct {
    m  map[interface{}]*entry
    amended bool   // true if the dirty map contains some key not in m.
}
type entry struct {
    p unsafe.Pointer // *interface{}
}

// expunged is an arbitrary pointer that marks entries which have been deleted from the dirty map.
var expunged = unsafe.Pointer(new(interface{})) 

上面不到 20 行的代码就是 sync.Mutex 的底层存储结构。跟上一篇 RWMutex 一样,我们先不着急直接看源码,而是顺着思路想一想怎样实现。

我们希望自己的 Map,是支持并发读写的。但是还希望尽可能不要用一把大锁。那应该怎么样呢?

并发写肯定是需要加锁的,否则无法控制数据更新的时序。这里少不了。

但是读,我们是希望可以不依赖锁的,如果你不改数据,多个 goroutine 一起来读也没什么。这也是为什么 Map 里面会有一个 read atomic.Value 的存在。

atomic.Value 本身也是个容器,我们可以对其进行 Load 和 Store,底层要实现对外的键值对读取,肯定还是需要依赖一个原生的 map,这就是 readOnly 结构。只要我们保证对于 readOnly 中的 map 不会出现并发读写,让 readOnly 作为 immutable(不可修改),就能复用 atomic.Value 的能力去支持并发度。

对外我们的语义上来说只有一个 map[interface{}]interface{} 的存在。但如果不拆,基于同一个原生 map 去并发支持读写,势必需要一把大锁,这样就回归了 RWMutex 的方案。

那拆开又应该怎么设计呢?我们可以用的资源是,一个互斥锁 Mutex,一个用 atomic.Value 外壳包裹着的 map[any]*entry(我们指望它来提供并发读),一个没有包裹的map[any]*entry(希望支持并发写)。

毕竟是两个 map,且 atomic.Value readOnly 下的 map 我们希望是 immutable 的,不去承载并发写的量。那么很简单:map[any]*entry 搭配互斥锁来支持并发现,atomic.Value 来支持并发读。

整体思想

  • read 就是我们的读 map,dirty 作为写map。正常情况下,我们希望绝大部分的读都落到 read,用一次 atomic.Value.Load 拿出来 readOnly 结构,然后去原生 map 里找到 key 就ok。如果找不到,会fallback到 dirty 中继续查找;

  • 用 misses 记录 read 中没有找到 key 的次数。一旦过多,就整体将 dirty 拷贝给 read(组装一个新的 readOnly,并用 atomic.Store 更新);

三个为性能考虑的思路:

  • 当发生过一次 dirty 拷贝至 read 之后,将 dirty 置为 nil。此后继续,读走 read,写走 dirty。当有写操作来的时候,发现 dirty 已经变成 nil 了,此时会逐个从 read 把 kv 拷贝过来,然后触发写操作。
  • readOnly 中的 amended 用来标识是否 dirty 中包含一些读 map 中没有的key,用于提升性能;
  • entry 只包含一个指针,本质是一个 *interface{},指向真实的数据。这样当我们需要更新时,直接 CAS 更换指针即可,无需修改原值。此处map[interface{}]*entry 的设计还是很精妙的。

这里你会发现一个问题:

设计上,我们希望 dirty 拥有的是最全的数据,毕竟历史的写入数据它都有,新的写操作也会到这里,只有在 miss过多了,dirty 才会同步给 read。可以理解为,read 只是我们拿出来一部分键值对,尽可能希望靠他们来承担并发读,优化【读多写少】的场景。

那么为什么?dirty 给 read 赋值完了以后,还要把自己变成 nil ?等下次写操作 Store 来的时候,再一个个去 read 那边读键值对,把自己还原呢?这样岂不是很耗费时间?

这里的原因我并没有找到比较信服的解释,个人感觉更多的是一个设计上的 tradeoff,毕竟 sync.Map 的优化场景是【读多写少】,既然数据都在 read 有了,且【写】的可能性不大,那么没必要去占用双倍的内存空间。大不了下次来的时候重新拷贝。可能在设计者的眼中,这点性能消耗,相比内存占用来说是可以接受的。

源码解析

回顾一下上一节我们看到的 Map 的结构

type Map struct {
    mu Mutex
    read atomic.Value // 对应到下面的 readOnly 结构
    dirty map[interface{}]*entry
    misses int
}
type readOnly struct {
    m  map[interface{}]*entry
    amended bool   // 若dirty 包含 m中没有的key,则为true
}
type entry struct {
    p unsafe.Pointer // 指向真实数据的指针
}
var expunged = unsafe.Pointer(new(interface{})) // 一个指针,用来标记删除态
  • mu:Mutex 锁,操作 dirty map 的时候需要加锁。
  • read:存放只读数据。因为是 atomic.Value 类型,只读,所以并发是安全的。实际存的是 readOnly 的数据结构,需要用 interface{} 进行转换。
  • amended:标记dirty和readOnly.m中的数据是否一致,不一致时为true。
  • misses:计数作用。每次读取操作时,若 read 中读失败,则计数+1。
  • dirty:存放读写数据/全量数据,就是一个原生 map,操作时需要加锁。
  • entry:readOnly.m 和 dirty 中 map 存储的值类型是 *entry,entry 中是一个指针,指向真正值的地址,从而避免了 value 数据多份存储时的空间浪费。

Load

func (m *Map) Load(key any) (value any, ok bool) {
	read, _ := m.read.Load().(readOnly)
	e, ok := read.m[key]
	if !ok && read.amended {
		m.mu.Lock()
		// Avoid reporting a spurious miss if m.dirty got promoted while we were
		// blocked on m.mu. (If further loads of the same key will not miss, it's
		// not worth copying the dirty map for this key.)
		read, _ = m.read.Load().(readOnly)
		e, ok = read.m[key]
		if !ok && read.amended {
			e, ok = m.dirty[key]
			// Regardless of whether the entry was present, record a miss: this key
			// will take the slow path until the dirty map is promoted to the read
			// map.
			m.missLocked()
		}
		m.mu.Unlock()
	}
	if !ok {
		return nil, false
	}
	return e.load()
}

func (e *entry) load() (value any, ok bool) {
	p := atomic.LoadPointer(&e.p)
	if p == nil || p == expunged {
		return nil, false
	}
	return *(*any)(p), true
}

这里 entry.Load 方法就是【根据指针找值】,从 unsafe.Pointer => interface{} (跟any是一个意思)。这里会校验是否为 nil,或者是已经删除。

Load 的流程拆解:

  • 先从 read 中找 key,有了就返回(这是最佳路径),没有就去找 dirty;
  • 通过加锁后二次判断避免并发问题(可能dirty此时正在拷贝到 read,此时 read 已经有了);
  • 找 dirty 的时候自增一下 misses,语义上是表明【又有一个key从 read 中找不到】,如果 misses 已经跟 dirty 的值个数相同,需要完成 promote (即 dirty => read, dirty = nil).

Store

func (m *Map) Store(key, value any) {
	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 {
		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.
			m.dirty[key] = e
		}
		e.storeLocked(&value)
	} else if e, ok := m.dirty[key]; ok {
		e.storeLocked(&value)
	} else {
		if !read.amended {
			// We're adding the first new key to the dirty map.
			// Make sure it is allocated and mark the read-only map as incomplete.
			m.dirtyLocked()
			m.read.Store(readOnly{m: read.m, amended: true})
		}
		m.dirty[key] = newEntry(value)
	}
	m.mu.Unlock()
}

我们先来看涉及到的四个子方法:

  1. tryStore 跟上面的 entry.load() 相对应,load 读,tryStore写。需要注意的是,如果 entry 本身已经是删除态( == expunged),tryStore 不会写入。

这里大家注意,跟前面是呼应的,我们说 entry 本身只包含一个 unsafer.Pointer,当我们要更新值的时候,只需要替换指针即可。这里用到了 CAS。

// tryStore stores a value if the entry has not been expunged.
//
// If the entry is expunged, tryStore returns false and leaves the entry
// unchanged.
func (e *entry) tryStore(i *any) bool {
	for {
		p := atomic.LoadPointer(&e.p)
		if p == expunged {
			return false
		}
		if atomic.CompareAndSwapPointer(&e.p, p, unsafe.Pointer(i)) {
			return true
		}
	}
}
  1. unexpungeLocked 将 entry 包含指针从 expunged (删除态)转为 nil(如果原来不是删除态就不作处理),还是用的 CAS
func (e *entry) unexpungeLocked() (wasExpunged bool) {
	return atomic.CompareAndSwapPointer(&e.p, expunged, nil)
}
  1. storeLocked 本质上是 tryStore 去掉了 expunged 校验和CAS。直接 StorePointer,更新 entry 包含指针的值。
// storeLocked unconditionally stores a value to the entry.
//
// The entry must be known not to be expunged.
func (e *entry) storeLocked(i *any) {
	atomic.StorePointer(&e.p, unsafe.Pointer(i))
}

注意,storeLocked 很重要的意义在于,我们直接更新了 entry 指向的值,具体这个 entry 是在 read 还是 dirty 其实无所谓。

  1. dirtyLocked 将 read 中的 kv 逐个 copy 过来到 dirty 中(Why?因为map是引用传值,如果将read map直接赋值给dirty map,两者最终将会共享同一个map,自然也就无法实现读写分离。因此将read map的元素逐个复制这部分的开销是必须要有的)
func (m *Map) dirtyLocked() {
	if m.dirty != nil {
		return
	}

	read, _ := m.read.Load().(readOnly)
	m.dirty = make(map[any]*entry, len(read.m))
	for k, e := range read.m {
		if !e.tryExpungeLocked() {
			m.dirty[k] = e
		}
	}
}

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

这里 tryExpungeLocked 中会尝试将read map 中值为 nil 的 value 置为删除态(expunged)。只有不是删除态的才会从 read 复制到 dirty。

回过头来看 Store 方法干了什么:

  • 先从 read 中找 key,如果有,就尝试去更新(用 tryStore,会校验删除态),更新成功了就返回;
  • 前一步最理想路径没走通,进入互斥锁,二度校验 read 中是否有 key:
    • 如果 read 中有,通过 storeLocked 赋值,并且尝试将 entry 从删除态转为 nil,如果成功,意味着此时 dirty 不为空,需要把 key 对应的指针给换掉;
    • 如果 dirty 中有,同样通过 storeLocked 赋值。
    • 如果两个map中都没,我们就构造一个新的 entry 塞到 dirty 里头,这算是纯新写入(此时要注意,如果dirty已经是空的,需要先做一次 dirtyLocked,完成 read => dirty 的拷贝。
  • 解锁,操作结束。

LoadAndDelete

func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
	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 {
			e, ok = m.dirty[key]
			delete(m.dirty, key)
			// Regardless of whether the entry was present, record a miss: this key
			// will take the slow path until the dirty map is promoted to the read
			// map.
			m.missLocked()
		}
		m.mu.Unlock()
	}
	if ok {
		return e.delete()
	}
	return nil, false
}

// Delete deletes the value for a key.
func (m *Map) Delete(key any) {
	m.LoadAndDelete(key)
}

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

先看 entry.delete,如果包含的指针已经是 nil 或 删除态,不做操作就ok。否则继续 CAS,把它换成 nil,这就算 entry 删除了,因为原来的值已经没有指针了。

Delete 本质也是用了 LoadAndDelete 的能力,我们直接一起看。

  • 先从 read 找 key,这里还是老一套,二次加锁校验,如果 read 没,就去 dirty 找;
  • 如果在 read 中找到了,直接 entry.delete 把指针变成 nil 即可;
  • 如果在 dirty 中找到,先删 delete(m.dirty, key),然后同样算作 read 的一次miss,继续去计数 misses 判断是否需要 dirty => read 进行 promote。最后一样调用 entry.delete 把指针变成 nil。

劣势分析

还是那句老话,一定要看好场景,如果你的业务场景下写很频繁,不建议用 sync.Map

  • 写入多的场景下,一旦查询数据时无法走到 read,就势必要走到互斥锁,dirty 的路径上,效率降低。同时还容易触发 dirty promote 到 read,性能变差;
  • 相对于直接写入的方案,双 map 这里一定要经过 read,多了一层操作的开销;
  • 新增 key 的时候,容易触发 read => dirty 的复制,如果 read 包含的键值对很多,这里开销也不小。

设计亮点

  • 使用 map[any]*entry 的结构,其中 entry 包含一个 unsafe.Pointer,这样使得我们在更新时,只需要用 CAS 改entry里的这个指针即可,不需要存储实际的数据;
  • 设计读 map 为一个无锁的,内嵌了 immutable(不能改变)原生 map 的 atomic.Value,承载并发读的流量;
  • 用 misses 计数判断读写两个 map 何时进行数据同步;
  • 互斥锁内外,对原生 map 进行双重检查,在保障性能的前提下,避免了并发场景的 race condition。
  • 延迟删除