好好学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好。
相关推荐
鱼跃鹰飞4 小时前
大厂面试真题-简单说说线程池接到新任务之后的操作流程
java·jvm·面试
程序员清风7 小时前
浅析Web实时通信技术!
java·后端·面试
测试19987 小时前
外包干了2年,快要废了。。。
自动化测试·软件测试·python·面试·职场和发展·单元测试·压力测试
mingzhi618 小时前
渗透测试-快速获取目标中存在的漏洞(小白版)
安全·web安全·面试·职场和发展
嚣张农民9 小时前
一文简单看懂Promise实现原理
前端·javascript·面试
于顾而言9 小时前
【笔记】Go Coding In Go Way
后端·go
qq_172805599 小时前
GIN 反向代理功能
后端·golang·go
Liknana10 小时前
Android 网易游戏面经
android·面试
威哥爱编程14 小时前
MongoDB面试专题33道解析
数据库·mongodb·面试
程序猿进阶15 小时前
Redis 基础数据改造
java·开发语言·数据库·redis·后端·面试·架构