最近开发Go语言总是遇到哈希表的使用,在高并发下如何保证读写的安全性尤为重要,假如不了解的情况下,使用原生map的话,性能倒是很高,但在多个goroutine操作下就会遇到并发读写的错误出现。为了并发安全,修改读写访问,每次都写都加入读写锁,又会导致性能的大幅度下降,安全和性能实在是难以同时兼得。
这里我们梳理下Go当前访问Map的几种方式,并给出实际的测试实例和性能表现。
1. 标准库map结构
map是go语言标准库中的哈希表实现,不是并发安全的,也就是在多个goroutine同时访问的时候存在数据并发访问冲突,导致程序异常。适合单一的goroutine中使用,性能也是所有所有哈希表实现中最好的,
同时在多个goroutine读取或者使用for循环遍历相同的map也是并发安全的,只要不涉及更新即可高效的访问map结构。
设计的测试实例如下面所示:
const (
MapSize = 10000
LoopCounter = 1000
)
func BenchmarkMap(b *testing.B) {
// 初始化数据结构
m := map[string]int{}
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m[keys[i]] = i
}
// 重置测试计时器
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m[keys[i]]; ok {
}
}
}
}
func BenchmarkMapWrite(b *testing.B) {
m :=...
...
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
m[keys[i]] = i
}
}
}
func BenchmarkMapReadAndWrite(b *testing.B) {
m :=...
...
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if d, ok := m[keys[i]]; ok == true {
m[keys[i]] = d + 1
}
}
}
}
实际执行的情况如下面所示, 可以看到实际读取和写入的时间差不多,我们这里以读写1000次为一个单元,因此单一读取的操作的时间是25ns左右,也就是每秒可以执行4000万次的操作,写入操作为30ns左右,每秒执行3000多万次。测试中读写操作包含了1000次读取和1000次写入操作。
BenchmarkMapRead-4 50000 25804 ns/op 0 B/op 0 allocs/op
BenchmarkMapWrite-4 50000 31337 ns/op 0 B/op 0 allocs/op
BenchmarkMapReadAndWrite-4 30000 47668 ns/op 0 B/op 0 allocs/op
假如我们测试并发的读取和写入操作,可以通过启动多个goroutine来测试, 下面的环境下我们启动8个goroutine同时去读取和写入这个map对象,实际执行情况肯定是会直接报错退出。
func BenchmarkMapReadAndWriteWithMutilGoroutine(b *testing.B) {
m :=...
...
wg := sync.WaitGroup{}
for i := 0; i < 8; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if d, ok := m[keys[i]]; ok == true {
m[keys[i]] = d + 1
}
}
}
}()
}
wg.Wait()
}
// 执行出错的原生map的并发读写
// fatal error: concurrent map read and map write
2.自定义加锁的map结构
我们可以简单的封装下map结构使其实现安全的并发访问,并实现一些基本的读取,写入和删除接口操作。使用一个全局的读写锁,当数据读取和写入的时候分别执行锁操作,保证不会出现读写冲突的问题。缺点就是,当我们的map数据写操作较多的情况,会导致效率较低。一个写操作将导致全局map的后续读取和写入都需要等待,直到释放该锁。
实现和测试的代码如下:
type CustomIntMap struct {
sync.RWMutex
internal map[string]int
}
func NewCustomIntMap() *CustomIntMap {
return &CustomIntMap{
internal: make(map[string]int),
}
}
func (rm *CustomIntMap) Load(key string) (value int, ok bool) {
rm.RLock()
result, ok := rm.internal[key]
rm.RUnlock()
return result, ok
}
func (rm *CustomIntMap) Delete(key string) {
rm.Lock()
delete(rm.internal, key)
rm.Unlock()
}
func (rm *CustomIntMap) Store(key string, value int) {
rm.Lock()
rm.internal[key] = value
rm.Unlock()
}
上面的读写锁,用于不同的接口使用,当我们仅仅读取的时候,仅需要执行读取锁,不影响其他goroutine的读取执行,性能损耗不大,全局锁则会阻止其他goroutine读写的执行
func BenchmarkCustomMapRead(b *testing.B) {
m := NewCustomIntMap()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
}
}
}
}
func BenchmarkCustomMapWrite(b *testing.B) {
m := NewCustomIntMap()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
m.Store(keys[i], i+1)
}
}
}
func BenchmarkCustomMapReadAndWrite(b *testing.B) {
m := NewCustomIntMap()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
m.Store(keys[i], i+1)
}
}
}
}
执行测试的结果如下所示, 可以看到相对于使用第三方库,这种方式明显可以改善程序的运行效率,相对于concurrent-map的性能提升约1/3左右
BenchmarkCustomMapRead-4 30000 53241 ns/op 0 B/op 0 allocs/op
BenchmarkCustomMapWrite-4 20000 70543 ns/op 0 B/op 0 allocs/op
BenchmarkCustomMapReadAndWrite-4 20000 96799 ns/op 0 B/op 0 allocs/op
执行多个goroutine的时候,读取和写入操作的时间进行测试。但是对于多个goroutine下的读写操作,自定义加锁map的性能却不如concurrent-map的效率。
func BenchmarkCustomMapReadAndWriteWithMutilGoroutine(b *testing.B) {
m := NewCustomIntMap()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
wg := sync.WaitGroup{}
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
m.Store(keys[i], i+1)
}
}
}
}()
}
wg.Wait()
}
执行发现自定义的安全锁map在多个goroutine下的读取操作和
BenchmarkCustomMapReadAndWriteWithMutilGoroutine-4 5000 338891 ns/op 0 B/op 0 allocs/op
PASS
3. 第三方库concurrent-map
1.9版本之前,在官方还未推出自己的sync.Map并发哈希表结构之前,concurrent-map被很多人使用作为可选的并发安全的读写map库。与直接加锁相比,concurrent-map通过使用分片的方式将,存储空间分为几个片段,每个片段包含一部分数据,这样我们在写的时候不需要讲整个的结构都进行加锁,阻塞新的写入或者读取操作。
问题在于每次读取和写入都需要额外的计算分片信息。同时并发写相同分片的key会导致效率更低。同时每次读写内部还需要进行类型的转换,因为concurrent-map存储的类型为interface结构类型。
提供基本的读取和设置以及删除操作。这里我们使用同样的方式对于读写性能进行测试, 这里只贴出读写的测试代码:
func BenchmarkConcurrentMapReadWrite(b *testing.B) {
m := cmap.New()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Set(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Get(keys[i]); ok {
m.Set(keys[i], i+1)
}
}
}
测试可以看出来,实际执行的读写操作时间性能相对于原生的map基本上将近3x的性能下降。 Concurrent-map的实现上面也是采用了锁的方式,作为第三方开源产品,很多情况下为了兼容不同的存取实现,内部会有一些额外的操作比如类型转换,而这些都可能导致程序性能的下降。
BenchmarkConcurrentMapRead-4 20000 63583 ns/op 0 B/op 0 allocs/op
BenchmarkConcurrentMapWrite-4 10000 121971 ns/op 8000 B/op 1000 allocs/op
BenchmarkConcurrentMapReadWrite-4 10000 160574 ns/op 8000 B/op 1000 allocs/op
针对并发操作的测试如下面所示,启动两个goroutine来同时执行读写操作,
func BenchmarkConcurrentMapReadAndWriteWithMutilGoroutine(b *testing.B) {
m := cmap.New()
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Set(keys[i], i)
}
b.ResetTimer()
wg := sync.WaitGroup{}
for i := 0; i < 2; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Get(keys[i]); ok {
m.Set(keys[i], i+1)
}
}
}
}()
}
wg.Wait()
}
测试结果如下面所示, 两个goroutine,执行读写操作实际执行结果每个内部的操作读写约为280ns/2 = 140ns基本上在多个goroutine上与上面的基本一致。
BenchmarkConcurrentMapReadAndWriteWithMutilGoroutine-4 5000 280993 ns/op 16000 B/op 2000 allocs/op
4. sync.Map
sync.Map结构是在1.9版本后加入到标准库中,提供给用户使用的并发安全锁机制,实现上通过两个map结构,其中一个只读的map,另外一个通过RWLocker锁保护的读写map组成。Go开发人员在github上提到过sync.Map的实现和解决的问题,提到sync.Map主要用于解决服务器上由于CPU核心过多导致的缓存争夺(多CPU更新同一个缓存变量的情况下,导致效率降低)的问题,这个问题可能导致对于一个O(1)操作,在多个核心(N核)同时操作的时候,可能导致变为O(N)的操作。
适合仅新增key的情况,且读取数据的比例远大于更新数据的总量。
同时也提到了类似于concurrent-map中多个分片锁的实现,对于并发读相同分片下可能导致对于分片锁资源的争夺问题。另外分片结构和锁策略也可能导致程序运行的其他问题。
我们使用通用的测试方法测试, 如下所示为程序的读写测试,和并发读写操作测试代码。
func BenchmarkSyncMapReadAndWrite(b *testing.B) {
var m sync.Map
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
m.Store(keys[i], i+1)
}
}
}
}
func BenchmarkSyncMapReadAndWriteWithMutilGoroutine(b *testing.B) {
var m sync.Map
keys := []string{}
for i := 0; i < MapSize; i++ {
keys = append(keys, strconv.Itoa(i))
m.Store(keys[i], i)
}
b.ResetTimer()
wg := sync.WaitGroup{}
for i := 0; i < MaxGoRoutineNumber; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for n := 0; n < b.N; n++ {
for i := 0; i < LoopCounter; i++ {
if _, ok := m.Load(keys[i]); ok {
m.Store(keys[i], i+1)
}
}
}
}()
}
wg.Wait()
}
测试的结果如下面所示:
BenchmarkSyncMapRead-4 30000 61689 ns/op 0 B/op 0 allocs/op
BenchmarkSyncMapWrite-4 10000 247273 ns/op 40000 B/op 3000 allocs/op
BenchmarkSyncMapReadAndWrite-4 5000 271561 ns/op 40000 B/op 3000 allocs/op
BenchmarkSyncMapReadAndWriteWithMutliGoroutine-4 5000 331194 ns/op 80000 B/op 6000 allocs/op
PASS
总结
通过对比各种不同的实现,可以看出:
- 读写方面原生map性能最好,其他的加锁的版本和sync.Map均会有2-3倍左右的性能下降
- 根据测试的性能读取排序大概是:map>自定义加锁map>concurrent-map>sync.Map, 总体上可以性能损耗相对map有2-3倍的下降
- 根据测试的性能写入排序大概是:map >自定义加锁map>concurrent-map>sync.Map,其中sync.Map的写操作下降严重约5-6倍
- 同时读写操作下,基本上和上面的顺序一致,自定义的锁约原生map的两倍左右,concurrent-map2-3倍,sync.Map基本上达到了4倍的性能下降。
- 并发方面:对于相同的操作下,并发读写(各一半)操作则出现自定义加锁map变慢的情况
• 2个goroutine下,concurrent-map > 自定义加锁map>sync.Map
• 4个goroutine下,concurrent-map >自定义加锁map> sync.Map
• 8个goroutine下,concurrent-map >自定义加锁map> sync.Map
• 16个goroutine下,concurrent-map >自定义加锁map> sync.Map
• 32个goroutine下,concurrent-map > sync.Map >自定义加锁map
• 64个goroutine下,concurrent-map > sync.Map >自定义加锁map
- 并发方面:对于相同的操作下,并发读操作则出现自定义加锁map变慢的情况
• 2个goroutine下,sync.Map > concurrent-map >自定义加锁map
• 4个goroutine下,sync.Map > concurrent-map >自定义加锁map
• 8个goroutine下,sync.Map > concurrent-map >自定义加锁map
• 16个goroutine下,sync.Map > concurrent-map >自定义加锁map
• 32个goroutine下,sync.Map > concurrent-map >自定义加锁map
• 64个goroutine下, sync.Map > concurrent-map >自定义加锁map
可以看出,当我们不需要并发操作的时候,直接使用map更快(显而易见),但是对于并发操作的时候,则要根据实际的业务需求进行判断,如果读取操作更多,且key比较稳定的话,在多个CPU核心的条件下,而对于读写各一半的情况下concurrent-map则会更好一些。
最后附上此次测试的系统信息
MacOSX: 10.14.6 Darwin Kernel Version 18.7.0
CPU: 2.5 GHz Intel Core i5
Memory: 16 GB 1600 MHz DDR3