1. golang map

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

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

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

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

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

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

2. sync.map

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

2.1 sync.map的定义

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

其中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