详解Go语言如何解决map并发安全问题

 更新时间:2024年04月11日 11:30:21   作者:shark_chili  
常说go语言是一门并发友好的语言,对于并发操作总会在编译期完成安全检查,所以这篇文章我们就来聊聊go语言是如何解决map这个数据结构的线程安全问题吧

常说go语言是一门并发友好的语言,对于并发操作总会在编译期完成安全检查,所以这篇文章我们就来聊聊go语言是如何解决map这个数据结构的线程安全问题。

详解map中的并发安全问题

问题复现

我们通过字面量的方式创建一个map集合,然后开启两个协程,其中协程1负责写,协程2负责读:

func main() {
 //创建map
 m := make(map[int]string)
 //声明一个长度为2的倒计时门闩
 var wg sync.WaitGroup
 wg.Add(2)

 //协程1写
 go func() {
  for true {
   m[0] = "xiaoming"
  }
  wg.Done()
 }()

 //协程2读
 go func() {
  for true {
   _ = m[0]
  }
  wg.Done()

 }()

 wg.Wait()
 fmt.Println("结束")
}

在完成编译后尝试运行

fatal error: concurrent map read and map write 

并发操作失败的原因

我们直接假设一个场景,协程并发场景下当前的map处于扩容状态,假设我们的协程1修改了key-111对应的元素触发渐进式驱逐操作,使得key-111移动到新桶上,结果协程2紧随其后尝试读取key-111对应的元素,结果得到nil,由此引发了协程安全问题:

上锁解决并发安全问题

Java一样,go语言也有自己的锁sync.Mutex,我们在协程进行map操作前后进行上锁和释放的锁的操作,确保单位时间内只有一个协程在操作map,从而实现协程安全,因为这种锁是排他锁,这使得协程的并发特性得不到发挥:

var mu sync.Mutex


func main() {
 //创建map
 m := make(map[int]string)
 
 var wg sync.WaitGroup
 wg.Add(2)

 //协程1上锁后写
 go func() {

  for true {
   mu.Lock()
   m[0] = "xiaoming"
   mu.Unlock()
  }
  wg.Done()
 }()

 //协程2上锁后读
 go func() {
  for true {
   mu.Lock()
   _ = m[0]
   mu.Unlock()
  }
  wg.Done()

 }()

 wg.Wait()
 fmt.Println("结束")
}

使用自带的sync.map进行并发读写

好在go语言为我们提供的现成的"轮子",即sync.Map,我们直接通过其内置函数storeload即可实现并发读写还能保证协程安全:

func main() {
 //创建sync.Map
 var m sync.Map
 
 
 var wg sync.WaitGroup
 wg.Add(2)

 //协程1并发写
 go func() {

  for true {
   m.Store(1, "xiaoming")
  }
  wg.Done()
 }()

 //协程2并发读
 go func() {
  for true {
   m.Load(1)
  }
  wg.Done()

 }()

 wg.Wait()
 fmt.Println("结束")
}

详解sync.map并发操作流程

常规sync.map并发读或写

sync.map会有一个readdirty指针,指向不同的key数组,但是这些key对应的value指针都是一样的,这意味着这个map不同桶的相同key共享同一套value

进行并发读取或者写的时候,首先拿到一个原子类型的read指针,通过CAS尝试修改元素值,如果成功则直接返回,就如下图所示,我们的协程通过CAS完成原子指针数值读取之后,直接操作read指针所指向的map元素,通过key定位到value完成修改后直接返回。

sync.map修改或追加

接下来再说说另一种情况,假设我们追加一个元素key-24,通过read指针进行读取发现找不到,这就意味当前元素不存在或者在dirty指针指向的map下,所以我们会先上重量级锁,然后再上一次read锁。 分别到readdirty指针上查询对应key,进行如下三部曲:

  • 如果在read发现则修改。
  • 如果在dirty下发现则修改。
  • 都没发现则说明要追加了,则将amended设置为true说明当前map脏了,尝试将元素追加到dirty指针管理的map下。

这里需要补充一句,通过amended可知当前map是否处于脏写状态,如果这个标志为true,后续每次读写未命中都会对misses进行自增操作,一旦未命中数达到dirty数组的长度(大抵是想表达所有未命中的都在dirty数组上)阈值就会进行一次dirty提升,将dirty的key提升为read指针指向的数组,确保提升后续并发读写的命中率:

sync.map并发删除

并发删除也和上述并发读写差不多,都是先通过read指针尝试是否成功,若不成功则锁主mutex到dirty进行删除,所以这里就不多赘述了。

sync.map源码解析

sync.map内存结构

通过上文我们了解了sync.map的基本操作,这里我们再回过头看看sync.map的数据结构,即重量级锁mu Mutex,

type Map struct {
 //重量级锁
 mu Mutex
 //read指针,指向一个不可变的key数组
 read atomic.Pointer[readOnly]

 //dirty 指针指向可以进行追加操作的key数组
 dirty map[any]*entry

 //当前map读写未命中次数
 misses int
}

sync.Map并发写源码

并发写底层本质是调用Swap进行追加或者修改:

func (m *Map) Store(key, value any) {
 _, _ = m.Swap(key, value)
}

步入swap底层即可看到上文图解的操作,这里我们给出核心源码,读者可自行参阅:

func (m *Map) Swap(key, value any) (previous any, loaded bool) {
 //上read尝试修改
 read := m.loadReadOnly()
 if e, ok := read.m[key]; ok {
  if v, ok := e.trySwap(&value); ok {
   if v == nil {
    return nil, false
   }
   return *v, true
  }
 }
 //上重量级锁和read原子指针加载进行修改
 m.mu.Lock()
 read = m.loadReadOnly()
 if e, ok := read.m[key]; ok {
  if e.unexpungeLocked() {
   
   m.dirty[key] = e
  }
  if v := e.swapLocked(&value); v != nil {
   loaded = true
   previous = *v
  }
 } else if e, ok := m.dirty[key]; ok { //如果在dirty数组发现则上swap锁进行修改
  if v := e.swapLocked(&value); v != nil {
   loaded = true
   previous = *v
  }
 } else {//上述情况都不符合则将amended 标记为true后进行追加
  if !read.amended {
   
   m.dirtyLocked()
   m.read.Store(&readOnly{m: read.m, amended: true})
  }
  m.dirty[key] = newEntry(value)
 }
 //解锁返回
 m.mu.Unlock()
 return previous, loaded
}

sync.Map读取

对应的读取源码即加载read原子变量后尝试到read指针下读取,若读取不到则增加未命中数到dirty指针下读取:

func (m *Map) Load(key any) (value any, ok bool) {
 //加载读原子变量
 read := m.loadReadOnly()
 //尝试在read指针下读取
 e, ok := read.m[key]
 //没读取到上mutex锁到dirty下读取,若发现则更新未命中数后返回结果
 if !ok && read.amended {
  m.mu.Lock()
  
  read = m.loadReadOnly()
  e, ok = read.m[key]
  if !ok && read.amended {
   e, ok = m.dirty[key]
   //更新未命中数
   m.missLocked()
  }
  m.mu.Unlock()
 }
 if !ok {
  return nil, false
 }
 return e.load()
}

sync.Map删除

删除步骤也和前面几种操作差不多,这里就不多赘述了,读者可参考笔者核心注释了解流程:

func (m *Map) LoadAndDelete(key any) (value any, loaded bool) {
 //上读锁定位元素
 read := m.loadReadOnly()
 
 e, ok := read.m[key]
 //为命中则上重量级锁到read和dirty下再次查找,找到了则删除,若是在dirty下找到还需要额外更新一下未命中数
 if !ok && read.amended {
  m.mu.Lock()
  read = m.loadReadOnly()
  e, ok = read.m[key]
  if !ok && read.amended {
   e, ok = m.dirty[key]
   delete(m.dirty, key)
   //自增一次未命中数
   m.missLocked()
  }
  m.mu.Unlock()
 }
 if ok {
  return e.delete()
 }
 return nil, false
}

// Delete deletes the value for a key.
func (m *Map) Delete(key any) {
 m.LoadAndDelete(key)
}

以上就是详解Go语言如何解决map并发安全问题的详细内容,更多关于Go解决map并发安全的资料请关注脚本之家其它相关文章!

相关文章

  • Go字符串切片操作str1[:index]的使用

    Go字符串切片操作str1[:index]的使用

    Go字符串切片str1[:index]从起始位置0到index-1截取,不复制数据,利用字符串不可变性和共享内存机制提升性能,具有一定的参考价值,感兴趣的可以了解一下
    2025-06-06
  • go语言实现处理表单输入

    go语言实现处理表单输入

    本文给大家分享的是一个使用go语言实现处理表单输入的实例代码,非常的简单,仅仅是实现了用户名密码的验证,有需要的小伙伴可以自由扩展下。
    2015-03-03
  • go语言实现全排列的示例代码

    go语言实现全排列的示例代码

    本文主要介绍了go语言实现全排列的示例代码,文中通过示例代码介绍的非常详细,对大家的学习或者工作具有一定的参考学习价值,需要的朋友们下面随着小编来一起学习学习吧
    2023-03-03
  • 详解Go语言单元测试中如何解决MySQL存储依赖问题

    详解Go语言单元测试中如何解决MySQL存储依赖问题

    MySQL 存储就是一个非常常见的外部依赖,这篇文章主要来和大家一起探讨在 Go 语言中编写单元测试时,如何解决 MySQL 存储依赖,需要的可以参考一下
    2023-07-07
  • Go打包静态文件的两种方式

    Go打包静态文件的两种方式

    使用 Go 开发应用的时候,有时会遇到需要读取静态资源的情况,如果不打包处理这种静态文件:发布单独挂载这种静态文件相对比较麻烦,就有人会想办法把静态资源文件打包进 Go 的程序文件中,下面介绍两种打包方式:go-bindata、go:embed,需要的朋友可以参考下
    2024-04-04
  • Go语言Select chan用法小结

    Go语言Select chan用法小结

    select语句是Go语言中用于处理多个通道操作的关键字,它允许你在多个通道上进行非阻塞的选择操作,本文就详细介绍一下如何使用,感兴趣的可以了解一下
    2023-09-09
  • go内置函数copy()的具体使用

    go内置函数copy()的具体使用

    当我们在Go语言中需要将一个切片的内容复制到另一个切片时,可以使用内置的copy()函数,本文就介绍了go内置函数copy()的具体使用,感兴趣的可以了解一下
    2023-08-08
  • go+react实现远程vCenter虚拟机管理终端方式

    go+react实现远程vCenter虚拟机管理终端方式

    基于Go和React实现远程vSphere vcenter虚拟机终端console页面,提供与vcenter管理中的LaunchWebConsole相同的功能,项目包括前端、后端配置,以及vCenter宿主机的Nginx代理设置
    2026-04-04
  • Go 语言入门之Go 计时器介绍

    Go 语言入门之Go 计时器介绍

    这篇文章主要介绍了Go 语言入门之Go 计时器,文章基于GO语言的相关资料展开对其中计时器的详细内容。具有一定的参考价值,需要的小伙伴可以参考一下
    2022-05-05
  • go性能分析工具pprof的用途及使用详解

    go性能分析工具pprof的用途及使用详解

    刚开始接触go就遇到了一个内存问题,在进行内存分析的时候发现了一下比较好的工具,在此留下记录,下面这篇文章主要给大家介绍了关于go性能分析工具pprof的用途及使用的相关资料,需要的朋友可以参考下
    2023-01-01

最新评论