Golang是怎样实现的: sync包之sync.Map

60 阅读10分钟

众嗦粥吃, go的map是并发不安全的, 我们可以通过sync.Mutex上锁来解决这个问题. 不过go语言本身提供了并发安全的sync.Map, 本章将带你一文吃透sync.Map.

我们将介绍如下内容

image.png 为了便于理解, 本文将尽可能的减少源码出现, 但有些实在无法省略的部分也将附带图片解释或添加精细的注释.

sync.Map实现思路

sync.Map通过读写分离的方式来优化查询速度, 他牺牲了部分写效率来支持并发读, 从而大幅优化了查询开销.

sync.Map数据结构

下面是去掉了注释后的数据结构

type Map struct {
   mu Mutex
   read atomic.Value // readOnly
   dirty map[any]*entry //这是go1.18版本, any等价于interface{}, 可以直接视为interface{}
   misses int
}
  • mu: 锁字段, 用于保护写操作和部分读操作下的并发安全
  • read: 实际数据结构为readOnly, 并发安全, 只存有dirty中的部分数据
  • dirty: 存有全部数据的map, 并发不安全
  • missed: 当前未命中次数(如果一个的key查询在read中没找到而在dirty中找到, 记为一次未命中), 维护他用来提高命中率.

咱们再看一下read字段的数据结构readOnly

type entry struct {
   p unsafe.Pointer
}
type readOnly struct {
   m       map[any]*entry
   amended bool
}
  • m: 一个key->指针的map, 其中指针指向一个entry, entry中包含了指向value的指针
  • amended: 用来标识read中的m中的数据和dirty中的数据是否不一致(下文将简述为read和dirty是否不一致)

综合起来如下图.

流程图.jpg sync.Map有如下几个性质(对照上图理解)

  • read中 "存在" (kv对在read中存在, 且entry中的指针不是expunged)的kv对, 在dirty中一定存在
  • 对于同一个key, 如果value在read和dirty中都存在, 那么read和dirty会指向同一块内存空间
  • dirty中可能有着read中没有的kv对

read中kv对的三种状态

让我们截取上图中的read部分进行分析

image.png 可以发现, read中最重要的其实是一个名为m的map.他里面存有三种状态的kv对

  1. v(我们用v表示kv映射中的v, value则是咱们存入的值如1919810)是一个指向一般entry的指针: 这个kv对在dirty中一定存在, 可以通过entry找到value. 这个kv对一定被我们添加过且未删除, 逻辑上存在.
  2. read中不存在该kv对, 查询key返回nil: 这个kv对可能在dirty中存在. 这个kv对可能被我们添加过, 逻辑上可能存在
  3. v指向一个特殊的entry: entry中p的值为一个名为expunged全局变量, 该变量起标识作用, 意味着这个kv对在dirty中一定不存在. 这个kv对曾经被添加过, 但已经被删除, 逻辑上不存在.

对于物理上和逻辑上都在read中存在的kv对, 即1状态的kv对, 我们称之为其在read中"存在"

read的只读如何理解

read只读指的是read中的m以及amended无论如何都不会被修改, 只会发生read被整个替换的情况, 而我们对value的修改其实也不会改动read, 而是会通过read找到value在内存中的地址, 然后修改这块内存中的值(即图中的绿色部分).

而对value的删除也同理, 他不会改变read, 而是把entry中的p修改为expunged

正是因为read绝对不可能发生写操作, 所以我们可以无需加锁的并发读取read从而获取该key指向的entry, 然后通过atomic.LoadPointer(&entry.p)来原子性的获取value.

dirty的作用是什么

因为read只读, 所以我们在不能在每次写操作中修改read, 而是在某一时刻来统一更新整个read, 我们这里把每次更新称为一个周期.

每个周期内的写操作都会去修改dirty, 而在read中查询返回nil的key也可能在当前周期中被新增, 此时我们就需要查询dirty, 因此dirty是可能发生并发读写的, 需要锁保护.

我们回顾一下图中dirty部分 流程图.jpg 114514和"hello"是在之前的写周期中新增的, 且未删除, 123和456是在之前周期中新增的, 但在本周期中删除, "你好"是在本周期中新增的.

sync.Map常见函数的实现

了解了上述知识后, 我们就可以大致看下sync.Map中各个方法是如何实现的了.

Store(key, value any)

该方法尝试往当前sync.Map中插入一个kv对, 如果该kv对已存在, 那么他会修改他的值. 让我们回顾下之前说的kv对的三个状态

  1. v是一个指向一般entry的指针: 这个kv对在dirty中一定存在, 可以通过他找到value. 这个kv对一定被我们添加过且未删除, 逻辑上存在.
  2. read中不存在该kv对, 查询key返回nil: 这个kv对可能在dirty中存在. 这个kv对可能被我们在当前周期添加, 逻辑上可能存在
  3. v指向一个特殊的entry: entry中p的值为一个名为expunged全局变量, 该变量起标识作用, 意味着这个kv对在dirty中一定不存在. 这个kv对曾经被添加过, 但在当前周期已经被删除, 逻辑上不存在.

Store方法会根据kv对的不同状态做出不同的行为

  1. 先根据read判断当前kv对处于哪个状态, 如果当前kv在read中"存在"即处于状态1, 那么他会通过CAS的方式来修改当前key对应entry中的p指向的地址中的内容, 即value的值, 因为对于同一个key, 如果value在read和dirty中都存在, 那么read和dirty会指向同一块内存空间, 所以此时dirty中的value也会随之改变
  2. 如果当前kv处于状态2则说明当前kv在read中不存在, 而状态3则是kv在dirty中不存在, 而无论哪种情况, 我们都无法通过修改read来修改dirty中的value, 从而需要加锁来修改dirty中的内容, 这里涉及到三个if.
  3. 第一个if: 在加锁后, 会再次进行判断当前kv是否处于状态3(因为在第一次判断到加锁的这部分时间内kv状态可能会被别的goroutine修改, 这种二次检查的上锁方式称为双检锁), 如果处于状态3, 则修改为状态2后修改dirty指向的entry为当前entry, 并修改其中对应的value的值
  4. 第二个else if: 如果当前kv存在于dirty中, 则直接修改当前entry中value的值(此时read和dirty中的kv对一定不同)
  5. 第三个else: 如果当前kv在dirty中不存在, 那么判断下read中amended的值, 如果为false则说明原本read和dirty中的kv对相同, 那么在加入当前kv到dirty后, 二者会变的不同, 因此修改amended为true(这里的修改也是创建一个amended为true的新read替换旧read). 最后把当前kv对加入到dirty中.

Store流程图.jpg

Load(key any, ok bool)

load的实现相对简单

  1. 首先通过双检锁的方式尝试从read中获取到对应的entry记为e.
  2. 如果read中没能找到, 则通过amended判断read和dirty中的kv对是否相同, 如果不相同, 说明dirty中可能存在该kv对, 则尝试去dirty中对应entry.
  3. 解锁, 并返回e.Load
  4. e.Load会判断e的Pointer是否为nil或expunged, 如果是则返回nil,false, 否则调用atomic.LoadPointer(&e.p)原子性的获取value.

misses的作用是什么

在read中的kv对和dirty的kv对不一样的前提下, 那么对于一次load, 如果没能从read中成功获取value, 那么我们记一次miss

misses的作用是记录当前的miss次数, 当misses的值和len(dirty)相等时, 说明此时miss次数过多, 那么我们会更新read. 创建一个新的read, 他的m是dirty, amended为false, 然后用新read替换旧read, 之后设置dirty为nil, 从而提高之后read的命中率.

Delete(key anu)

Delete本质上会调用LoadAndDelete

func (m *Map) Delete(key any) {
   m.LoadAndDelete(key)
}

LoadAndDelete(key any)

首先用双检锁的方式检查当前kv对是否存在于read中且amended参数是否为true(即read的kv对与dirty的不同), 如果存在于read则通过read获得对应的entry记为e.

不存在read中且amended为true, 那么尝试通过dirty获取对应entry, 并用delete(dirty,key)来在dirty中删除

最后如果e成功获取, 且e的Pointer不为nil或expunged, 那么通过CAS的方式修改e的pointer为nil.

LoadOrStore(key, value any)

在了解完Load和Store后, 我们变很容易理解LoadOrStore的实现了.

该函数的作用是如果kv对存在, 那么返回value, 否则加入该kv对并返回当前value

首先通过双检索的方式尝试在read中获取e, 如果获取成功且e的Pointer不为expunged或nil, 则直接返回该Pointer向的value, 如果为nil, 则通过CAS的方式设置为我们提供的value, 并返回.

然后还剩下两种情况, e在read中不存在或e的Pointer为expunged

对于e在read中不存在这种情况, 我们会尝试从dirty中获取e, 获取成功则判断对于Pointer的状态, 如果是非nil则返回value, 否则通过CAS的方式设置为当前value并返回(dirty中不存在expunged状态的e)

对于剩下一种情况, dirty中一定不存在我们指定的key, 因此首先会判断read.amended, 如果为false, 则创建一个amended为true的新read替换原read. 之后无论amended的值如何, 都往dirty中新增当前参数提供的kv对并返回.

Range(f func(key, value any) bool)

Range的作用是让用户提供一个函数, 然后会把当前所有的kv对作为函数的参数传递进去并执行.

Range的实现很简单, 它通过双检锁的方式判断amended, 即判断read和dirty是否一致, 如果一致则通过read获取所有kv, 如果不一致, 则通过dirty创建一个新read, 并替换原read, 之后再通过read来获取所有kv

我们需要注意的是read的获取方式

read, _ = m.read.Load().(readOnly)

这里通过类型断言的方式来获取read, 而类型断言返回的其实是一个copy, 这也就意味着对于m.read的改变不会再影响到read, 从而并发安全.

sync.Map的优劣

sync.Map为什么这么快

结合前面的知识, 我们可以很轻易的回答这个问题, 因为sync.Map使用read和dirty进行读写分离, 从而使得大部分读操作都无需加锁, 极大的提高了读取速率, 并且sync.Map还会通过misses来不断修正命中率.

sync.Map为什么不适合写频繁

结合前面的实现, 我们可以发现, sync.Map不适合频繁写主要有如下两点

  1. 在read能命中的情况下, 会通过CAS的方式来修改value, 这就导致在大量写的情况下效率甚至会低于Mutex+map
  2. 对于read未命中的情况下, 写操作会频繁让dirty变脏(即和read不一致), 这就导致会频繁的创建新read替换旧read从而引入大量的额外开销.