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的流程如下:

flowchart LR

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的代码流程图画出来

flowchart TD

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的代码转化为流程图如下,

flowchart TD

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,我该选择哪个?的更多相关文章

  1. sync.Map(在并发环境中使用的map)

    sync.Map 有以下特性: 需要并发读写时,一般的做法是加锁,但这样性能并不高,Go语言在 1.9 版本中提供了一种效率较高的并发安全的 sync.Map,sync.Map 和 map 不同,不是 ...

  2. 看过这篇剖析,你还不懂 Go sync.Map 吗?

    hi, 大家好,我是 haohongfan. 本篇文章会从使用方式和原码角度剖析 sync.Map.不过不管是日常开发还是开源项目中,好像 sync.Map 并没有得到很好的利用,大家还是习惯使用 M ...

  3. sync.Map与Concurrent Map

    1. sync.Map 1.1. map并发不安全 go1.6以后map有了并发的安全检查,所以如果在并发环境中读写map就会报错 func unsafeMap() { // 创建一个map对象 m ...

  4. 并发安全 sync.Map

    https://mp.weixin.qq.com/s/MqPm7QH3_D9roVkpTs9Xpw 谈谈Go的并发安全相关 原创 歪鼻子 歪鼻子 2020-12-27     package main ...

  5. Go 1.9 sync.Map揭秘

    Go 1.9 sync.Map揭秘 目录 [−] 有并发问题的map Go 1.9之前的解决方案 sync.Map Load Store Delete Range sync.Map的性能 其它 在Go ...

  6. 源码解读 Golang 的 sync.Map 实现原理

    简介 Go 的内建 map 是不支持并发写操作的,原因是 map 写操作不是并发安全的,当你尝试多个 Goroutine 操作同一个 map,会产生报错:fatal error: concurrent ...

  7. 深入理解golang:sync.map

    疑惑开篇 有了map为什么还要搞个sync.map 呢?它们之间有什么区别? 答:重要的一点是,map并发不是安全的. 在Go 1.6之前, 内置的map类型是部分goroutine安全的,并发的读没 ...

  8. go sync.map源码解析

    go中的map是并发不安全的,同时多个协程读取不会出现问题,但是多个协程 同时读写就会出现 fatal error:concurrent map read and map write的错误.通用的解决 ...

  9. Golang:sync.Map

    由于map在gorountine 上不是安全的,所以在大量并发读写的时候,会出现错误. 在1.9版的时候golang推出了sync.Map. sync.Map 通过阅读源码我们发现sync.Map是通 ...

  10. golang fatal error: concurrent map read and map write

    调试程序的时候,为了打印map中的内容 ,直接 使用seelog 的方法打印 map中的内容到日志,结果出现 “concurrent map read and map write”的错误,导致程序异常 ...

随机推荐

  1. hashlib加密模块、加密补充说明、subprocess模块、logging日志模块

    目录 hashlib加密模块 加密补充说明 subprocess模块 logging日志模块 日志的组成 日志配置字典 hashlib加密模块 1.何为加密 将明文数据处理成密文数据 让人无法看懂 2 ...

  2. vue 中使用 this 更新数据的一次大坑

    情景说明: 之前用 vue 做数据绑定更新时,发现一个莫名奇妙的问题. 我在 vue 实例中声明了一个数组属性如 books: [],在异步请求的回调函数中使用 this.books = res.da ...

  3. HDC2022的无障碍参会体验,手语服务是如何做到的?

    华为开发者大会2022(HDC)上,HMS Core手语数字人以全新形象亮相,并在直播中完成了长达3个多小时的实时手语翻译,向线上线下超过一千万的观众提供了专业.实时.准确的手语翻译服务,为听障人士提 ...

  4. KVC原理与数据筛选

    作者:宋宏帅 1 前言 在技术论坛中看到一则很有意思的KVC案例: @interface Person : NSObject @property (nonatomic, copy) NSString ...

  5. day28 BOM浏览器对象 & 定时事件与Cookie & (视频卷子讲解)

    3.10 BOM浏览器对象模型 3.10.1 window对象 所有浏览器都支持window对象,它表示浏览器窗口: | 属性 | history 网页历史记录 返回History只读对象 locat ...

  6. 学习ASP.NET Core Blazor编程系列十五——查询

    学习ASP.NET Core Blazor编程系列一--综述 学习ASP.NET Core Blazor编程系列二--第一个Blazor应用程序(上) 学习ASP.NET Core Blazor编程系 ...

  7. 【每日一题】【快速排序过程、循环过程无=、递归参数】2022年1月16日-NC140 排序

    快速排序 对时间复杂度和空间复杂度有要求 方法1:快速排序-递归 import java.util.*; public class Solution { /** * 代码中的类名.方法名.参数名已经指 ...

  8. python装饰器初级

    global与nonlocal 1.global的作用: 可以在局部空间里直接就该全局名称工具中的数据 代码展示: name = 'moon' #设置了一个全局变量 def fucn(): name ...

  9. 什么是JS?JS的用途?

    一.JavaScript是什么?它有什么作用? JavaScript是一种运行在客户端的脚本语言,简称JS,属于解释性语言.它是一行翻译执行完以后再进行下一行,代码不进行预编译. JavaScript ...

  10. 如何通过 C#/VB.NET 将 PDF 转为 Word

    众所周知,PDF 文档支持特长文件,集成度和安全可靠性都较高,可有效防止他人对 PDF 内容进行更改,所以在工作中深受大家喜爱.但是在工作中,我们不可避免的会对 PDF 文档进行修改或再编辑,这时我们 ...