极客时间已完结课程限时免费阅读

35 | 并发安全字典sync.Map (下)

35 | 并发安全字典sync.Map (下)-极客时间

35 | 并发安全字典sync.Map (下)

讲述:黄洲君

时长11:12大小6.41M

你好,我是郝林,今天我们继续来分享并发安全字典 sync.Map 的内容。
我们在上一篇文章中谈到了,由于并发安全字典提供的方法涉及的键和值的类型都是interface{},所以我们在调用这些方法的时候,往往还需要对键和值的实际类型进行检查。
这里大致有两个方案。我们上一篇文章中提到了第一种方案,在编码时就完全确定键和值的类型,然后利用 Go 语言的编译器帮我们做检查。
这样做很方便,不是吗?不过,虽然方便,但是却让这样的字典类型缺少了一些灵活性。
如果我们还需要一个键类型为uint32并发安全字典的话,那就不得不再如法炮制地写一遍代码了。因此,在需求多样化之后,工作量反而更大,甚至会产生很多雷同的代码。

知识扩展

问题 1:怎样保证并发安全字典中的键和值的类型正确性?(方案二)

那么,如果我们既想保持sync.Map类型原有的灵活性,又想约束键和值的类型,那么应该怎样做呢?这就涉及了第二个方案。
在第二种方案中,我们封装的结构体类型的所有方法,都可以与sync.Map类型的方法完全一致(包括方法名称和方法签名)。
不过,在这些方法中,我们就需要添加一些做类型检查的代码了。另外,这样并发安全字典的键类型和值类型,必须在初始化的时候就完全确定。并且,这种情况下,我们必须先要保证键的类型是可比较的。
所以在设计这样的结构体类型的时候,只包含sync.Map类型的字段就不够了。
比如:
type ConcurrentMap struct {
m sync.Map
keyType reflect.Type
valueType reflect.Type
}
这里ConcurrentMap类型代表的是:可自定义键类型和值类型的并发安全字典。这个类型同样有一个sync.Map类型的字段m,代表着其内部使用的并发安全字典。
另外,它的字段keyTypevalueType,分别用于保存键类型和值类型。这两个字段的类型都是reflect.Type,我们可称之为反射类型。
这个类型可以代表 Go 语言的任何数据类型。并且,这个类型的值也非常容易获得:通过调用reflect.TypeOf函数并把某个样本值传入即可。
调用表达式reflect.TypeOf(int(123))的结果值,就代表了int类型的反射类型值。
我们现在来看一看ConcurrentMap类型方法应该怎么写。
先说Load方法,这个方法接受一个interface{}类型的参数key,参数key代表了某个键的值。
因此,当我们根据 ConcurrentMap 在m字段的值中查找键值对的时候,就必须保证 ConcurrentMap 的类型是正确的。由于反射类型值之间可以直接使用操作符==!=进行判等,所以这里的类型检查代码非常简单。
func (cMap *ConcurrentMap) Load(key interface{}) (value interface{}, ok bool) {
if reflect.TypeOf(key) != cMap.keyType {
return
}
return cMap.m.Load(key)
}
我们把一个接口类型值传入reflect.TypeOf函数,就可以得到与这个值的实际类型对应的反射类型值。
因此,如果参数值的反射类型与keyType字段代表的反射类型不相等,那么我们就忽略后续操作,并直接返回。
这时,Load方法的第一个结果value的值为nil,而第二个结果ok的值为false。这完全符合Load方法原本的含义。
再来说Store方法。Store方法接受两个参数keyvalue,它们的类型也都是interface{}。因此,我们的类型检查应该针对它们来做。
func (cMap *ConcurrentMap) Store(key, value interface{}) {
if reflect.TypeOf(key) != cMap.keyType {
panic(fmt.Errorf("wrong key type: %v", reflect.TypeOf(key)))
}
if reflect.TypeOf(value) != cMap.valueType {
panic(fmt.Errorf("wrong value type: %v", reflect.TypeOf(value)))
}
cMap.m.Store(key, value)
}
这里的类型检查代码与Load方法中的代码很类似,不同的是对检查结果的处理措施。当参数keyvalue的实际类型不符合要求时,Store方法会立即引发 panic。
这主要是由于Store方法没有结果声明,所以在参数值有问题的时候,它无法通过比较平和的方式告知调用方。不过,这也是符合Store方法的原本含义的。
如果你不想这么做,也是可以的,那么就需要为Store方法添加一个error类型的结果。
并且,在发现参数值类型不正确的时候,让它直接返回相应的error类型值,而不是引发 panic。要知道,这里展示的只一个参考实现,你可以根据实际的应用场景去做优化和改进。
至于与ConcurrentMap类型相关的其他方法和函数,我在这里就不展示了。它们在类型检查方式和处理流程上并没有特别之处。你可以在 demo72.go 文件中看到这些代码。
稍微总结一下。第一种方案适用于我们可以完全确定键和值具体类型的情况。在这种情况下,我们可以利用 Go 语言编译器去做类型检查,并用类型断言表达式作为辅助,就像IntStrMap那样。
在第二种方案中,我们无需在程序运行之前就明确键和值的类型,只要在初始化并发安全字典的时候,动态地给定它们就可以了。这里主要需要用到reflect包中的函数和数据类型,外加一些简单的判等操作。
第一种方案存在一个很明显的缺陷,那就是无法灵活地改变字典的键和值的类型。一旦需求出现多样化,编码的工作量就会随之而来。
第二种方案很好地弥补了这一缺陷,但是,那些反射操作或多或少都会降低程序的性能。我们往往需要根据实际的应用场景,通过严谨且一致的测试,来获得和比较程序的各项指标,并以此作为方案选择的重要依据之一。

问题 2:并发安全字典如何做到尽量避免使用锁?

sync.Map类型在内部使用了大量的原子操作来存取键和值,并使用了两个原生的map作为存储介质。
其中一个原生map被存在了sync.Mapread字段中,该字段是sync/atomic.Value类型的。 这个原生字典可以被看作一个快照,它总会在条件满足时,去重新保存所属的sync.Map值中包含的所有键值对。
为了描述方便,我们在后面简称它为只读字典。不过,只读字典虽然不会增减其中的键,但却允许变更其中的键所对应的值。所以,它并不是传统意义上的快照,它的只读特性只是对于其中键的集合而言的。
read字段的类型可知,sync.Map在替换只读字典的时候根本用不着锁。另外,这个只读字典在存储键值对的时候,还在值之上封装了一层。
它先把值转换为了unsafe.Pointer类型的值,然后再把后者封装,并储存在其中的原生字典中。如此一来,在变更某个键所对应的值的时候,就也可以使用原子操作了。
sync.Map中的另一个原生字典由它的dirty字段代表。 它存储键值对的方式与read字段中的原生字典一致,它的键类型也是interface{},并且同样是把值先做转换和封装后再进行储存的。我们暂且把它称为脏字典。
注意,脏字典和只读字典如果都存有同一个键值对,那么这里的两个键指的肯定是同一个基本值,对于两个值来说也是如此。
正如前文所述,这两个字典在存储键和值的时候都只会存入它们的某个指针,而不是基本值。
sync.Map在查找指定的键所对应的值的时候,总会先去只读字典中寻找,并不需要锁定互斥锁。只有当确定“只读字典中没有,但脏字典中可能会有这个键”的时候,它才会在锁的保护下去访问脏字典。
相对应的,sync.Map在存储键值对的时候,只要只读字典中已存有这个键,并且该键值对未被标记为“已删除”,就会把新值存到里面并直接返回,这种情况下也不需要用到锁。
否则,它才会在锁的保护下把键值对存储到脏字典中。这个时候,该键值对的“已删除”标记会被抹去。
sync.Map 中的 read 与 dirty
顺便说一句,只有当一个键值对应该被删除,但却仍然存在于只读字典中的时候,才会被用标记为“已删除”的方式进行逻辑删除,而不会直接被物理删除。
这种情况会在重建脏字典以后的一段时间内出现。不过,过不了多久,它们就会被真正删除掉。在查找和遍历键值对的时候,已被逻辑删除的键值对永远会被无视。
对于删除键值对,sync.Map会先去检查只读字典中是否有对应的键。如果没有,脏字典中可能有,那么它就会在锁的保护下,试图从脏字典中删掉该键值对。
最后,sync.Map会把该键值对中指向值的那个指针置为nil,这是另一种逻辑删除的方式。
除此之外,还有一个细节需要注意,只读字典和脏字典之间是会互相转换的。在脏字典中查找键值对次数足够多的时候,sync.Map会把脏字典直接作为只读字典,保存在它的read字段中,然后把代表脏字典的dirty字段的值置为nil
在这之后,一旦再有新的键值对存入,它就会依据只读字典去重建脏字典。这个时候,它会把只读字典中已被逻辑删除的键值对过滤掉。理所当然,这些转换操作肯定都需要在锁的保护下进行。
sync.Map 中 read 与 dirty 的互换
综上所述,sync.Map的只读字典和脏字典中的键值对集合,并不是实时同步的,它们在某些时间段内可能会有不同。
由于只读字典中键的集合不能被改变,所以其中的键值对有时候可能是不全的。相反,脏字典中的键值对集合总是完全的,并且其中不会包含已被逻辑删除的键值对。
因此,可以看出,在读操作有很多但写操作却很少的情况下,并发安全字典的性能往往会更好。在几个写操作当中,新增键值对的操作对并发安全字典的性能影响是最大的,其次是删除操作,最后才是修改操作。
如果被操作的键值对已经存在于sync.Map的只读字典中,并且没有被逻辑删除,那么修改它并不会使用到锁,对其性能的影响就会很小。

总结

这两篇文章中,我们讨论了sync.Map类型,并谈到了怎样保证并发安全字典中的键和值的类型正确性。
为了进一步明确并发安全字典中键值的实际类型,这里大致有两种方案可选。
其中一种方案是,在编码时就完全确定键和值的类型,然后利用 Go 语言的编译器帮我们做检查。
另一种方案是,接受动态的类型设置,并在程序运行的时候通过反射操作进行检查。
这两种方案各有利弊,前一种方案在扩展性方面有所欠缺,而后一种方案通常会影响到程序的性能。在实际使用的时候,我们一般都需要通过客观的测试来帮助决策。
另外,在有些时候,与单纯使用原生字典和互斥锁的方案相比,使用sync.Map可以显著地减少锁的争用。sync.Map本身确实也用到了锁,但是,它会尽可能地避免使用锁。
这就要说到sync.Map对其持有两个原生字典的巧妙使用了。这两个原生字典一个被称为只读字典,另一个被称为脏字典。通过对它们的分析,我们知道了并发安全字典的适用场景,以及每种操作对其性能的影响程度。

思考题

今天的思考题是:关于保证并发安全字典中的键和值的类型正确性,你还能想到其他的方案吗?
分享给需要的人,Ta购买本课程,你将得18
生成海报并分享

赞 14

提建议

上一篇
34 | 并发安全字典sync.Map (上)
下一篇
36 | unicode与字符编码
unpreview
 写留言

精选留言(25)

  • Timo
    2019-05-30
    做个sync.Map优化点的小总结: 1. 空间换时间。 通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响 2. 使用只读数据(read),避免读写冲突。 3. 动态调整,miss次数多了之后,将dirty数据提升为read 4. 延迟删除。 删除一个键值只是打标记,只有在提升dirty的时候才清理删除的数据 5. 优先从read读取、更新、删除,因为对read的读取不需要锁
    展开
    共 1 条评论
    52
  • sky
    2018-11-01
    郝大 go方面能推荐下比较成熟的微服务框架吗

    作者回复: 在我发布的Github优秀Go语言项目的思维导图里有。

    共 2 条评论
    12
  • 下雨天
    2020-10-20
    老师好,关于:sync.Map在存储键值对的时候,只要只读字典中已存有这个键,并且该键值对未被标记为“已删除”,就会把新值存到里面并直接返回,这种情况下也不需要用到锁。这句话,只读map里面的值可以被替换的话,为什么不需要加锁?不会 有读写冲突吗?

    作者回复: 【第一个层面】 因为这里面的只读字典 read 是通过 sync/atomic.Value 存储的,又正因为 read 是只读的,不存在增删键值对的情况,所以从 read 整体的层面可以安全地操作其中的某个键值对。 【第一个层面:更具体的细节】 在这种保护下,只要这里的键值对在数量上没有增减,就不会出现新数据丢失或者弄脏数据的情况。反例的话,可以看我最近写的这篇文章:https://mp.weixin.qq.com/s/ru161EtyQMrQVtWji0CqzQ 【第二个层面】 由于其中的每一个键值对(entry 结构)都可以保证自身的并发性( entry 内部只会存指针,因此用原子操作就可以保证并发读写的安全性),所以从 read 中单个键值对的层面也就有了并发安全性方面的保证。 【结论】 综合以上两种措施,这里才无需使用锁来保护。

    5
  • 我来也
    2020-08-26
    最近几天,golang升级到了1.15后,sync.Map又`火`了一把. 我又来温习了下本篇文章,也看了下对应的源码, 对sync.Map的印象又深刻了点. 具体的链接如下: [踩了 Golang sync.Map 的一个坑](https://gocn.vip/topics/10860) [sync: sync.Map keys will never be garbage collected](https://github.com/golang/go/issues/40999) 有兴趣的小伙伴可以去看看.
    展开
    6
  • 疯琴
    2020-01-05
    这个设计很巧妙,在自己的开发中可以借鉴这种思想。有个问题请问老师: “脏字典中的键值对集合总是完全的”,而“read 和 dirty 互换之后 dirty 会置空”,那么重建的意思是不是这样的:在下一次访问 read 的时候,将 read 中的键值对全部复制到 dirty 中?

    作者回复: read 和 dirty 互换是分两步走的。Load 的时候如果发现“不得不去 dirty 中查找”的情况已经有很多了,就会把 dirty 作为新的 read,然后把 dirty 置为 nil。之后,在 Store 的时候,如果发现健是新的,而且是对于新 read 的第一个新健(此时 dirty 必定为 nil),那么就重新初始化 dirty,然后把新 read 中的有效键一个一个地存入 dirty。 另外你可以尝试阅读一下 sync.Map 的源码,写得还是挺清晰的。可以配合着这里的文章去看。

    共 2 条评论
    5
  • Rainman
    2019-03-09
    这个脏字典让我想起了mysql的刷脏页。 给老师点赞。
    5
  • 大漠胡萝卜
    2020-07-12
    sync.Map适用于读多写少的情况,如果写数据比较频繁可以参考:https://github.com/orcaman/concurrent-map
    4
  • 墨水里的鱼
    2018-11-30
    如何初始化reflect.Type?reflect.TypeOf(1) reflect.TypeOf("a") 只能这样吗?
    3
  • 渺小de尘埃
    2018-11-01
    当一个结构体里的字段是sync.map类型的,怎么json序列化呢?

    作者回复: 既然要序列化了就用不着同步了吧,用个普通map倒腾一下呗。或者你再包装一下,自定义序列化过程。

    3
  • 金时
    2021-07-10
    // The read field itself is always safe to load, but must only be stored with mu held. 老师,源代码里对read变量注释时说read 在store时,需要加锁,没懂这是为什么?

    作者回复: 你应该知道,atomic.Value 类型的值(以下简称“value”吧)只能保证(完整地)存/取操作的原子性。比如,你在里面存一个 map,它只能保证存这个 map 或取这个 map 的时候是原子操作,但你如果要存、取、改、删这个 map 里的键值对,那 value 就管不到了(这时就会是非原子的操作)。 另外,这里还有一个问题,如果有多个 goroutine 同时向同一个 value 里存值,那么,里面到底存成了哪一个 goroutine 提供的值就不好说了。如果没有并发保护,多个并发写非常容易造成问题。 最后,你看源码肯定也知道,read 里存的是私有类型 readOnly 的实例,这个类型本身并不是并发安全的。所以无论对它做什么操作,都需要并发安全保护。 这也跟“并发多写”的问题有关。 所以,无论是替换 read 里的 readOnly 值(如果是单 goroutine 写就不用,可惜这里不可能做出这种保证),还是修改该值的内部,都需要有锁的保护。 你看,原子操作虽然能保证单值存取的原子性,但还是太简单了,在很多场景下是不足以完全保证并发安全的。不过,作为“可升级的并发安全保障”中的第一级防护还是挺好用的,就像 sync.Map 里做的那样。 sync.Map 的 Store 方法和 Load 方法里都是有一个升级保障的过程的,“升级”的标志就是用了 m.mu 。

    2
  • mkii
    2021-03-04
    老师,看到源码中Store的时候有个疑惑。如果read中存在此key对应的vlaue,则tryStore替换read的value。这里如果在dirty给read并将dirty置为nil的时候不会丢失新数据吗?

    作者回复: 不会啊,这里操作(包括存取和转移)的都是指针啊。

    共 3 条评论
    2
  • 夜来寒雨晓来风
    2021-01-07
    关于文中提到的“键值对应该被删除,但却仍然存在于只读字典”,什么时候会出现这种情形呢?对于sync.Map的删除机制看的不是很明白,希望能解答一下,谢谢~

    作者回复: 虽然在只读字典里,但是你肯定是获取不到的。这样做是为了操作效率,是一种“用空间换时间”的做法。之后内部做整理的时候,这些都会被一并删除的。

    2
  • Geek_a8be59
    2020-07-21
    看了一下源码有地方不理解,有劳解答一下 第一:Store、Load等方法都会执行两次m.read.Load().(readOnly),去判断两次 这样做的目的是什么?

    作者回复: 主要是怕第一次的操作没有体现真实情况,所以在锁的保护下再来一次。这就相当于快路径和慢路径。

    共 2 条评论
    2
  • xl000
    2021-06-21
    老师,read、dirty交换和访问read这两个操作,难道不需要保护read变量吗

    作者回复: 交换时会有互斥锁的保护,而 read 实际上存在了一个 sync/atomic.Value 里面。所以相关操作都是并发安全的。

    1
  • simple_孙
    2021-05-27
    更新时只修改只读map,不会造成数据不一致吗,后面应该会定期同步到脏map里吧?

    作者回复: 不会造成数据不一致,因为读的时候会先读 read 再读 dirty。而且不是“只修改 read”,如果 read 里面有这个键,那就修改 read,如果没有还会到 dirty 里面找。 sync.Map 会定期让 dirty 变成 read,然后重建 dirty(以下简称“read 转换”吧)。“定期”的时机是,dirty 中有太多 read 里没有的键值对(以下简称“脏键值对”吧)。 具体来说是,发现一个“脏键值对”就增加一次计数。每当已发现的“脏键值对”的数量等于或大于 dirty 的长度时,就触发一次“read 转换”。

    1
  • Calios
    2019-08-14
    个人以为是读过的这个专栏里最精彩的一篇~ 只读字典和脏字典的实现好精彩,忍不住再去细细读一下sync.Map的实现。
    1
  • 唐大少在路上。。。
    2019-05-11
    班门弄斧,其实有个细节帮老师丰富一下: 两个原生map的定义为 map[interface{}]*entry 其中的entry为一个只包含一个unsafe.pointer的结构体 这里之所以value设置为指针类型,个人感觉就是为了在dirtry重建的时候直接把read里面这个entry的地址copy到dirty里面,这样当read中对entry里面的pointer执行原子替换的时候,dirty里面的值也会跟随着改变。 这样,当每次对read已有key进行更新的时候就不用单独去操作一次dirty了
    展开
    1
  • 虢國技醬
    2019-03-03
    打卡
    1
  • Ronin
    2022-10-28 来自北京
    老师,我读了好几遍,还是不明白为什么要检查键值对的类型,这不是强制整个字典的key都为一个类型,整个字典的value都为一个类型了,而实际上原先的字典的键值对类型是可以多样化的存储,像这样: var m sync.Map m.Store("test", 18) m.Store(18, "test") 而不是只能: m.Store(1, "a") m.Store(2, "b") 就算要检查,应该是检查键的实际类型不能是函数类型、字典类型和切片类型 还望老师解惑下~谢谢
    展开

    作者回复: sync.Map 依然是可以多类型存储的啊,你觉得这是优势还是劣势呢? 我觉得这是并不是什么好事。因为这相当于把检查类型的工作抛给了程序员。实际上,这在当初也是一种在缺少自定义泛型支持情况下的 plan B,并不是最佳实践。 现在有了泛型,sync.Map其实可以被改造的更好,只不过为了向后兼容性,折中改造就被搁置了。

  • Jason
    2022-10-11 来自北京
    郝大,map的键值对的删除为什么要先置为nil再置为unpunged呢,直接置为unpunged不行吗?而且真正删除一个键值对要经过delete->dirtyLocked->missLocked三个步骤才能删除

    作者回复: 为rehash做准备啊,防止到时候内部状态不一致,rehash在map中是非常重要的一环,它的很多内部状态都是与rehash相关的。另外也是为了防止内存泄露。