浅谈两种golang中的map

1. golang map

golang原生map在并发场景下,同时读写是线程不安全的,如论key是否一样,我们可以编写一个测试用例来看看同时读写不同的key会发生什么情况:

func testForMap() {
    m := make(map[int]int)
    go func() {
        for {
            m[1] = 1
        }
    }()

    go func() {
        for {
            _ = m[2]
        }

    }()
    select {}
}

func main() {
    testForMap()
}

 

当在终端执行 go run main.go时,会发现系统报错

fatal error: concurrent map read and map write

 

错误很明显,我们在不同的协程中并发的读写了同一个map,虽然是不同的key,还是会发发生并发错误,那么如果想用原生map实现并发操作就必须使用互斥锁或者读写锁来实现。

我们可以定义一个线程安全的map结构体,其中包含了读写锁和一个map:

type SafeMap struct {
    sync.RWMutex
    m map[int]int
}

 

然后就可以并发读写这个线程安全的map了:

func main() {
    safeMap := SafeMap{
        m: make(map[int]int),
    }

    // 读数据
    safeMap.RLock()
    data := safeMap.m[1]
    safeMap.Unlock()
    fmt.Println(data)

    // 写数据
    safeMap.Lock()
    safeMap.m[2] = 1
    safeMap.Unlock()
}

 

使用读写锁实现的线程安全map已经是一种效率较高的map了,我们都知道在并发编程中读写共享资源加锁是必须的,即使我们使用了封装的线程安全的数据结构,其底层也是使用了锁机制,只是在一定程度上对加锁时机和粒度做了一些优化。

 

2. sync.map

sync.map是用读写分离实现的,其思想是空间换时间。和map+lock的实现方式相比,它本身做了一些优化:可以无锁访问read map,而且会优先操作read map,如果只操作read map就可以满足要求,那就不回去操作write map(读写加锁),所以在一些使用场景中它发生锁竞争的频率会远远小于map+lock的实现方式。

 

2.1 sync.map的定义

type Map struct{
  // 互斥锁mu,主要是为dirty服务
  mu Mutex
  // read是只读数据,可以无锁访问
  read atomic.Value
  // 加锁读写,主要处理插入key
  dirty map[interface{}]*entry
  // 统计访问read未命中然后访问dirty的次数
  // 用于将dirty提升为read
  misses int
}

 

结构体readOnly,顾名思义这就是一个只读结构,其实就是上面map定义中的read

type readOnly struct{
  m map[interface{}]*entry
  amended bool
}

其中m就是一个只读的map,其值entry指针指向真实的数据地址,amended=true表示dirty中有read中不存在的数据

 

2.2 sync.map Load

基本使用我就不放了,就是取值操作,取出key对应的value

我们看一下Load方法的流程图

 

尝试简单分析一下Load数据的流程:

  1. 首先访问read map,如果read map命中直接返回value
  2. 查看amended状态,如果其为false,说明write map中也没有这个key,返回空就好了
  3. 如果amended=true,需要加锁在访问一次read map,是一种双重检查机制
  4. 如果read中有了这个key,可能是另一个并发的协程在我们第一次无锁查询时已经load了这个key,那么直接返回value
  5. 如果read中还是没有,那么去读write,并且把miss+1,然后解锁并返回结果
  6. 注意这个miss计数器,当miss计数器的计数长度达到write的大小时,需要将write的kv拷贝给read,然后将write清空

 

2.3 sync.map Store

Store就是往map中添加新的值或者更新value

 

  1. Store会优先访问read,未命中加锁访问write
  2. Store进行双重检查,同样是因为我们在第一次访问的同时key已经被放入到了read中
  3. dirtyLocked在write为nil会从read中拷贝数据,如果read中数据量很大,可能会出现性能抖动
  4. sync.map不适合频繁插入新的key-value的场景,因为这种操作会频繁加锁访问

 

2.4 sync.map Delete

 

其实可以吧delete视为load的反向操作

  1. 删除read中存在的key,可以不用加锁
  2. 如果要删除read中不存在的或者map中不存在的key,都需要加锁

 

2.5 sync.map Range

Range可以遍历map

  1. Range时,当全部的key都存在于read中是无锁遍历的,效率最高
  2. Range时,如果有部分key存在于write,会加锁一次性拷贝所有的kv到read中

 

3. 总结

sync.map更适合多读的情况,因为多写场景下会频繁加锁而且会发生值拷贝

如果想用多读的场景,可以考虑开源库orcaman/concurrent-map,或者如果对性能要求不是很高也可以选择map+lock的实现方式

 

参考:

https://cloud.tencent.com/developer/article/1915119

https://stackoverflow.com/questions/45585589/golang-fatal-error-concurrent-map-read-and-map-write/45585833

https://github.com/golang/go/issues/20680

https://github.com/golang/go/blob/master/src/sync/map.go

posted @ 2022-03-20 13:40  aganippe  阅读(538)  评论(0编辑  收藏  举报