前言
由于要造一些轮子,例如 像 laravel 一样的 Event组件
,我参考了网上的几个库
这 2 个库,大同小异,只有一小部分差别,新打组件将会在这 2 个库的基础上再封装
这 2 个库都是比较早期的库,所以在实现上用到了 map,但是由于考虑到 map 的线程安全性问题,所以他们都使用了 go1.9 之前实现的方式,就是在结构体嵌入一个读写锁来避免线程安全问题。
在 Go 1.6 之前, 内置的 map 类型是部分 goroutine 安全的,并发的读没有问题,并发的写可能有问题。自 go 1.6 之后, 并发地读写 map 会报错,这在一些知名的开源库中都存在这个问题,所以 go 1.9 之前的解决方案是额外绑定一个锁,封装成一个新的 struct 或者单独使用锁都可以。
本文带你深入到 sync.Map 的具体实现中,看看为了增加一个功能,代码是如何变的复杂的,以及作者在实现 sync.Map 的一些思想。
有并发问题的 map
官方的 faq 已经提到内建的 map 不是线程(goroutine)安全的。
首先,让我们看一段并发读写的代码,下列程序中一个 goroutine 一直读,一个 goroutine 一只写同一个键值,即即使读写的键不相同,而且 map 也没有”扩容”等操作,代码还是会报错。
1 | package main |
报错信息为:fatal error: concurrent map read and map write
在开发
msource
组件的时候,同样遇到了这个问题
如果你查看 Go 的源代码: hashmap_fast.go#L118,会看到读的时候会检查 hashWriting 标志, 如果有这个标志,就会报并发错误。
写的时候会设置这个标志: hashmap.go#L542
1 | h.flags |= hashWriting |
hashmap.go#L628 设置完之后会取消这个标记。
当然,代码中还有好几处并发读写的检查, 比如写的时候也会检查是不是有并发的写,删除键的时候类似写,遍历的时候并发读写问题等。
有时候,map 的并发问题不是那么容易被发现, 你可以利用 -race
参数来检查。
Go 1.9 之前的解决方案
但是,很多时候,我们会并发地使用 map 对象,尤其是在一定规模的项目中,map 总会保存 goroutine 共享的数据。在 Go 官方 blog 的 Go maps in action 一文中,提供了一种简便的解决方案。
1 | var counter = struct{ |
它使用嵌入 struct 为 map 增加一个读写锁。
读数据的时候很方便的加锁:
1 | counter.RLock() |
写数据的时候:
1 | counter.Lock() |
sync.Map
可以说,上面的解决方案相当简洁,并且利用读写锁而不是 Mutex 可以进一步减少读写的时候因为锁带来的性能。
但是,它在一些场景下也有问题,如果熟悉 Java 的同学,可以对比一下 java 的 ConcurrentHashMap
的实现,在 map 的数据非常大的情况下,一把锁会导致大并发的客户端共争一把锁,Java 的解决方案是 shard, 内部使用多个锁,每个区间共享一把锁,这样减少了数据共享一把锁带来的性能影响,orcaman 提供了这个思路的一个实现: concurrent-map,他也询问了 Go 相关的开发人员是否在 Go 中也实现这种方案,由于实现的复杂性,答案是 Yes, we considered it
.,但是除非有特别的性能提升和应用场景,否则没有进一步的开发消息。
那么,在 Go 1.9 中 sync.Map
是怎么实现的呢?它是如何解决并发提升性能的呢?
sync.Map
的实现有几个优化点,这里先列出来,我们后面慢慢分析。
空间换时间。 通过冗余的两个数据结构(read、dirty),实现加锁对性能的影响。
使用只读数据(read),避免读写冲突。
动态调整,miss 次数多了之后,将 dirty 数据提升为 read。
double-checking。
延迟删除。 删除一个键值只是打标记,只有在提升 dirty 的时候才清理删除的数据。
优先从 read 读取、更新、删除,因为对 read 的读取不需要锁。
下面我们介绍 sync.Map
的重点代码,以便理解它的实现思想。
首先,我们看一下 sync.Map
的数据结构:
1 | type Map struct { |
它的数据结构很简单,值包含四个字段:read
、mu
、dirty
、misses
。
它使用了冗余的数据结构 read
、dirty
。dirty
中会包含 read 中未删除的 entries
,新增加的 entries 会加入到 dirty
中。
read
的数据结构是:
1 | type readOnly struct { |
amended 指明 Map.dirty
中有 readOnly.m
未包含的数据,所以如果从 Map.read 找不到数据的话,还要进一步到 Map.dirty 中查找。
对 Map.read 的修改是通过原子操作进行的。
虽然 read
和 dirty
有冗余数据,但这些数据是通过指针指向同一个数据,所以尽管 Map 的 value 会很大,但是冗余的空间占用还是有限的。
readOnly.m
和 Map.dirty
存储的值类型是\*entry
,它包含一个指针 p, 指向用户存储的 value 值。
1 | type entry struct { |
p 有三种值:
- nil: entry 已被删除了,并且 m.dirty 为 nil
- expunged: entry 已被删除了,并且 m.dirty 不为 nil,而且这个 entry 不存在于 m.dirty 中
- 其它: entry 是一个正常的值
以上是 sync.Map 的数据结构,下面我们重点看看 Load
、Store
、Delete
、Range
这四个方法,其它辅助方法可以参考这四个方法来理解。
Load
加载方法,也就是提供一个键 key
,查找对应的值 value
,如果不存在,通过 ok 反映:
1 | func (m *Map) Load(key interface{}) (value interface{}, ok bool) { |
这里有两个值的关注的地方。一个是首先从 m.read
中加载,不存在的情况下,并且 m.dirty
中有新数据,加锁,然后从 m.dirty
中加载。
二是这里使用了双检查的处理,因为在下面的两个语句中,这两行语句并不是一个原子操作。
1 | if !ok && read.amended { |
虽然第一句执行的时候条件满足,但是在加锁之前,m.dirty
可能被提升为 m.read
,所以加锁后还得再检查 m.read
,后续的方法中都使用了这个方法。
双检查的技术 Java 程序员非常熟悉了,单例模式的实现之一就是利用双检查的技术。
可以看到,如果我们查询的键值正好存在于 m.read
中,无须加锁,直接返回,理论上性能优异。即使不存在于 m.read
中,经过 miss
几次之后,m.dirty
会被提升为 m.read
,又会从 m.read
中查找。所以对于更新/增加较少,加载存在的 key 很多的 case,性能基本和无锁的 map 类似。
下面看看 m.dirty
是如何被提升的。missLocked
方法中可能会将 m.dirty
提升。
1 | func (m *Map) missLocked() { |
Store
这个方法是更新或者新增一个 entry。
1 | func (m *Map) Store(key, value interface{}) { |
你可以看到,以上操作都是先从操作 m.read
开始的,不满足条件再加锁,然后操作 m.dirty
。
Store
可能会在某种情况下(初始化或者 m.dirty
刚被提升后)从 m.read
中复制数据,如果这个时候 m.read
中数据量非常大,可能会影响性能。
Delete
删除一个键值。
1 | func (m *Map) Delete(key interface{}) { |
同样,删除操作还是从 m.read
中开始, 如果这个 entry 不存在于 m.read
中,并且 m.dirty
中有新数据,则加锁尝试从 m.dirty
中删除。
注意,还是要双检查的。 从 m.dirty
中直接删除即可,就当它没存在过,但是如果是从 m.read
中删除,并不会直接删除,而是打标记:
1 | func (e *entry) delete() (hadValue bool) { |
Range
因为for ... range map
是内建的语言特性,所以没有办法使用for range
遍历 sync.Map, 但是可以使用它的Range
方法,通过回调的方式遍历。
1 | func (m *Map) Range(f func(key, value interface{}) bool) { |
Range 方法调用前可能会做一个 m.dirty
的提升,不过提升 m.dirty
不是一个耗时的操作。
sync.Map 的性能
Go 1.9 源代码中提供了性能的测试: map_bench_test.go、map_reference_test.go
我也基于这些代码修改了一下,得到下面的测试数据,相比较以前的解决方案,性能多少回有些提升,如果你特别关注性能,可以考虑 sync.Map。
1 | BenchmarkHitAll/*sync.RWMutexMap-4 20000000 83.8 ns/op |
其它
sync.Map
没有 Len
方法,并且目前没有迹象要加上 (issue#20680),所以如果想得到当前 Map 中有效的 entries 的数量,需要使用 Range
方法遍历一次, 比较 X 疼。
LoadOrStore
方法如果提供的 key 存在,则返回已存在的值(Load),否则保存提供的键值(Store)。