Golang 基础之并发基本同步原语(三)

213 阅读6分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第1天,点击查看活动详情

大家好,今天将梳理出的 Go语言并发知识内容,分享给大家。 请多多指教,谢谢。

本文主要介绍 sync 标准库中的 sync.Map 介绍及使用。

本章节内容

  • sync.Map

sync.Map

介绍

sync.Map 类似于 Go内置 Map[interface{}]interface{},但对于多个 goroutine 并发使用是安全的,无需额外的锁或协调。加载、存储和删除在平摊常数时间内运行。

sync.Map类型针对两个常见的用例进行了优化:

  1. 当一个给定键的条目只写入一次而被多次读取时,就像在缓存中只会增长一样。
  2. 当多个 goroutine 对不相交的键集读、写和覆盖条目时。在这两种情况下,与单独 MutexRWMutex 配对的Go Map相比,使用 sync.Map 类型可以显著减少锁争用。

总之来说适合大量读,少量写。

历史版本

在Go v1.6之前,内置 map 是部分 goroutine 安全的,并发读没有问题,并发写可能有问题。

在Go v1.6之后,并发读写内置 map 会报错,在一些知名的开源库都有这个问题,所以在Go v1.9之前,解决方案是加一个额外的大锁,锁住map。

在Go v1.9之后,Go官方推出了 Mapsync.Map

sync.Map 类型原型

// entry 键值对中的值结构体
type entry struct {
  p unsafe.Pointer // 指针,指向实际存储value值的地方
}
​
// Map 并发安全的map结构体
type Map struct {
  mu sync.Mutex // 锁,保护read和dirty字段
​
  read atomic.Value // 存仅读数据,原子操作,并发读安全,实际存储readOnly类型的数据
​
  dirty map[interface{}]*entry // 存最新写入的数据
​
  misses int // 计数器,每次在read字段中没找所需数据时,+1
  // 当此值到达一定阈值时,将dirty字段赋值给read
}
​
// readOnly 存储mao中仅读数据的结构体
type readOnly struct {
  m       map[interface{}]*entry // 其底层依然是个最简单的map
  amended bool                   // 标志位,标识m.dirty中存储的数据是否和m.read中的不一样,flase 相同,true不相同
}
​
func (m *Map) Delete(key any)
func (m *Map) Load(key any) (value any, ok bool)
func (m *Map) LoadAndDelete(key any) (value any, loaded bool)
func (m *Map) LoadOrStore(key, value any) (actual any, loaded bool)
func (m *Map) Range(f func(key, value any) bool)
func (m *Map) Store(key, value any)

sync.Map 中key和value是分开存放的,key通过内置map指向entry,entry通过指针,指向value实际内存地址。

需要注意的地方:

  • read在进行非读操作时,需要锁mu进行保护
  • 写入的数据,都是直接写到dirty,后面根据read miss次数达到阈值,会进行read和dirty数据的同步
  • readOnly中专门有一个标志位,用来标注read和dirty中是否有不同,以便进行read和dirty数据同步

func (m *Map) Delete(key any) 方法:删除键的值。

func (m *Map) Load(key any) 方法: 返回存储在映射中的某个键的值,如果没有值,则返回nil。ok结果表明是否在映射中找到了值。

func (m *Map) LoadAndDelete(key any) 方法:删除键的值,如果有则返回前一个值。加载的结果报告键是否存在。

func (m *Map) LoadOrStore(key, value any) 方法:如果存在,则返回键的现有值。否则,它存储并返回给定的值。如果该值已加载,则加载结果为true,如果已存储则为false。

func (m *Map) Range(f func(key, value any) bool) 方法:Range对映射中出现的每个键和值依次调用f。如果f返回false, range将停止迭代。

func (m *Map) Store(key, value any) 方法:设置键的值。

sync.Map 设计思想

空间换时间

sync.Map 中冗余的数据结构就是 dirtyread,二者存放的都是 key-entryentry 其实是一个指针,指向 valuereaddirty 各自维护一套 keykey 指向的都是同一个 value ,也就是说,只要修改了这个entry,对 readdirty 都是可见的。

拿空间换时间策略在 sync.Map 中的体现:

  • 遍历操作:只需遍历read即可,而read是并发读安全的,没有锁,相比于加锁方案,性能大为提升
  • 查找操作:先在read中查找,read中找不到再去dirty中找

核心思想就是一切操作先去read中执行,因为read是并发读安全的,无需锁,实在在read中找不到,再去dirty中。read在sycn.Map 中是一种冗余的数据结构,因为read和dirty中数据有很大一部分是重复的,而且二者还会进行数据同步。

读写分离

sync.Map 中有专门用于读的数据结构:read,将其和写操作分离开来,可以避免读写冲突。而采用读写分离策略的代价就是冗余的数据结构,其实还是空间换时间的思想。

双检查机制

sync.Map 中,每次当 read 不符合要求要去操作 dirty 前, 都会上锁, 上锁后再次判断是否符合要求, 因为 read 有可能在上锁期间,产生了变化,突然又符合要求了。

通过额外的一次检查操作,来避免在第一次检查操作完成后,其他的操作使得检查条件产生突然符合要求的可能。

延迟删除

在删除操作中,删除 key-value 仅仅只是先将需要删除的 key-value 打一个标记,这样可以尽快的让 delete 操作先返回,减少耗时,在后面提升 dirty 时,在一次性的删除需要删除的 key-value。

read 优先

需要进行读取、删除、更新操作时,优先操作 read,因为 read 无锁的,更快。 如果在 read 中得不到结果,再去 dirty中。

read 的修改操作需要加锁, read只是并发读安全,并发写并不安全。

状态机制

entry 的指针是有状态的,主要分为:nil、expunged(指向被删除的元素)、正常状态。

主要是两个操作会引起 entry指针状态的变化:Store()(新增/修改)和 Delete()(删除)

sync.Map 实践

基本使用

package main
​
import (
  "fmt"
  "sync"
)
​
func main() {
  var m sync.Map
  // 写入
  m.Store("key1", 1)
  m.Store("key2", 2)
  // 读取
  value, _ := m.Load("key1")
  fmt.Println(value.(int))
  // 遍历
  m.Range(func(key, value interface{}) bool {
    k := key.(string)
    v := value.(int)
    fmt.Println(k, v)
    return true
  })
  // 删除
  m.Delete("key1")
  value, ok := m.Load("key1")
  fmt.Println(value, ok)
  // 读取或写入
  m.LoadOrStore("key2", 22)
  value, _ = m.Load("key2")
  fmt.Println(value)
}

输出

1
key1 1
key2 2
<nil> false
2

第 1 步,写入两个 k-v 对;

第 2 步,使用 Load 方法读取其中的一个 key;

第 3 步,遍历所有的 k-v 对,并打印出来;

第 4 步,删除其中的一个 key,再读这个 key,得到的就是 nil;

第 5 步,使用 LoadOrStore,尝试读取或写入 "key2",因为这个 key 已经存在,因此写入不成功,并且读出原值。

解决并发中write问题

golang的map是非协程安全的,并发写是会出现错误

package main
​
func main() {
  m := map[int]int{1:1}
  go concurrent(m)
  go concurrent(m)
  select{}
}
​
func concurrent(m map[int]int) {
  i := 0
  for i < 10000 {
    m[1] = 1 // 频繁写
    i++
  }
}

输出

fatal error: concurrent map writes

在多个goroutine中,map不能同时写。

使用 sync.Map 解决不能同时写问题

package main
​
import (
  "fmt"
  "sync"
  "time"
)
​
func main() {
  m := sync.Map{}
  m.Store(1, 1)
  go concurrent(m)
  go concurrent(m)
  time.Sleep(3*time.Second)
  fmt.Println(m.Load(1))
}
​
func concurrent(m sync.Map) {
  i := 0
  for i < 10000 {
    m.Store(1, 1) // 频繁写
    i++
  }
}

输出

1 true

互斥锁与sync.Map效率对比

package main
​
import (
  "fmt"
  "time"
  "sync"
)
​
var s sync.RWMutex
var w sync.WaitGroup
​
func main() {
  mapTest()
  syncMapTest()
}
​
func mapTest() {
  m := map[int]int {1:1}
  startTime := time.Now().Nanosecond()
  w.Add(1)
  go writeMap(m)
  w.Add(1)
  go writeMap(m)
  w.Add(1)
  go readMap(m)
​
  w.Wait()
  endTime := time.Now().Nanosecond()
  timeDiff := endTime-startTime
  fmt.Println("map:",timeDiff)
}
​
func writeMap (m map[int]int) {
  defer w.Done()
  i := 0
  for i < 10000 {
    // 加锁
    s.Lock()
    m[1]=1
    // 解锁
    s.Unlock()
    i++
  }
}
​
func readMap (m map[int]int) {
  defer w.Done()
  i := 0
  for i < 10000 {
    s.RLock()
    _ = m[1]
    s.RUnlock()
    i++
  }
}
​
func syncMapTest() {
  m := sync.Map{}
  m.Store(1,1)
  startTime := time.Now().Nanosecond()
  w.Add(1)
  go writeSyncMap(m)
  w.Add(1)
  go writeSyncMap(m)
  w.Add(1)
  go readSyncMap(m)
​
  w.Wait()
  endTime := time.Now().Nanosecond()
  timeDiff := endTime-startTime
  fmt.Println("sync.Map:",timeDiff)
}
​
func writeSyncMap (m sync.Map) {
  defer w.Done()
  i := 0
  for i < 10000 {
    m.Store(1,1)
    i++
  }
}
​
func readSyncMap (m sync.Map) {
  defer w.Done()
  i := 0
  for i < 10000 {
    m.Load(1)
    i++
  }
}

对比结果如下:

情况结果
只写map: 1374000 sync.Map: 2899000
读写map: 7607000 sync.Map: 22295000

总结:在大量写的场景下, sync.Map 的效率没有单纯 map + Mutex的效率高。 读写场景下因为互斥锁大量消耗解锁加锁,性能消耗 sync.Map 最优。

技术文章持续更新,请大家多多关注呀~~

搜索微信公众号,关注我【 帽儿山的枪手 】


参考资料

sync.Map 设计思想和底层源码分析 www.cnblogs.com/yinbiao/p/1…

由浅入深聊 Golang的 sync.Map blog.csdn.net/u011957758/…