好好学Go(四):面试官问我sync.Map的底层实现

前言

上一篇博文我们讲解了Go语言中map的底层实现,然而就像Java中的HashMap是并发不安全的,Go语言的map也是不支持并发场景的。

如果要在并发场景下使用map,就需要用到我们即将介绍的sync.Map。

map的并发问题

首先,我们先来理解一下为什么Go语言的map会存在并发问题。

让我们从一个程序开始,说明这个问题。在下面这个程序中,我们开启了两个协程,其中1个负责读map,另一个负责修改map的值,而且他们修改的key不是同一个。

所以按理说,这个程序应该可以正常运行,我们可以尝试跑一下。

go 复制代码
func main() {
    hash := make(map[int]int, 10)
    go func() {
       for {
          _ = hash[1]
       }
    }()

    go func() {
       for {
          hash[2] = 2
       }
    }()

    select {}
}

程序运行结束后,我们得到了下面的报错信息:

arduino 复制代码
fatal error: concurrent map read and map write

可见,Go语言是不允许并发对map进行读和写的。从上一篇博文中我们知道,map是有扩容机制的。我们假设这样一种情况:A协程在桶中读数据时,读的是一个未被驱逐的老桶;恰好此时,B协程要修改这个桶的数据,按照扩容的原理,B协程就会顺便驱逐了这个老桶的数据。

这就可能导致A协程读到错误的老桶的数据或者找不到数据

那怎么解决呢?一般来说,Map并发问题的解决方案有两种,第一种,直接对map加锁。这种方法效率比较低;第二种就是使用下面要介绍的sync.map

sync.Map的底层实现

打开sync/map.go,可以看到sync.Map的实现代码如下:

go 复制代码
type Map struct {
    mu Mutex
    read atomic.Pointer[readOnly]
    dirty map[any]*entry
    misses int
}
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
    m       map[any]*entry
    amended bool // true if the dirty map contains some key not in m.
}
type entry struct {
    p atomic.Pointer[any]
}

我们可以画图来表示整个sync.Map的结构:

sync.Map的读写流程和追加流程,就是紧紧围绕着这个底层结构来的。

sync.Map的读写流程与追加流程

正常读写流程 (以m["a"] = "AAA"为例子):

  • 走read结构体,访问read里面的map,按照map的寻找算法找到key
  • 找到entry指针指向的万能指针Pointer,修改Pointer指向的值。

追加流程 (以m["d"]="D"为例子):

  • 首先,访问read里面的map,发现read里面的map中没有这个key。
  • 此时将mu上锁,锁住dirty map
  • 进入dirty map,追加新的key和value(其中value是一个万能指针Pointer)
  • 将read的amended设置为true,表示read数据不完整。

即正常读写走read,追加走dirty并加锁。

追加后读写

  • 访问read里面的map,发现map中没有这个key并发现amended=true
  • 访问dirty里面的map,读取对应的值。
  • 将misses的值增加1,表示上面的值未命中一个。
  • 随着线程的推进,misses会一直增加,如果misses=len(dirty),需要做dirty提升
  • dirty里面的map提升,变成新的read里面的map。同时dirty变成nil。misses=0。
  • 要追加的时候会重建dirty,继续执行上述步骤

sync.Map的删除流程

sync.Map的删除相比于查询、修改和新增,是最麻烦的。它可以分为正常删除和追加后删除。同时,删除后的key还需要经过特殊处理。

正常删除

当read中的map和dirty中的map是一样的时候,删除为正常删除。

  • 找到read中的map,找到对应key
  • 将key对应的value,即万能指针Pointer设置为nil
  • GC会自动回收这个value

追加后删除

dirty还未提升到read,dirty刚刚追加了新的key,此时需要删除。

  • 先从read中的map寻找,发现没有。
  • mu加锁。
  • 从dirty中的map寻找,找到对应的key的value,即万能指针Pointer设置为nil。
  • dirty提升后,原先被删除的键不会被重建,其value被指向为expunged(表示这个键被删除,且在dirty中没有)。提醒协程如果要删除这个key,只需要直接删除即可,无须设置为nil。

总的来说,sync.Map做到了【读写】和【追加】分离。

小结

  1. map在扩容时候会有并发问题,主要表现在map扩容时会驱逐数据,造成协程之间读写不一致的问题。
  2. sync.Map底层使用了两个map,分离了扩容的问题。对于不会引发扩容的操作,例如查询和修改,使用read map进行。对于可能引发扩容的操作,比如新增,使用dirty map。
  3. 适用场景:sync.Map在写多读多但是追加少的情况下,性能比直接加锁的map好。
相关推荐
独行soc5 小时前
#渗透测试#批量漏洞挖掘#HSC Mailinspector 任意文件读取漏洞(CVE-2024-34470)
linux·科技·安全·网络安全·面试·渗透测试
小飞悟7 小时前
你以为 React 的事件很简单?错了,它暗藏玄机!
前端·javascript·面试
掘金安东尼8 小时前
技术解析:高级 Excel 财务报表解析器的架构与实现
前端·javascript·面试
天天扭码8 小时前
AI时代,前端如何处理大模型返回的多模态数据?
前端·人工智能·面试
阳火锅9 小时前
都2025年了,来看看前端如何给刘亦菲加个水印吧!
前端·vue.js·面试
Java技术小馆10 小时前
GitDiagram如何让你的GitHub项目可视化
java·后端·面试
UGOTNOSHOT10 小时前
7.4项目一问题准备
面试
YaHuiLiang12 小时前
小微互联网公司与互联网创业公司 -- 学历之殇
前端·后端·面试
风飘百里12 小时前
分组加密核心原理与实践解析(AES/SM4)
go
岁忧12 小时前
(LeetCode 每日一题) 1865. 找出和为指定值的下标对 (哈希表)
java·c++·算法·leetcode·go·散列表