sync.Map是什么
Go语言map不支持并发读写操作(线程不安全),否则会出现致命的错误导致程序直接终止(fatal error无法被defer+recover捕获)
但Go官方也为我们提供了一个并发安全的sync.Map,Go语言的并发安全map本质是采用空间换时间的思想
这里我们可能会想到,我们可以对map加一个锁来让其的每一次操作都受到保护,这样就实现了并发安全,但性能很低
sync.Map内部使用两个map之间的相互配合(一个read map 和 dirty map),来为我们提供一个拥有并发读写能力的hash表结构
在并发处理上,相比于我们前面提到的普通map的无脑加锁操作,sync.Map将读和写分开
读操作优先从read中读取,read操作是不会加锁的,当read读取不到才会去dirty读
写操作只会在dirty写,只有对dirty操作的时候才需要加锁,这样区分加锁时机,提升并发性能
sync.Map的数据结构
sync.Map的结构定义,sync.Map定义在源文件src/sync/map.go里面
type Map struct {
mu Mutex // 用于保护dirty字段的锁
read atomic.Value // 只读字段,其实际的数据类型是一个readOnly结构
dirty map[interface{}]*entry //需要加锁才能访问的map,其中包含在read中除了被expunged(删除)以外的所有元素和新加入的元素
misses int // 计数器,记录在read中读取数据的时候,没有命中的次数,当misses值等于dirty长度时,dirty提升为read
}
misses的作用我们后续会提到,现在只需要知道有这个变量就行
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
m map[interface{}]*entry // key为任意可比较类型, value位为entry指针的一个map
amended bool // amended为true,表明dirty中包含read中没有的数据,为false表明dirty中的数据在read中存在
}
我们看一下entry的结构,entry作为dirty这个map的value类型以及readOnly结构体中map对应的value类型
type entry struct {
p unsafe.Pointer // p指向真正的value所在的地址
}
sync.Map的设计其实很巧妙,在dirty和readOnly里面的两个map的value并不是一个对象,而是一个指向任意类型的对象指针
所以,在这两个map都非空的情况下,map的read字段和dirty字段都会包含相同的数据项,如果通过read字段更改了这个项的值
dirty字段会读取到这个项的新值,因为它们都指向的同一个地址,sync.Map的底层结构如下图:
sync.Map流程及双向数据流转机制
对于读取,删除,更新这三种操作,会先找到read,如果read无法解决,再找dirty,写入操作直接找dirty,可以理解为另一种读写分离
接下来聊一下双向数据流转机制,什么是数据流转机制?为什么需要这个机制?什么时候会触发呢?
简单讲,双向数据流转机制就是:
- dirty把全量数据交给read
- read把全量数据交给dirty
dirty -> read
我们知道read可以为dirty尽量挡住读请求的流量,但如果read很多次没有挡住请求,让读请求访问到了dirty意味着什么呢?
代表read的责任削弱了,把压力给到了dirty,一旦dirty感受到压力超过了一个阈值(上文提到的misses),就会做出一个决定
这个决定就是将dirty的数据全部交给read,这样dirty的压力就减少了(read map获得dirty map全量数据)
read -> dirty
在上面的流程我们知道dirty->read,并且将dirty重置为nil,在这之后,如果有新的写请求进来,我们需要初始化dirty
这个初始化的过程也就是将read map里面的逻辑上存在的数据拷贝到dirty map(以O(N)的时间复杂度将数据拷贝一份给dirty)
什么叫逻辑上存在,能从sync.Map中读出的数据
什么叫逻辑上不存在,可能在sync.Map中物理内存上有这份数据,但无法读取到(后文讲entry的状态再分析)
sync.Map双向数据流转思考
这样的数据流转操作是否是多余的呢?dirty把数据给read,自己重置为nil,然后read又把数据给dirty,这算是踢皮球吗?
-
dirty把数据全给read,为什么自己要重置为nil呢?
如果dirty不重置为nil,就会出现read和dirty使用同一个底层map,无法做到数据隔离的目的
-
为什么dirty不能拷贝一份全量的数据给read?而是要直接改变指针指向?
不能,因为如果是写数据全部给read,那么对于访问read的操作就全都要加锁(读、删、写要互斥)
那么在dirty -> read的这个过程中,整个sync.Map将处于报废的状态
entry的状态
在上面,我们聊了sync.Map的结构,这里还有一个非常重要的结构需要单独拿出来讲讲--entry
我们都知道map是k-v(key,value)键值对的哈希表结构,而在sync.Map中,read map和dirty map各自的map结构对应的是entry
type entry struct {
p unsafe.Pointer // p指向真正的value所在的地址
}
对于entrty,这里p有三种取值:
- p为正常值:entry表示正常的value
- p为nil,我称之为软删除态,代表数据逻辑上不存在了(物理上仍存在,read和dirty都有这份数据)
- p为expunged,我称之为硬删除态(物理上dirty没有这份数据)
nil状态
这里我们可能会想,entry不就是key对应value吗?不就指向真正的物理内存就可以了吗,为什么还需要nil和expunged?
我们来分析为什么需要这两种状态,首先我们知道了read map是一个可以通过无锁操作来帮助我们消耗请求sync.Map整体的流量
这样就可以不需要加锁,换句话说我们思考为什么写操作要加锁访问dirty,而更新和删除操作不需要进行加锁访问dirty呢?
前面我们提到dirty保存了全量的数据,那么写操作肯定是要到dirty的,dirty要有全量数据,并且保证线程安全要加锁
-
那为什么更新操作可以不走dirty呢?我们不是要保证dirty要有最全且最新的数据吗?
因为底层read和dirty两个map的指向都是同一个entry,所以更新read,就自然的更新了dirty
-
那为什么删除操作可以不走dirty呢?如果说没有nil和expunged状态,会有什么问题?
会发现我们的dirty中仍然有着key2,并没有删除干净
那么既然删除操作走read删除不干净,那就应该走read的同时也走dirty不是吗?
那要删除dirty的key-value,那是不是意味着要给dirty上锁?这也就是为什么entry会有nil和expunged的状态
归根结底就是:通过read map来消耗删除流量,尽可能的减少加锁的操作,实现无锁删除
当这个entry对应的p变成nil后,我们再来看读写的情况
读:p变成了nil,最后返回的value是nil (着个key-entry对被隐藏了,访问不到了)
写:因为key-entry对,仍然存在于read map和dirty中,可以通过CAS,将nil值改为value值,完成更新操作,而不是完成写操作,无需加锁访问dirty,这句话是最关键的
其实nil状态的设计就是为了做到:"对同一个key先删后写的场景的优化"
expunged状态
那么expunged状态有什么作用呢?
我们回顾 read -> dirty的流程,有提到read只会将逻辑上存在的数据Copy给dirty,那么这里有个问题:
read中为nil的数据没有拷贝给dirty(因为逻辑上不存在),那么下一次进行写的时候,我们如何判断是直接更新写到read还是dirty呢?
- 如果只有nil状态,那么此时进行写操作,就会导致dirty丢失了这个数据key3-v3
- 所以我们需要一个额外的状态来标识 --- "dirty和read是否正在共享 这个逻辑删除的key-entry"
entry状态总结
不管是nil状态还是expunged状态,都代表逻辑上,key-entry从sync.Map中已经删除了
-
nil状态:我们将其称之为软删除,但是read map和dirty map中物理上仍然有这个key-entry对
同时我们可以基于CAS让read消耗 对p为nil的key-entry的写流量(优化对同一个key先删后写的场景)
-
expunged状态:我们将其称之为硬删除,物理上dirty map中已经没有key-entry对了,它能帮助我们标识出dirty map
当前有没有这个逻辑删除的key-entry(如果标识dirty map中没有,我们就需要访问dirty map)
思考
-
sync.Map有啥不足的吗?
sync.Map不适用于写多的场景,因为写操作足够多的话,sync.Map相当于Mutex + map
而且sync.Map中存在一个将read map数据流转到dirty map的过程,这个过程是线性时间复杂度(On)的
当map中key-value数据较多的时候,容易导致程序性能抖动,比如想要访问sync.Map拿锁操作goroutine要一直等待O(n)
-
sync.Map的底层原理是什么?
空间换时间、数据的状态流转、entry状态的设计