好好学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好。
相关推荐
鱼跃鹰飞8 小时前
Leetcode面试经典150题-349.两个数组的交集
算法·leetcode·面试
程序猿进阶15 小时前
如何在 Visual Studio Code 中反编译具有正确行号的 Java 类?
java·ide·vscode·算法·面试·职场和发展·架构
无名之逆15 小时前
云原生(Cloud Native)
开发语言·c++·算法·云原生·面试·职场和发展·大学期末
andrew_121919 小时前
腾讯 IEG 游戏前沿技术 一面复盘
java·redis·sql·面试
andrew_121919 小时前
腾讯 IEG 游戏前沿技术 二面复盘
后端·sql·面试
寻求出路的程序媛19 小时前
JVM —— 类加载器的分类,双亲委派机制
java·jvm·面试
kay_54520 小时前
YOLOv8改进 | 模块缝合 | C2f 融合SCConv提升检测性能【CVPR2023】
人工智能·python·深度学习·yolo·目标检测·面试·yolov8改进
gopher95111 天前
qt相关面试题
开发语言·qt·面试
视觉小鸟1 天前
【java面试每日五题之基础篇一】(仅个人理解)
java·笔记·面试
vd_vd1 天前
内存区域-面试与分析
jvm·面试·职场和发展