Go 并发读写 sync.map 详细

 更新时间:2021年10月15日 00:01  点击:2307

map 的两种目前在业界使用的最多的并发支持的模式分别是:

  • 原生 map + 互斥锁或读写锁 mutex
  • 标准库 sync.Map(Go1.9及以后)。

有了选择,总是有选择困难症的,这两种到底怎么选,谁的性能更加的好?我有一个朋友说 标准库 sync.Map 性能菜的很,不要用。我到底听谁的...

今天煎鱼就带你揭秘 Go sync.map,我们先会了解清楚什么场景下,Go map 的多种类型怎么用,谁的性能最好!

接着根据各 map 性能分析的结果,针对性的对 sync.map 进行源码解剖,了解 WHY。

一起愉快地开始吸鱼之路。

1、sync.Map 优势

在 Go 官方文档中明确指出 Map 类型的一些建议:

  • 多个 goroutine 的并发使用是安全的,不需要额外的锁定或协调控制。
  • 大多数代码应该使用原生的 map,而不是单独的锁定或协调控制,以获得更好的类型安全性和维护性。

同时 Map 类型,还针对以下场景进行了性能优化:

  • 当一个给定的键的条目只被写入一次但被多次读取时。例如在仅会增长的缓存中,就会有这种业务场景。
  • 当多个 goroutines 读取、写入和覆盖不相干的键集合的条目时。

这两种情况与 Go map 搭配单独的 Mutex RWMutex 相比较,使用 Map 类型可以大大减少锁的争夺。

2、性能测试

听官方文档介绍了一堆好处后,他并没有讲到缺点,所说的性能优化后的优势又是否真实可信。我们一起来验证一下。

首先我们定义基本的数据结构:

// 代表互斥锁
type FooMap struct {
 sync.Mutex
 data map[int]int
}

// 代表读写锁
type BarRwMap struct {
 sync.RWMutex
 data map[int]int
}

var fooMap *FooMap
var barRwMap *BarRwMap
var syncMap *sync.Map

// 初始化基本数据结构
func init() {
 fooMap = &FooMap{data: make(map[int]int, 100)}
 barRwMap = &BarRwMap{data: make(map[int]int, 100)}
 syncMap = &sync.Map{}
}

在配套方法上,常见的增删改查动作我们都编写了相应的方法。用于后续的压测(只展示部分代码):

func builtinRwMapStore(k, v int) {
 barRwMap.Lock()
 defer barRwMap.Unlock()
 barRwMap.data[k] = v
}

func builtinRwMapLookup(k int) int {
 barRwMap.RLock()
 defer barRwMap.RUnlock()
 if v, ok := barRwMap.data[k]; !ok {
  return -1
 } else {
  return v
 }
}

func builtinRwMapDelete(k int) {
 barRwMap.Lock()
 defer barRwMap.Unlock()
 if _, ok := barRwMap.data[k]; !ok {
  return
 } else {
  delete(barRwMap.data, k)
 }
}

其余的类型方法基本类似,考虑重复篇幅问题因此就不在此展示了。

压测方法基本代码如下:

func BenchmarkBuiltinRwMapDeleteParalell(b *testing.B) {
 b.RunParallel(func(pb *testing.PB) {
  r := rand.New(rand.NewSource(time.Now().Unix()))
  for pb.Next() {
   k := r.Intn(100000000)
   builtinRwMapDelete(k)
  }
 })
}

这块主要就是增删改查的代码和压测方法的准备,压测代码直接复用的是大白大佬的 go19-examples/benchmark-for-map 项目。

也可以使用 Go 官方提供的 map\_bench\_test.go,有兴趣的小伙伴可以自己拉下来运行试一下。

2.1 压测结果

1)写入

含义 压测结果
BenchmarkBuiltinMapStoreParalell-4 map+mutex 写入元素 237.1 ns/op
BenchmarkSyncMapStoreParalell-4 sync.map 写入元素 509.3 ns/op
BenchmarkBuiltinRwMapStoreParalell-4 map+rwmutex 写入元素 207.8 ns/op

总体的排序(从慢到快)为:SyncMapStore < MapStore < RwMapStore。

2)查找

方法名 含义 压测结果
BenchmarkBuiltinMapLookupParalell-4 map+mutex 查找元素 166.7 ns/op
BenchmarkBuiltinRwMapLookupParalell-4 map+rwmutex 查找元素 60.49 ns/op
BenchmarkSyncMapLookupParalell-4 sync.map 查找元素 53.39 ns/op

在查找元素上,最慢的是原生 map+互斥锁,其次是原生 map+读写锁。最快的是 sync.map 类型。

总体的排序为:MapLookup < RwMapLookup < SyncMapLookup。

3)删除

方法名 含义 压测结果
BenchmarkBuiltinMapDeleteParalell-4 map+mutex 删除元素 168.3 ns/op
BenchmarkBuiltinRwMapDeleteParalell-4 map+rwmutex 删除元素 188.5 ns/op
BenchmarkSyncMapDeleteParalell-4 sync.map 删除元素 41.54 ns/op

在删除元素上,最慢的是原生 map+读写锁,其次是原生 map+互斥锁,最快的是 sync.map 类型。

总体的排序为:RwMapDelete < MapDelete < SyncMapDelete

2.3 场景分析

根据上述的压测结果,我们可以得出 sync.Map 类型:

  • 在读和删场景上的性能是最佳的,领先一倍有多。
  • 在写入场景上的性能非常差,落后原生 map+锁整整有一倍之多。

因此在实际的业务场景中。假设是读多写少的场景,会更建议使用 sync.Map 类型。

但若是那种写多的场景,例如多 goroutine 批量的循环写入,那就建议另辟途径了,性能不忍直视(无性能要求另当别论)。

3、sync.Map 剖析

清楚如何测试,测试的结果后。我们需要进一步深挖,知其所以然。

为什么 sync.Map 类型的测试结果这么的 “偏科”,为什么读操作性能这么高,写操作性能低的可怕,他是怎么设计的?

3.1 数据结构

sync.Map 类型的底层数据结构如下:

type Map struct {
 mu Mutex
 read atomic.Value // readOnly
 dirty map[interface{}]*entry
 misses int
}

// Map.read 属性实际存储的是 readOnly。
type readOnly struct {
 m       map[interface{}]*entry
 amended bool
}

  • mu:互斥锁,用于保护 read dirty
  • read:只读数据,支持并发读取(atomic.Value 类型)。如果涉及到更新操作,则只需要加锁来保证数据安全。read 实际存储的是 readOnly 结构体,内部也是一个原生 mapamended 属性用于标记 read dirty 的数据是否一致。
  • dirty:读写数据,是一个原生 map,也就是非线程安全。操作 dirty 需要加锁来保证数据安全。
  • misses:统计有多少次读取 read 没有命中。每次 read 中读取失败后,misses 的计数值都会加 1。

read dirty 中,都有涉及到的结构体:

type entry struct {
 p unsafe.Pointer // *interface{}
}


其包含一个指针 p, 用于指向用户存储的元素(key)所指向的 value 值。

在此建议你必须搞懂 readdirtyentry,再往下看,食用效果会更佳,后续会围绕着这几个概念流转。

3.2 查找过程

划重点,Map 类型本质上是有两个 “map”。一个叫 read、一个叫 dirty,长的也差不多:

sync.Map 的 2 个 map

当我们从 sync.Map 类型中读取数据时,其会先查看 read 中是否包含所需的元素:

  • 若有,则通过 atomic 原子操作读取数据并返回。
  • 若无,则会判断 read.readOnly 中的 amended 属性,他会告诉程序 dirty 是否包含 read.readOnly.m 中没有的数据;因此若存在,也就是 amended 为 true,将会进一步到 dirty 中查找数据。

sync.Map 的读操作性能如此之高的原因,就在于存在 read 这一巧妙的设计,其作为一个缓存层,提供了快路径(fast path)的查找。

同时其结合 amended 属性,配套解决了每次读取都涉及锁的问题,实现了读这一个使用场景的高性能。

3.3 写入过程

我们直接关注 sync.Map 类型的 Store 方法,该方法的作用是新增或更新一个元素。

源码如下:

func (m *Map) Store(key, value interface{}) {
 read, _ := m.read.Load().(readOnly)
 if e, ok := read.m[key]; ok && e.tryStore(&value) {
  return
 }
  ...
}


调用 Load 方法检查 m.read 中是否存在这个元素。若存在,且没有被标记为删除状态,则尝试存储。

若该元素不存在或已经被标记为删除状态,则继续走到下面流程:

func (m *Map) Store(key, value interface{}) {
 ...
 m.mu.Lock()
 read, _ = m.read.Load().(readOnly)
 if e, ok := read.m[key]; ok {
  if e.unexpungeLocked() {
   m.dirty[key] = e
  }
  e.storeLocked(&value)
 } else if e, ok := m.dirty[key]; ok {
  e.storeLocked(&value)
 } else {
  if !read.amended {
   m.dirtyLocked()
   m.read.Store(readOnly{m: read.m, amended: true})
  }
  m.dirty[key] = newEntry(value)
 }
 m.mu.Unlock()
}

由于已经走到了 dirty 的流程,因此开头就直接调用了 Lock 方法上互斥锁,保证数据安全,也是凸显性能变差的第一幕。

其分为以下三个处理分支:

  • 若发现 read 中存在该元素,但已经被标记为已删除(expunged),则说明 dirty 不等于 nil(dirty 中肯定不存在该元素)。其将会执行如下操作。
  • 将元素状态从已删除(expunged)更改为 nil。
  • 将元素插入 dirty 中。
  • 若发现 read 中不存在该元素,但 dirty 中存在该元素,则直接写入更新 entry 的指向。
  • 若发现 read dirty 都不存在该元素,则从 read 中复制未被标记删除的数据,并向 dirty 中插入该元素,赋予元素值 entry 的指向。

我们理一理,写入过程的整体流程就是:

  • readread 上没有,或者已标记删除状态。
  • 上互斥锁(Mutex)。
  • 操作 dirty,根据各种数据情况和状态进行处理。

回到最初的话题,为什么他写入性能差那么多。究其原因:

  • 写入一定要会经过 read,无论如何都比别人多一层,后续还要查数据情况和状态,性能开销相较更大。
  • (第三个处理分支)当初始化或者 dirty 被提升后,会从 read 中复制全量的数据,若 read 中数据量大,则会影响性能。

可得知 sync.Map 类型不适合写多的场景,读多写少是比较好的。

若有大数据量的场景,则需要考虑 read 复制数据时的偶然性能抖动是否能够接受。

3.4 删除过程

这时候可能有小伙伴在想了。写入过程,理论上和删除不会差太远。怎么 sync.Map 类型的删除的性能似乎还行,这里面有什么猫腻?

源码如下:

func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
 read, _ := m.read.Load().(readOnly)
 e, ok := read.m[key]
 ...
  if ok {
  return e.delete()
 }
}

删除是标准的开场,依然先到 read 检查该元素是否存在。

若存在,则调用 delete 标记为 expunged(删除状态),非常高效。可以明确在 read 中的元素,被删除,性能是非常好的。

若不存在,也就是走到 dirty 流程中:

func (m *Map) LoadAndDelete(key interface{}) (value interface{}, loaded bool) {
 ...
 if !ok && read.amended {
  m.mu.Lock()
  read, _ = m.read.Load().(readOnly)
  e, ok = read.m[key]
  if !ok && read.amended {
   e, ok = m.dirty[key]
   delete(m.dirty, key)
   m.missLocked()
  }
  m.mu.Unlock()
 }
 ...
 return nil, false
}

read 中不存在该元素,dirty 不为空,read dirty 不一致(利用 amended 判别),则表明要操作 dirty,上互斥锁。

再重复进行双重检查,若 read 仍然不存在该元素。则调用 delete 方法从 dirty 中标记该元素的删除。

需要注意,出现频率较高的 delete 方法:

func (e *entry) delete() (value interface{}, ok bool) {
 for {
  p := atomic.LoadPointer(&e.p)
  if p == nil || p == expunged {
   return nil, false
  }
  if atomic.CompareAndSwapPointer(&e.p, p, nil) {
   return *(*interface{})(p), true
  }
 }
}

该方法都是将 entry.p 置为 nil,并且标记为 expunged(删除状态),而不是真真正正的删除。

注:不要误用 sync.Map,前段时间从字节大佬分享的案例来看,他们将一个连接作为 key 放了进去,于是和这个连接相关的,例如:buffer 的内存就永远无法释放了...

总结:

针对 sync.Map 的性能差异,进行了深入的源码剖析,了解到了其背后快、慢的原因,实现了知其然知其所以然。

经常看到并发读写 map 导致致命错误,实在是令人忧心。大家觉得如果本文不错,欢迎分享给更多的 Go 爱好者 :)

到此这篇关于Go 并发读写 sync.map 详细的文章就介绍到这了,更多相关Go 并发读写 sync.map 内容请搜索猪先飞以前的文章或继续浏览下面的相关文章希望大家以后多多支持猪先飞!

[!--infotagslink--]

相关文章

  • 基于springcloud异步线程池、高并发请求feign的解决方案

    这篇文章主要介绍了基于springcloud异步线程池、高并发请求feign的解决方案,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2021-02-25
  • Go应用中优雅处理Error的技巧总结

    在程序员中,尤其是go新手,经常听到的一个讨论话题是:如何处理错误,这篇文章主要给大家介绍了关于Go应用中优雅处理Error的一些相关技巧,需要的朋友可以参考下...2021-09-08
  • Django def clean()函数对表单中的数据进行验证操作

    这篇文章主要介绍了Django def clean()函数对表单中的数据进行验证操作,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2020-07-09
  • C#编程高并发的几种处理方法详解

    这篇文章主要为大家详细介绍了C#编程高并发的几种处理方法,具有一定的参考价值,感兴趣的小伙伴们可以参考一下...2020-06-25
  • C# 向二进制文件进行读写的操作方法

    该例子使用 BinaryStream 和 BinaryWriter 对二进制文件进行读写操作先上代码再根据我理解的所分享给各位朋友...2020-06-25
  • 解决线程并发redisson使用遇到的坑

    这篇文章主要介绍了解决线程并发redisson使用遇到的坑,具有很好的参考价值,希望对大家有所帮助。如有错误或未考虑完全的地方,望不吝赐教...2021-06-18
  • C#读写共享文件夹的方法

    这篇文章主要为大家详细介绍了C#读写共享文件夹的方法,具有一定的参考价值,感兴趣的小伙伴们可以参考一下...2020-06-25
  • golang官方嵌入文件到可执行程序的示例详解

    这篇文章主要介绍了golang官方嵌入文件到可执行程序,本文通过示例代码给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下...2021-02-20
  • go浮点数转字符串保留小数点后N位的完美解决方法

    这篇文章主要介绍了go浮点数转字符串保留小数点后N位解决办法,本文给大家介绍的非常详细,对大家的学习或工作具有一定的参考借鉴价值,需要的朋友可以参考下...2020-05-11
  • 解决docker挂载的目录无法读写问题

    这篇文章主要介绍了解决docker挂载的目录无法读写问题,具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2021-03-19
  • Go语言使用读写OPC详解

    这篇文章主要介绍了Go语言使用读写OPC详解,图文讲解的很清晰,有感兴趣的同学可以学习下...2021-03-05
  • 使用Python文件读写,自定义分隔符(custom delimiter)

    这篇文章主要介绍了使用Python文件读写,自定义分隔符(custom delimiter),具有很好的参考价值,希望对大家有所帮助。一起跟随小编过来看看吧...2020-07-06
  • asp.net通过消息队列处理高并发请求(以抢小米手机为例)

    这篇文章主要介绍了asp.net通过消息队列处理高并发请求(以抢小米手机为例),文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧...2021-09-22
  • Go中string与[]byte高效互转的方法实例

    string与[]byte经常需要互相转化,普通转化会发生底层数据的复制,下面这篇文章主要给大家介绍了关于Go中string与[]byte高效互转的相关资料,文中通过示例代码介绍的非常详细,需要的朋友可以参考下...2021-09-20
  • Go项目的目录结构详解

    这篇文章主要介绍了Go项目的目录结构,对基础目录做了讲解,对项目开发中的其它目录也一并做了介绍,需要的朋友可以参考下...2020-05-01
  • 分享C#操作内存读写方法的主要实现代码

    这篇文章介绍了C#操作内存读写方法的主要实现代码,下面让我们来看看具体的实例实现,有需要的朋友可以参考一下...2020-06-25
  • Go 容器遍历的实现示例

    Go 语言提供的基础容器,免不了要查询容器中的数据,那么是如何实现遍历的呢?本文将会介绍几种常用容易的遍历及其使用。感兴趣的可以了解一下...2021-06-13
  • 创建第一个Go语言程序Hello,Go!

    这篇文章主要介绍了创建第一个Go语言程序Hello,Go!本文详细的给出项目创建、代码编写的过程,同时讲解了GOPATH、Go install等内容,需要的朋友可以参考下...2020-05-01
  • 在Django中使用MQTT的方法

    这篇文章主要介绍了在Django中使用MQTT的方法,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧...2021-05-10
  • c#编写的高并发数据库控制访问代码

    往往大数据量,高并发时, 瓶颈都在数据库上, 好多人都说用数据库的复制,发布, 读写分离等技术, 但主从数据库之间同步时间有延迟....2020-06-25