关于php:Go-并发读写-syncmap-的强大之处

39次阅读

共计 6768 个字符,预计需要花费 17 分钟才能阅读完成。

大家好,我是煎鱼。

在之前的《为什么 Go map 和 slice 是非线程平安的?》文章中,咱们探讨了 Go 语言的 map 和 slice 非线程平安的问题,基于此引申出了 map 的两种目前在业界应用的最多的并发反对的模式。

别离是:

  • 原生 map + 互斥锁或读写锁 mutex。
  • 规范库 sync.Map(Go1.9 及当前)。

有了抉择,总是有抉择艰难症的,这 两种到底怎么选,谁的性能更加的好?我有一个敌人说 规范库 sync.Map 性能菜的很,不要用。我到底听谁的 …

明天煎鱼就带你揭秘 Go sync.map,咱们先会理解分明什么场景下,Go map 的多种类型怎么用,谁的性能最好!

接着依据各 map 性能剖析的后果,针对性的对 sync.map 进行源码解剖,理解 WHY。

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

sync.Map 劣势

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

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

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

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

这两种状况与 Go map 搭配独自的 Mutex 或 RWMutex 相比拟,应用 Map 类型能够大大减少锁的抢夺。

性能测试

听官网文档介绍了一堆益处后,他并没有讲到毛病,所说的性能优化后的劣势又是否真实可信。咱们一起来验证一下。

首先咱们定义根本的数据结构:

// 代表互斥锁
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,有趣味的小伙伴能够本人拉下来运行试一下。

压测后果

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

在写入元素上,最慢的是 sync.map 类型,其次是原生 map+ 互斥锁(Mutex),最快的是原生 map+ 读写锁(RwMutex)。

总体的排序(从慢到快)为: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。

场景剖析

根据上述的压测后果,咱们能够得出 sync.Map 类型:

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

因而在理论的业务场景中。假如是读多写少的场景,会更倡议应用 sync.Map 类型。

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

sync.Map 分析

分明如何测试,测试的后果后。咱们须要进一步深挖,知其所以然。

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

数据结构

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 构造体,外部也是一个原生 map,amended 属性用于标记 read 和 dirty 的数据是否统一。
  • dirty:读写数据,是一个原生 map,也就是非线程平安。操作 dirty 须要加锁来保障数据安全。
  • misses:统计有多少次读取 read 没有命中。每次 read 中读取失败后,misses 的计数值都会加 1。

在 read 和 dirty 中,都有波及到的构造体:

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

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

在此倡议你必须搞懂 read、dirty、entry,再往下看,食用成果会更佳,后续会围绕着这几个概念流转。

查找过程

划重点,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 属性,配套解决了每次读取都波及锁的问题,实现了读这一个应用场景的高性能。

写入过程

咱们间接关注 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 的指向。

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

  • 查 read,read 上没有,或者已标记删除状态。
  • 上互斥锁(Mutex)。
  • 操作 dirty,依据各种数据状况和状态进行解决。

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

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

可得悉 sync.Map 类型不适宜写多的场景,读多写少是比拟好的。

若有大数据量的场景,则须要思考 read 复制数据时的必然性能抖动是否可能承受。

删除过程

这时候可能有小伙伴在想了。写入过程,实践上和删除不会差太远。怎么 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 + 互斥锁 / 读写锁之间的性能状况。

规范库 sync.Map 虽说反对并发读写 map,但更实用于读多写少的场景,因为他写入的性能比拟差,应用时要思考分明这一点。

另外咱们针对 sync.Map 的性能差别,进行了深刻的源码分析,理解到了其背地快、慢的起因,实现了知其然知其所以然。

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

若有任何疑难欢送评论区反馈和交换,最好的关系是相互成就 ,各位的 点赞 就是煎鱼创作的最大能源,感激反对。

文章继续更新,能够微信搜【脑子进煎鱼了】浏览,本文 GitHub github.com/eddycjy/blog 已收录,欢送 Star 催更。

参考

  • Package sync
  • 踩了 Golang sync.Map 的一个坑
  • go19-examples/benchmark-for-map
  • 通过实例深刻了解 sync.Map 的工作原理

正文完
 0