好好学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好。
相关推荐
贵州晓智信息科技21 分钟前
如何优化求职简历从模板选择到面试准备
面试·职场和发展
古木20199 小时前
前端面试宝典
前端·面试·职场和发展
桃园码工16 小时前
1-Gin介绍与环境搭建 --[Gin 框架入门精讲与实战案例]
go·gin·环境搭建
码农爱java16 小时前
设计模式--抽象工厂模式【创建型模式】
java·设计模式·面试·抽象工厂模式·原理·23种设计模式·java 设计模式
云中谷16 小时前
Golang 神器!go-decorator 一行注释搞定装饰器,v0.22版本发布
go·敏捷开发
Jiude16 小时前
算法题题解记录——双变量问题的 “枚举右,维护左”
python·算法·面试
长安05111 天前
面试经典题目:LeetCode134_加油站
c++·算法·面试
正在绘制中1 天前
Java重要面试名词整理(一):MySQL&JVM&Tomcat
java·开发语言·面试
沉默王二1 天前
虾皮开的很高,还有签字费。
后端·面试·github
Do1 天前
时间系列三:实现毫秒级倒计时
前端·javascript·面试