线程安全的map
众所周知,go中的map不是线程安全的,两个线程(或协程)同时修改map中同一个key的value,会产生不确定的结果。而在golang中,遇到这种情况,程序会panic退出,个人觉得这样严苛的限制可以迫使开发者明白自己在写什么,以免未来陷入排查并发问题的痛苦之中。
关于go开发者关于设计非并发安全的map的初衷可以看这里:
Why are map operations not defined to be atomic?
目前运用比较广泛的有三种线程安全的map实现方法:
sync.RWMutex + map
组成struct
一个利用分段锁原理实现的map: concurrent-map
性能比较
测试环境
测试平台:
硬件 | 配置 |
---|---|
CPU | i7-9700K 8C8T |
RAM | 32G |
OS | Win10 专业工作站版 |
go版本:
1 | $ go version |
测试结果
1 |
|
结果分析
首先创建长度均为10000的MutexMap
、SyncMap
和ConcurrentMap
三种并发安全的map,然后分别循环10000次的并发写入和并发读取,具体代码见下方。
BenchmarkMap_Set
和 BenchmarkMap_Get
是非并发安全的基准测试
上述结果表明单独并发写入的时候,concurrent-map
形式的map性能最好,mutex+map
次之,sync.Map
排在最后。
比较反直觉的是,为什么利用分段锁原理实现的concurrent-map
性能还不如一整把大锁的mutex+map
。理论上来说,并发性能应该随着分段锁数量的增长而增长。
上述被删除的话是因为之前benchmark代码写错了而导致的错误结论,具体原因是由于concurrent-map
库实现的时候底层结构是map[string]interface{}
,而我在与之比较的mutex+map
用的却是map[string]int
代码
1 | package main |
进阶分析
mutex+map
这种并发安全的map实现方式简单来说,就是在整个map上加了一把大锁。
而concurrent-map
和java的ConcurrentHashMap
整体思路大致相同,都是分段锁,减小锁的粒度换来更高并发性能的思想。
理论上,分成两段锁的map写入性能应该是一把大锁的map的两倍,但实际情况,还需要考虑寻找分段时的损耗以及上下文的切换。
下面我简单实现了一下分段锁的map,并测试了一下不同分段数量下的性能比较。
main.go
1 | package main |
其中New(log2OfShardCount int)
表示创建的段数为2的幂,是为了上篇文章golang map 实现原理初探中提到的性能优化方式:n mod m = n & (m - 1)
主要测试逻辑是,创建长度为10000的各种map,然后每个goroutine读或写100次map
实验结果
实验环境同上
1 |
|
结果分析
sync.Map
的读取性能是MutexMap
4.3倍,正如文档里所说的:
The Map type is optimized for two common use cases: (1) when the entry for a given key is only ever written once but read many times, as in caches that only grow, or (2) when multiple goroutines read, write, and overwrite entries for disjoint sets of keys. In these two cases, use of a Map may significantly reduce lock contention compared to a Go map paired with a separate Mutex or RWMutex.
主要适用于两个常见场景:
读多写少
多goroutine读、写、复写完全不同的key
从实验结果可以看出来,使用了分段锁的map有很大的读写性能提升。
但是和理论上的性能提升还有很大差距,而且分段数量也并不是越多越好。
所以当我们在设计高并发性能的程序时,需要权衡并发带来的性能提升和并发导致的开销。
代码
main_test.go
1 | package main |
benchmark如有错误或不妥的地方,请大家不吝赐教!