sync.map原理¶
sync.map是什么¶
sync.map是go语言在sync包下提供的一个可以提供并发访问的map。我们知道go语言的map是非线程安全的,对map的操作不是原子操作,所以在对原生的map进行并发读写的时候,很容易造成panic。
这里我们可能会想到,我们可以对map加一个锁来让其的每一次操作都受到保护,这样就实现了并发安全。但是这样对一整个map都加锁,无疑在性能上会大打折扣。所以,go语言在其1.9的版本中提供了一个并发安全的字段类型sync.Map
。
从功能上看,sync.map是一个读写分离的map,采用了空间换时间的策略来提高数据的读写性能,其内部其实用了两个map来实现,一个read map
和一个dirty map
。在并发处理上,相比于我们前面提到的普通map的无脑加锁操作,sync.map将读和写分开,读数据优先从read
中读取,对read
的操作是不会加锁的,当read
读取不到才会去dirty
读,而写数据只会在dirty
写,只有对dirty
操作时需要加锁的,这样区分加锁时机,就提升了并发性能。
sync.map的数据结构¶
先来看一下sync.Map
的结构定义,sync.Map
定义在源文件src/sync/map.go
里面,这里去掉了注释:
Go | |
---|---|
这里主要看一下read
这个字段,read
字段的类型是atomic.Value
,但是在使用中里面其实存储的是readOnly
结构,readOnly
结构定义如下:
Go | |
---|---|
下面看一下entry这个结构
这个p有三种取值:
-
p为
nil
:标记删除状态,代表这个key被删除了,此时dirty要么为nil
,要么也存在这个key -
p为
expunged
:标记删除状态,此时dirty非空,p存在于read,但不存在于dirty -
p为
entry
:表示正常的value
entry
这个字段在sync.map
里被频繁的用到,他作为dirty
这个map
的value
类型以及readOnly
结构体中m
这个字段对应的map
的value
类型。
这里其实很巧妙,在dirty
和readOnly
里面的两个map
的value
并不是一个对象,而是一个指向任意类型的对象的指针,所以,在这两个map
都非空的情况下,map
的read
字段和dirty
字段会包含相同的数据项,如果通过read
字段更改了这个项的值,dirty
字段会读取到这个项的新值,因为它们指向的是同一个地址。
sync.Map
的底层结构如下图:
在map
的结构定义中介绍dirty
字段的时候说到,dirty
这个map
包含在read
中除了被expunged
(删除)以外的所有元素,下面重点说一下expunged这个字段:
expunged这个字段的作用是用来标识map
中的某个key
是否被删除,注意,这里只是标记删除,并没有真正的删除,所以expunged是map
中用来对某个key
做假删除动作的,当从sync.Map
删除某个key
的时候,将这个key
对应的value
标记为nil
或者expunged,后面再对这个key
进行删除。
sync.map方法¶
sync.map跟map一样,提供了数据的增删改查功能,这里我们对照map从源代码来分析一下sync.map各个功能的具体实现:
-
Store()
:更新/插入一个键值对 -
Load()
:返回一个key对应的value -
Delete()
:删除一个键值对 -
Range()
:对sync.Map进行遍历
Store¶
sync.map.Store()
方法既可以用来新增键值对,也可用用来更新键值对。
更新键值对,key
存在于read
中,那么这时key
对应的p有三种情况:
-
p == expunged
,当前key
存在于read
,但是key
不存在于dirty
,dirty
也不为空,read
包含dirty
中不存在的key
,dirty
也包含read
中不存在的key
,这种情况下p
的值为expunged
,形式如下图。在这种情况下,不能单单只操作read
,还要加锁同步更新dirty
,将这个key
加入到dirty
中,将e.p
的保存新传入的value
; -
p == nil
,key
存在于read
,此时被标记删除,但还没有完成dirty
的重塑; -
p == &value
,p
指向的是一个正常值,没有被标记删除。
新增键值对,只操作dirty
:
-
dirty == nil
,由于read
的misses
次数达到了dirty
的长度,dirty
刚刚提升为read
,还没有被插入过新的key
,此时为nil
,此时要插入新的key
,则要根据read
重建dirty
,在重建的时候,原来被标记删除未nil
的键值对过滤掉,不复制到dirty
,剩余的键值对都copy
到dirty
。并且还要改一下read
里被标记删除的key
的状态,由原来的nil
改为expunged
; -
dirty != nil
,直接在dirty
中插入新的键值对。
其源码如下:
tryStore()
方法
unexpungeLocked()
方法
Go | |
---|---|
storeLocked()
方法
Go | |
---|---|
dirtyLocked()
方法
tryExpungeLocked()
方法
store
的流程如下图:
Load¶
Load()
方法很简单,就是返回一个key
对应的value
,value
不存在就返回nil
。读取的时候,先从read
中读取,读到了key
,就直接返回结果,没有读取到,就加锁从dirty
中读取,所以读取不在read
中的key
会因为加锁而导致性能下降。
在读取的过程中,可能发生read
被重构的过程,即将dirty
提升为read
的过程,比如当读取某个key
的时候,这个key
存在与dirty
中,但不存在与read
中,所以,misses
会加1
,当misses
刚好达到dirty
的长度时,就会重塑read
,拷贝dirty
的数据到read
中,将dirty
提升为read
,并将dirty
置为nil
。
load()
方法源码如下:
missLocked()
方法:
Go | |
---|---|
load
流程如图:
Delete¶
Delete
方法是从sync.Map
中删除一个元素,delete
方法也很简单,还是优先检查read
,若key
在read
中存在,则只会操作read
。若在read
中不存在,回去dirty
中删除这个键值对delete(m.dirty, key)
。所以分两种情况讨论:
-
key
存在于read
:-
key
只存在read
,不存在于dirty
。直接将key
对应的e
的e.p
设置为nil
,这种情况dirty
中根本就没有这个key
,所以不用管; -
key
既存在于read
,也存在于dirty
。也是直接将key
对应的e
的e.p
设置为nil
,这种情况下read
中m
的e
和dirty
中的e
指向同一个,将read
中e
的e.p
设置为nil
,其实dirty
中的p
也指向了nil
。
但是这两种方式删除键值对的时候其实都没有像从
dirty
中删除那样调用delete
函数从map
中删除这个key/value
,所以这里并没有真的删除,只是标记删除了,真正删除要等到read
中的misses
大于等于dirty
的时候,dirty
提升为read
的时候,这些key
才回被垃圾回收掉。 -
-
key
不存在于read
:这种情况很简单,直接去dirty
中这个map
删除这个键值对就行了,这里是直接删除。
所以:从read
中删除是延迟删除,从dirty
中删除是直接删除。
其源代码如下:
delete
流程如图:
e.delete()
方法:
Range¶
Range
方法是对sync.Map
进行遍历,其参数是一个func(key, value interface{}) bool
类型的函数f
,f
的作用是对sync.Map
中遍历到的每一个key/value
键值对进行处理,当f
返回false
的时候,遍历停止。
在sync.Map
中当dirty
不为nil
时,dirty
会包含map
中所有非删除的key
。在遍历的时候会先看read
的amended
字段,当amended
为true
时,表示dirty
中有read
中没有的字段,将dirty
提升为read
,在遍历read
即可,这样就避免了访问dirty
会加锁导致性能低下,如果amended
为false
时,表示read
和dirty
中的key
一致,这时直接遍历read
即可。
其源代码如下:
range
流程如下图:
p的状态变化¶
在sync.Map
中,不论是read
中还是dirty
中,其底层的存储都是一个map
,回顾一下这个map
的结构:
再回顾一下这个entry
的结构:
通过前面的方法分析,我们知道了这个p
可能存在三种状态,nil
,expunged
,或者是指向一个正常value
。那么他在这三种状态下是怎么切换的呢?还有为什么要有expunged
这个字段呢?下面就通过简单的map
图解操作来看一下p
的状态是怎么变化的:
在一个空的map
中加入两个元素假设为key1/value1
和key2/value2
,由于新加入元素,只会去dirty
里面加入,所以加入完了以后,read
还是空,dirty
含有两个key
,key1
和key2
。
此时,在读取连续读取两次,读取调用load
方法,因为read
中没有,所以回去dirty
中读取,read
未命中次数misses
变为2
,等于了dirty
的长度,这个时候要将dirty
提升为read
,read
中就包含了key1
和key2
,dirty
置为nil
,misses
清0
。
然后执行一次删除操作,删除key1
,因为key1
在read
中存在,所以直接操作read
即可,把key1
标记删除,所以key1
的p
对应的状态就变为了nil
,此时read.amended
为false
,并且此时dirty
为空,并不包含任何key
,所以不需要操作。
此时再插入一个新的键值对key3
,由于是插入操作,要在dirty
中插入,此时发现dirty
为nil
,所以要重塑dirty
,重塑过程是这样,首先创建一个新的空dirty map
,然后将read
中标记删除为nil
的key
对应的p
标记为expunged
,最后将不是expunged
状态的键值对都copy
到dirty
,然后将read.amended
置为true
,此时可以看到expunged
状态出现了。
修改key1
的值为value0
,发现key1
存在于read
中,此时key1
对应的p的状态是expunged
,表明key1
不存在于dirty
,所以不能单单指操作read
,还要加锁操作dirty
,首先将key1
对应p
的状态由expunged
改为nil
,然后将key1
加入到dirty
中,将p
的值修改为新的value
,即e.p=&value0
,此时read
中只包含key1
和key2
,而dirty
中包含map
的全量key
:key1
,key2
和key3
。
通过上面的流程分析,走了一遍map
的增删改查,分析了read
到dirty
中key
集合的变化过程,以及key1
对应的p
的状态变化,可以看到key1
的nil
和expunged
都表示标记删除,二者只有一个区别,就是当p
为nil
时,此时dirty
对应的状态是nil
或者dirty
不为空且包含这个key
,而当p
的状态时expunged
时,dirty
不为nil
,且dirty
中包含read
中没有的key
。
这里就可以知道,当p
的状态为expunged
时,对key1
的操作不能只操作read
,还要加锁操作dirty
,而p
的状态为nil
时,只用操作read
即可,不用加锁,性能更高。
所以可以看出,虽然二者都表示标记删除,但分为两个状态之后,可以更细粒度的区分操作复杂度,在p
的状态为nil
时不加锁,尽量保证在能不加锁的时候就不加锁,提升程序性能。从这里分析也可以得知,没有expunged
这个状态行不行呢,其实也可以,不过那样就不能根据区分度来判断是不是不用加锁直接操作read
就可以了,还要加锁去dirty
中检查一次,这样就降低了程序的性能。
sync.Map总结¶
-
sync.Map
是一个线程安全的map
,可以多线程并发安全执行; -
sync.Map
的核心思想是采用空间换时间,内置了两个map
来存储数据,read
和dirty
,其中read
支持原子操作,read
的操作不加锁,dirty
操作需要加锁; -
sync.Map
将增删改查四个操作都做了细分,只有新增操作直接加锁操作dirty
,其余的改,查,还有删除都是优先不加锁操作read
,在发现read
中没有对应key
或者需要同步数据到dirty
的时候才会加锁操作dirty
,这样尽可能减少加锁次数,提升程序性能; -
在删除一个
key
的时候,如果key
存在于read
中则是延迟删除,key
存在于dirty
,不存在于read
会立即删除; -
dirty
和read
都会依靠另一个进行重建,在dirty
不为空的时候,dirty
包含map
中的所有有效key
,在dirty
为空的时候,read
包含map
中的所有有效key
; -
read
中的key
在dirty
中可能存在,也可能不存在;dirty
中的key
在read
中也可能存在,可能不存在; -
sync.Map
中的entry
里的p
指针有三种状态,nil
,正常值value
还有expunged
。