concurrent-map 和 sync.Map,我该选择哪个?
concurrent-map 和 sync.Map,我该选择哪个?
官方的map并不是线程安全的,如果我们在多线程中并发对一个map进行读写操作,是会引发panic的。解决方案除了使用锁来对map进行保护外,还有两种方式:
一,开源项目 concurrent-map 提供了可以用来做并发安全的map
二,Go1.9之后,标准库提供了一个sync.Map
这两种并发安全的map,我们应该怎么选择呢?
在concurrent-map我看到这么一段话:
标准库中的sync.Map是专为append-only场景设计的。因此,如果您想将Map用于一个类似内存数据库,那么使用我们的版本可能会受益。你可以在golang repo上读到更多,这里 and 这里 译注:sync.Map在读多写少性能比较好,否则并发性能很差
concurrent-map为什么会有这种表述呢?这篇文章就来庖丁解牛下。
concurrent-map
concurrent-map
是Golang中一个流行的并发安全的哈希表库,它允许多个goroutine同时对哈希表进行读写操作,而不需要使用显式的锁或同步原语。
该库的核心原理是使用分片锁,将哈希表分成多个小的哈希表片段,并为每个片段分配一个独立的锁。当多个goroutine尝试同时读写同一个片段时,只有该片段上的锁会被锁住,而其他片段的锁则不受影响,从而避免了整个哈希表被锁住的情况。
当进行写操作时,只需要锁住要写入的片段的锁,以确保原子性操作。当进行读操作时,则不需要锁住片段的锁,只需要对该片段上的读取操作进行同步即可。
此外,concurrent-map
库还使用了一些优化策略,如缓存哈希值和桶的地址,以减少计算和查找时间,从而提高并发读写性能。
总之,concurrent-map
库的原理是基于分片锁和其他优化策略来实现高效的并发安全哈希表。
我们先看它的使用方式:
// 创建一个新的 map.
m := cmap.New[string]()
// 设置变量m一个键为“foo”值为“bar”键值对
m.Set("foo", "bar")
// 从m中获取指定键值.
bar, ok := m.Get("foo")
// 删除键为“foo”的项
m.Remove("foo")
它的New方法创建了一个ConcurrentMap结构
type ConcurrentMap[K comparable, V any] struct {
shards []*ConcurrentMapShared[K, V]
sharding func(key K) uint32
}
我们看ConcurrentMap结构中的shards,是用来代表map分片之后的这些存储分片ConcurrentMapShared。
而sharing这个匿名函数代表的是分配的hash函数。
而存储分片是一个基础的,带有互斥锁的map
type ConcurrentMapShared[K comparable, V any] struct {
items map[K]V
sync.RWMutex
}
所以看到这里我们其实心里明白了个七七八八了,再看下它的New/Set/Get的流程如下:
cmap.New --> 创建一个ConcurrentMap --> 初始化ConcurrentMapShared
cmap.Set --> 根据需要设置的key查找对应的ConcurrentMapShared --> 加锁写分片中的map
cmap.Get --> 根据需要查找的key找出对应分片ConcurrentMapShared --> 加读锁读取分片中的map
是的,基本原理就是如上图所示。concurrent-map就是将一个大map拆分成若干个小map,然后用若干个小mutex 对这些小map进行保护。这样,通过降低锁的粒度提升并发程度。毕竟嘛,一个诸葛亮不如十个臭皮匠。
sync.Map
sync.Map
是Golang标准库中提供的一个并发安全的哈希表,它与常规的map相比,可以在多个goroutine并发访问时,保证数据的安全性和一致性。
理解sync.Map,最关键就是理解Map结构。
type Map struct {
mu Mutex //互斥锁,用于锁定dirty map
//优先读map,支持原子操作,注释中有readOnly不是说read是只读,而是它的结构体。read实际上有写的操作
read atomic.Value // readOnly
// dirty是一个当前最新的map,允许读写
dirty map[any]*entry
// 主要记录read读取不到数据加锁读取read map以及dirty map的次数,当misses等于dirty的长度时,会将dirty复制到read
misses int
}
这里的sync.Map的逻辑还是比较复杂的。我们再看它的Store函数和Load函数。
func (m *Map) Store(key, value any)
func (m *Map) Load(key any) (value any, ok bool)
我们先把Store的代码流程图画出来
Store-->判断read中是否有key{判断read中是否有key}
判断read中是否有key{判断read中是否有key}--有key-->在read中tryStore-->CompareAndSwapPointer-->原子替换read中对应指针
判断read中是否有key{判断read中是否有key}--没有key-->加锁-->判断key的位置
判断key的位置--在read中存在-->dirty中存入这对keyvalue-->read中原子替换指针-->解锁
判断key的位置--在read中不存在\n在dirty中存在-->dirty中原子替换指针-->解锁
判断key的位置--在read中不存在\n在dirty中不存在-->read中所有元素复制到dirty一份-->read中增加这个keyvalue-->dirty中增加这个keyvalue-->解锁
我们看下,这里面有几个步骤是非常有细节的。
首先,第一次判断read中是否有key的时候是没有加锁的,所以当第一次判断结束后,一旦明确read中没有key,要做后续的操作之前,先做一次加锁操作,做完加锁操作之后,又判断了一次key是否在read中。这是为什么呢?其实是由于在加锁这个操作的前后,map还是有可能有变化的,人不可能两次踏入同一个河流,map也不可能在加锁前后两次都不变,所以这里必须进行二次判断,这里可以说是非常细节了。
其次,在判断read或者dirty中已经有key的时候,Store做的操作不是复制一份value到目标结构,而是使用原子替换atomic.StorePointer 来将目标map中key对应的value指针替换为参数value。为什么呢? - 这是极致的性能优化写法,原子替换能减少一次值拷贝操作,做一次指针赋值就能替换拷贝内存操作。从这里我们也能理解为什么这个并发map会放在atomic包中,因为它的实现大量依赖atomic的原子操作。
同样,我们将Load的代码转化为流程图如下,
Load --> 判断read中是否有key{判断read中是否有key}
判断read中是否有key{判断read中是否有key}--有key-->直接返回对应的value
判断read中是否有key{判断read中是否有key}--没有key-->加锁-->再次判断read中是否有key{再次判断read中是否有key}
再次判断read中是否有key{再次判断read中是否有key} --有key-->直接返回对应的value
再次判断read中是否有key{再次判断read中是否有key} --没有key-->返回dirty中是否有key-->标记map的miss值加一-->如果miss值大于dirty的个数-->将dirty中的map通过指针切换到read-->dirty置空-->标记map的miss值为0
从Load中我们大致能看出sync.Map的思路。
sync.Map内部使用两个map,read和dirty。其实read的map的作用是挡在读写操作的第一个屏障。如果读写在这个read中能直接操作的话,我们就直接在read中读写,那么就可以完全避免使用锁,性能自然就提升了。
而dirty的作用就相当于是一个缓冲区,一旦要写的key在read中找不到,我们就会先写dirty中。这个好处是什么?也是不去影响读read的操作,不会出现并发读写一个数据结构的情况。
而什么时候dirty的缓存清空同步到read中呢?就是“当map的miss标记大于dirty的个数的时候”。
这里我读的时候也确实有这个疑问,为什么是“当miss标记个数大于dirty个数”。而不是当miss标记个数大于某个值呢?我是这么理解,miss是代表读操作在read中失效的数量,而dirty个数代表写操作在read中失效的数量。如果使用固定值来比对miss个数,那么这个固定值是不好定的,比如一个有10个key的map和一个有10000个key的map如果都是一样的固定值,那是明显不合适的。所以就找了这么个“浮动阈值”。
concurrent-map和sync.map的比较
我们再回到最开始的那一段话:
标准库中的sync.Map是专为append-only场景设计的。因此,如果您想将Map用于一个类似内存数据库,那么使用我们的版本可能会受益。你可以在golang repo上读到更多,这里 and 这里 译注:sync.Map在读多写少性能比较好,否则并发性能很差
通过以上的代码分析,我们看出sync.Map的这个机制,是一个想追求无锁读写的结构,它最好的运行方式是读永远都命中read,写只命中dirty,这用能不用任何锁机制就能做到map读写。而它最差的运行状态是read和dirty不断做替换和清理动作,性能就无法达到预期。而什么时候可能出现最差运行状态呢?- 大量的写操作和大量的读操作。大量读写会导致“map的miss标记大于dirty的个数”。 这个时候sync.Map中第一层屏障会失效,dirty就会频繁变动。
而current-map就相当于是一个比较中等中规中矩的方案。它的每次读写都会用到锁,只是这个锁的粒度比较小。它的最优运行方式是我们的所有并发读写都是分散在不同的hash切片中。它的最差运行方式就是我们所有的并发读写都集中在一个hash切片。但是按照实际运行逻辑,这两种极端情况都不会发生。
所以总结下来,concurrent-map 的这段话确实没有骗我们:
sync.Map在读多写少性能比较好,而concurrent-map 在key的hash度高的情况下性能比较好。
在无法确定读写比的情况下,建议使用 concurrent-map。
最后说一句:世上本没有烦恼,选择多了,便有了幸福的烦恼。
参考
https://segmentfault.com/a/1190000015242373
concurrent-map 和 sync.Map,我该选择哪个?的更多相关文章
- sync.Map(在并发环境中使用的map)
sync.Map 有以下特性: 需要并发读写时,一般的做法是加锁,但这样性能并不高,Go语言在 1.9 版本中提供了一种效率较高的并发安全的 sync.Map,sync.Map 和 map 不同,不是 ...
- 看过这篇剖析,你还不懂 Go sync.Map 吗?
hi, 大家好,我是 haohongfan. 本篇文章会从使用方式和原码角度剖析 sync.Map.不过不管是日常开发还是开源项目中,好像 sync.Map 并没有得到很好的利用,大家还是习惯使用 M ...
- sync.Map与Concurrent Map
1. sync.Map 1.1. map并发不安全 go1.6以后map有了并发的安全检查,所以如果在并发环境中读写map就会报错 func unsafeMap() { // 创建一个map对象 m ...
- 并发安全 sync.Map
https://mp.weixin.qq.com/s/MqPm7QH3_D9roVkpTs9Xpw 谈谈Go的并发安全相关 原创 歪鼻子 歪鼻子 2020-12-27 package main ...
- Go 1.9 sync.Map揭秘
Go 1.9 sync.Map揭秘 目录 [−] 有并发问题的map Go 1.9之前的解决方案 sync.Map Load Store Delete Range sync.Map的性能 其它 在Go ...
- 源码解读 Golang 的 sync.Map 实现原理
简介 Go 的内建 map 是不支持并发写操作的,原因是 map 写操作不是并发安全的,当你尝试多个 Goroutine 操作同一个 map,会产生报错:fatal error: concurrent ...
- 深入理解golang:sync.map
疑惑开篇 有了map为什么还要搞个sync.map 呢?它们之间有什么区别? 答:重要的一点是,map并发不是安全的. 在Go 1.6之前, 内置的map类型是部分goroutine安全的,并发的读没 ...
- go sync.map源码解析
go中的map是并发不安全的,同时多个协程读取不会出现问题,但是多个协程 同时读写就会出现 fatal error:concurrent map read and map write的错误.通用的解决 ...
- Golang:sync.Map
由于map在gorountine 上不是安全的,所以在大量并发读写的时候,会出现错误. 在1.9版的时候golang推出了sync.Map. sync.Map 通过阅读源码我们发现sync.Map是通 ...
- golang fatal error: concurrent map read and map write
调试程序的时候,为了打印map中的内容 ,直接 使用seelog 的方法打印 map中的内容到日志,结果出现 “concurrent map read and map write”的错误,导致程序异常 ...
随机推荐
- Swing常用窗体
Swing常用窗体 Swing 主要具有以下特点: ( 1 )轻量级组件. ( 2 )可插入外观组件. 窗体作为Swing的应用程序中组件的承载体,处于非常重要的地位.Swing中常用的窗体包括JFr ...
- 一行代码实现shell if else逻辑
前言 前几天学习 shell 脚本,发现这种好用的写法,简单记录一下. if else 一行实现 if [ 1=1 ] ;then echo "条件成立";else echo &q ...
- easyui textbox setvalue 和 settext前后之别
今天在这里转了好久,浪费了不少时间.话不多说直接上干货: 1.text与value设置不同的值一定要先赋值 value后赋值text, 否则全为value值 2.若只setValue,则getValu ...
- 4.11:Storm之WordCount
〇.概述 1.拓扑结构 2.目标 使用storm进行计数实验. 一.启动服务 在网页中输入:http://localhost:8081可以查看storm的相关信息. 二.kafka操作 终端中输入 ...
- 基于opencv实现简单人脸检测
作用:在视频中自动检测出人脸 使用内容:灰度转换,分类器,矩形框选,圆形框选,摄像头读取及释放 代码逻辑: 先读取摄像头后, 对摄像头拍摄到的图片进行逐帧分析, 并对图像进行灰度转换后使用类选择器识别 ...
- Kubernetes(k8s)存储管理之数据卷volumes(五):动态制备-存储类StorageClass
目录 一.系统环境 二.前言 三.静态制备和动态制备 四.存储类StorageClass 4.1 存储类StorageClass概览 4.2 StorageClass 资源 五.创建存储类Storag ...
- 通过 CancellationToken 提高 Web 性能
在 Web 开发中,经常会遇到这样的场景:用户发起一个请求,Web 服务器执行一些计算密集型的操作,等待结果返回给用户.这种情况下,如果用户在等待结果的过程中取消了请求,那么服务器端依然会继续执行计算 ...
- ping localhost时出现::1的原因以及解决办法
ping localhost时出现: 在cmd中ping localhost解析出来的是ipv6的::1的原因是windows有个优先解析列表,当ipv6的优先级高于ipv4时,就会出现这种情况. 具 ...
- 中国风?古典系?AI中文绘图创作尝鲜!⛵
作者:韩信子@ShowMeAI 深度学习实战系列:https://www.showmeai.tech/tutorials/42 本文地址:https://www.showmeai.tech/artic ...
- Linux基础第五章 进程控制
5.2 fork fork函数实现进程复制,类似于动物界的单性繁殖,fork函数直接创建一个子进程.这是Linux创建进程最常用的方法.在这一小节中,子进程概念指fork产生的进程,父进程指主动调用f ...