Go map 读写性能优化 - 分片 map

基本在所有的编程语言中,都有 map 这种数据结构,Go 语言也不例外。
我们知道 Go 是一门对并发支持得比较好的语言,但是 map 并不支持并发读写。
比如,下面这种写法是错误的:

var m = make(map[int]int)
var wg sync.WaitGroup
wg.Add(2)
// 启动两个协程序同时写入 map
go func() {for i := 0; i < 100; i++ {m[i] = i}wg.Done()
}()
go func() {for i := 0; i < 100; i++ {m[i] = i}wg.Done()
}()
wg.Wait()

这样写会报错:

fatal error: concurrent map writes

为什么 Go map 不支持并发读写

这跟 map 的实现有关,根本原因是:map 的底层是支持自动扩容的,在添加元素的时候,如果发现容量不够,就会自动扩容。
如果允许扩容和访问操作同时发生,那么访问到的数据就不一定就是我们之前存放进去的了,所以 Go 从设计上就禁止了这种操作。
也就是 fail fast 的原则。

至于具体为什么,我们可以看看 map 在扩容时做了什么操作:

在这里插入图片描述

Go 中 map 的扩容是一个渐进的过程,在我们访问 map 的时候,会对 map 底层实际存储数据的桶进行迁移。

如果支持并发读写,就有可能会导致底层定位到的桶是扩容前的,但是实际上数据已经迁移到了新的桶中,这样就会导致访问到的并不是我们想要的数据。

Go map 设计上的考虑

在 Go 官网的博客上有专门针对 Go 不支持并发读写的说明,大概意思是:

经过长时间讨论,Go 团队认为,多数情况下,我们并不需要从多个 goroutine 来对 map 进行安全访问,
map 可能是已经实现了同步的某些较大数据结构或计算的一部分。因此,如果底层再去实现 map 的互斥操作,
就会减慢大多数程序的速度,而只能增加少数程序的安全性。

也就是说,他们认为大多数情况下,map 通常是我们自定义数据结构的一部分,而对这个自定义数据结构的访问时,我们一般已经有了锁去保证并发读写安全了,所以没有必要再在底层的 map 上加锁,从而可以保证大多数程序的速度。

但是从语言层面上来说,我们依然可以自行通过互斥锁来实现 map 的的互斥访问。
仅当对 map 在进行更新的时候,map 的读才是不安全的,但是 map 是支持并发读的。

如何解决这个问题 - 互斥锁

关于这一点,同样可以在 Go 官方博客中找到相关的说明,在 Go map 并发这一节也给了对应的 demo。具体来说就是将一般锁跟 map 关联起来,要读写 map 的时候,得先获取这个锁才能访问,这样就避免了对 map 的并发读写了。这是最典型的一种解决方案,也是最简单的。

下面的结构体定义了一个匿名结构体 counter,这个结构体中包含了一个 sync.RWMutex 互斥锁和一个 map

var counter = struct{sync.RWMutexm map[string]int
}{m: make(map[string]int)}

读的时候,我们可以使用 RLock 获取读锁,然后访问 m 这个 map

counter.RLock()
n := counter.m["some_key"]
counter.RUnlock()
fmt.Println("some_key:", n)

RLock 是读锁,多个 goroutine 可以同时获取读锁,读锁释放之前,其他 goroutine 无法获取写锁。

写的时候,我们可以使用 Lock 获取写锁:

counter.Lock()
counter.m["some_key"]++
counter.Unlock()

Lock 是写锁,只有一个 goroutine 可以获取写锁,并且写锁释放之前,其他 goroutine 无法获取读锁,也无法获取写锁。

另一种解决方法 - sync.Map

除了使用互斥锁,我们也可以使用 Go 语言自带的 sync.Map 来解决这个问题:

var m sync.Map
var wg sync.WaitGroup
wg.Add(2)
go func() {for i := 0; i < 100; i++ {m.Store(i, i)}wg.Done()
}()
go func() {for i := 0; i < 100; i++ {m.Store(i, i)}wg.Done()
}()
wg.Wait()

虽然 sync.Map 可以实现并发的读写,但是底层上依然会有较多的竞态条件,所以性能上并不是最好的,本质上还是操作一个 map
只是通过一些原子操作 + 自旋锁来实现并发安全的读写。

而且 sync.Map 设计出来的时候是为了应对一些特定的场景的,具体来说有以下两个场景:

  1. 当给定 key 的条目只写入一次但读取多次时,如在只会增长的缓存中。(读多写少)
  2. 当多个 goroutine 读取、写入和覆盖不相交的键集的条目。(不同 goroutine 操作不同的 key)

在这两种情况下,可以获得比用 Mutex + mapRWMutex + map 更好的性能,因为很多的锁操作都变成了原子操作。

具体细节可参考我此前的一篇文章:《深入理解 go sync.Map - 基本原理》

互斥锁、sync.Map 还不是最优的解决方案

使用互斥锁或者 sync.Map 的方式,虽然都可以解决 map 并发读写的问题,但是性能上都不是最优的。

因为它们底层还是会有互斥锁的竞争。这就意味着,在进行写 map 操作时,可能会存在较多的锁竞争,从而导致性能下降。

map 分片

如果我们有了解过 MongoDB,就会知道,MongoDB 中也有分片的概念,当数据量过大时,
单个 MongoDB 实例可能无法存储所有的数据,或者单个实例无法处理过多的读写请求,
这时候就需要将数据分片存储到多个 MongoDB 实例中,也就是按照一定的规则将数据存储到不同的机器上,
然后读写数据的请求也会依据一定规则被路由到对应的机器上。

同样的,如果我们的 map 并发请求过多,那么我们也可以将 map 分片,
将不同的 key 存储到不同的 map 中,这样就可以避免 map 的并发读写了。

我们需要做的是:通过 key 来计算其 hash 值,然后根据 hash 值来决定将 key 存储到哪个 map 中,
同时,我们每一个 map 都需要加上互斥锁,这样就可以保证每一个 map 的并发安全了。

具体如下图:

在这里插入图片描述

说明:

  1. 图中的 G0~2 表示 goroutinelock0~2 表示不同的互斥锁,map shard 0~2 表示多个 map 分片。
  2. goroutine 中会根据 key 计算出 hash 值,然后根据 hash 值来决定将 key 存储到哪个 map 分片中,然后获取这个分片对应的锁,然后进行读写操作。

虽然上图画起来是 G1 不会访问到 shard 0 或者 shard 2,但实际上是有可能的,上图只是想说明:
多个 goroutine 可以多个锁来访问多个 map 分片,而不用像之前那样多个 goroutine 都来竞争同一把锁了。
也就减少了锁的竞争和等待

代码实现

具体实现已经有一个开源的库了:orcaman/concurrent-map,
可以在 github 上搜到。

下面是它的部分代码:

var SHARD_COUNT = 32// A "thread" safe map of type string:Anything.
// To avoid lock bottlenecks this map is dived to several (SHARD_COUNT) map shards.
type ConcurrentMap[K comparable, V any] struct {shards   []*ConcurrentMapShared[K, V]sharding func(key K) uint32
}// A "thread" safe string to anything map.
type ConcurrentMapShared[K comparable, V any] struct {items        map[K]Vsync.RWMutex // Read Write mutex, guards access to internal map.
}// GetShard returns shard under given key
func (m ConcurrentMap[K, V]) GetShard(key K) *ConcurrentMapShared[K, V] {return m.shards[uint(m.sharding(key))%uint(SHARD_COUNT)]
}

说明:

  1. SHARD_COUNT 是分片数量,也就是底层会有多少个 map 分片。
  2. ConcurrentMap 表示一个支持并发读写的 map,它底层包含了多个 map 分片,以及有一个根据 key 计算分片的函数。
  3. ConcurrentMapShared 表示一个 map 分片,也就是上面提到的 map + RWMutex 组合。
  4. GetShard 根据 key 获取对应的 map 分片。

单从代码的角度,它封装了更多的东西,性能相比单纯的 map + RWMutex 自然会差一点,
但是从并发读写的角度来说,它比单纯 map + RWutex 要好很多。
因为它将原本只支持一个协程写的 map 转换为了支持多个协程写操作的 map,一定程度上提高了并发

但是需要注意的是,我们需要频繁写同一个 key 的操作,上面这种分片的方式也不能带来性能上的提升。
分片的方式更适合那些 key 区分度高的、写操作频繁的场景。

总结

最后再简单回顾一下本文所讲内容:

  1. Go 的 map 设计上不支持并发读写,如果我们有并发读写操作会直接 panic
  2. Go 的设计者们认为,多数情况下,我们并不需要从多个 goroutine 来对 map 进行安全访问,所以他们没有在底层实现 map 的互斥操作。
  3. 有两种方法可以解决 map 并发读写的问题:互斥锁、sync.Map。但是 sync.Map 设计上是应对某些特定场景的,并不合适所有场景。
  4. 我们可以通过分片的方式来解决 map 并发读写的问题,这样可以减少锁的竞争,提高并发读写性能。目前已经有现成的开源库可以使用了。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.hqwc.cn/news/444287.html

如若内容造成侵权/违法违规/事实不符,请联系编程知识网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

人工智能时代:AI提示工程的奥秘 —— 驾驭大语言模型的秘密武器

文章目录 一、引言二、提示工程与大语言模型三、大语言模型的应用实践四、策略与技巧五、结语《AI提示工程实战&#xff1a;从零开始利用提示工程学习应用大语言模型》亮点内容简介作者简介目录获取方式 一、引言 随着人工智能技术的飞速发展&#xff0c;大语言模型作为一种新…

海外多语言盲盒App开发:机遇与挑战并存

随着全球化进程的加速&#xff0c;跨文化交流成为人们日常生活和工作中的重要部分。在此背景下&#xff0c;多语言盲盒App的开发成为了一个具有巨大潜力的市场。本文将探讨海外多语言盲盒App开发的机遇与挑战&#xff0c;以及如何应对这些挑战。 一、海外多语言盲盒App开发的机…

【SpringBoot系列】自动装配的魅力:Spring Boot vs 传统Spring

IT行业有哪些证书含金量高? 文章目录 IT行业有哪些证书含金量高?强烈推荐前言区别项目配置&#xff1a;依赖管理&#xff1a;内嵌服务器&#xff1a;开发体验&#xff1a; 实例Spring项目示例&#xff1a;Spring Boot项目示例&#xff1a; 总结强烈推荐专栏集锦写在最后 强烈…

关于JVM常见的十道面试题

方法区、永久代和元空间有什么区别&#xff1f; 方法区、永久区和元空间是Java虚拟机用于存储类信息的区域&#xff0c;它们在不同的Java虚拟机版本有所不同&#xff1a; 方法区&#xff1a;方法去是一块用于存储类的结构信息、常量、静态变量、即时编译器编译后的代码等数据…

酷开科技依托酷开系统新剧热播,引领潮流风向

随着科技的不断发展&#xff0c;智能电视已经成为了家庭娱乐的主流&#xff0c;是消费者居家休闲放松的好帮手。其中&#xff0c;作为国内智能电视操作系统领军者的酷开系统&#xff0c;一直致力于为消费者提供丰富的内容和贴心的体验。近日&#xff0c;酷开系统新剧热播&#…

运动蓝牙耳机哪个品牌好一点?2024年好用的运动蓝牙耳机推荐

​其实&#xff0c;选择运动耳机并不只是看外观&#xff0c;性能也同样重要。在选择时&#xff0c;我们需要考虑几个关键因素&#xff0c;例如稳固性、舒适度和音质等。这些都是运动耳机必备的要求&#xff0c;因为它们能帮助我们在运动时更加专注于锻炼&#xff0c;而不会被耳…

17- OpenCV:图像矩(Image Moments)和点多边形测试

目录 一、图像矩 1、矩的概念介绍 2、相关的API 3、代码演示 二、点多边形测试 1、概念介绍-点多边形测试 2、cv::pointPolygonTest 3、代码演示 一、图像矩 引言 在数字图像处理、计算机视觉与相关领域中&#xff0c;图像矩(Image moments)是指图像的某些特定像素灰…

C#学习笔记_类(Class)

类的定义 类的定义是以关键字 class 开始&#xff0c;后跟类的名称。类的主体&#xff0c;包含在一对花括号内。 语法格式如下&#xff1a; 访问标识符 class 类名 {//变量定义访问标识符 数据类型 变量名;访问标识符 数据类型 变量名;访问标识符 数据类型 变量名;......//方…

光纤熔接-冷接

实验教学目的及具体要求 目的 掌握光纤连接器和冷接子连接的基本知识和操作方法。 具体要求 1.完成 SC光纤快速连接器的冷接。 2.完成预埋式光纤冷接子的冷接。 实验仪器、设备和材料 材料:皮线光缆1条、2个光纤快速连接器。 工具:皮线光缆开剥器、光纤剥…

【Git配置代理】Failed to connect to github.com port 443 问题解决方法

前言&#xff1a; 在学习代码审计时&#xff0c;有时会需要使用git去拉取代码&#xff0c;然后就出现了如下错误 看过网上很多解决方法&#xff0c;觉得问题的关键还是因为命令行在拉取/推送代码时并没有使用VPN进行代理。 解决办法 &#xff1a; 配置http代理&#xff1a;…

Mybatis 源码系列:领略设计模式在 Mybatis 其中的应用

文章目录 一、Builder模式二、工厂模式三、单例模式四、代理模式五、组合模式六、模板方式模式七、适配器模式八、装饰器模式九、迭代器模式 虽然我们都知道有23种设计模式&#xff0c;但是大多停留在概念层面&#xff0c;真实开发中很少遇到&#xff0c;Mybatis源码中使用了大…

Nginx 本地部署vue项目

1、 下载 Nginx 稳定版本 2、下载安装后&#xff0c;打开 nginx.conf配置文件 3、找到打包好的文件&#xff0c;并配置运行文件 文件的位置 root C:/server/build location /{root C:/server/build;index index.html index.htm;#解决刷新后nginx报404问题try_files $uri …