文章目录
一、从并发不安全的map聊起
在golang的官方的文档中已经提到内建的map
不是线程(goroutine)安全的。
典型的场景:2个协程同时进行读和写
func main() {
m := make(map[int]int)
go func() { //开一个协程写map
for i := 0; i < 1000; i++ {
m[i] = i
}
}()
go func() { //开一个协程读map
for i := 0; i < 1000; i++ {
fmt.Println(m[i])
}
}()
for {
;
}
}
fatal error: concurrent map read and map write
哪怕是读写的键不相同,而且map也没有"扩容"等操作,代码还是会报错。
例如:一个协程只读一个键,另外一个协程只写另外一个键
func main() {
m := make(map[int]int)
go func() {
for {
_ = m[0]
}
}()
go func() {
for {
m[1] = 1
}
}()
//
time.sleep(time.Second*20)
}
二、go #1.9之前的解决方案
在golang 1.9之前的解决方案是额外绑定一个锁,封装成一个新的struct或者单独使用互斥锁都可以。
在Go官方blog的Go maps in action一文中,提供了嵌入map的读写锁的解决方案。
var counter = struct{
sync.RWMutex
m map[string]int
}{m: make(map[string]int)}
//读数据
counter.RLock()
n := counter.m["key"]
counter.RUnlock()
//写数据
counter.Lock()
counter.m["key"]=value
counter.Unlock()
三、sync.Map的引入 (golang1.9后的新特性)
go 1.9之后,新增了sync.Map,是并发安全的,效率也很高,适合读多写少的业务场景
阅读sync.Map的源码,发现 具有以下的优点,
- 通过冗余的手段,冗余两个数据结构(read、dirty)优化加锁对性能的影响
- 内部引入大量
double checking
(双重检测)的机制 - 动态调整,当read
miss
次数达到len(dirty)后,将dirty
提升为read
- 延迟删除,删除某个key时,先进行逻辑删除(标记),只有dirty提升为read时才真正进行物理删除
- 优化read的读取、更新,对read的读取不加锁
四、sync.Map的源码分析
4.1 sync.Map的数据结构
type Map struct {
//互斥锁,用于dirty数据操作
mu Mutex
//只读的数据结构,不会有读写冲突
// Entries stored in read may be updated concurrently without mu, but updating a previously-expunged entry requires that the entry be copied to the dirty
//当entries处于未删除状态(unexpunged),不需要加锁,如果entries已经删除(previously-expunged ),需要加锁,以便下次更新dirty
read atomic.Value // readOnly
//1、dirty数据包含了当前map最新的entries
//2、提升dirty为read时不需要一个个复制,而是利用原子操作将这个数据结构作为read的一部分
//3、对于dirty的操作需要加锁,对它的操作可能面临读写竞争
//4、dirty为空时,可能是刚初始化或者刚被提升,下一次操作将从read中处于未删除状态的数据复制到dirty中。
dirty map[interface{}]*entry
//当从read中读取数据时,如果read不包含相应的数据,将尝试加锁从dirty中读取,无论读取是否成功,miss均加一
//当len(dirty)==misses,将dirty提升变成read
misses int
}
总而言之,该数据结构以空间换时间,冗余了read、dirty
,dirty中包含read已经逻辑删除的entries,以及新加入的entries。
read对应的DataStruct为:
// readOnly is an immutable struct stored atomically in the Map.read field.
type readOnly struct {
m map[interface{}]*entry
//amended为true时,说明dirty中有readOnly未包含的数据,所以如果从read找不到数据的话,设置该值为true,要进一步到dirty中查找。
amended bool // true if the dirty map contains some key not in m.
}
这里有个问题可能你也想到了,如果数据量过于庞大,由于冗余将占用过多的空间。
实际上,尽管read、dirty之间互相冗余,但是两者数据之间通过指针指向同一块内存地址,减少了内存的浪费。
// An entry is a slot in the map corresponding to a particular key.
type entry struct {
p unsafe.Pointer // *interface{}
}
readOnly.m
和dirty
存储的value类型是*entry,它包含一个指针p, 指向用户存储的value值,节约了内存的消耗。
4.2 聊聊Double-Checking
前面说到,sync.Map内部引入大量double checking
(双重检测)的机制,这里简单介绍一下。关于double checking
,最典型的是java中的单例模式的双重检测实现,如下:
public class Instance{
private Instance(){}
private static Instance instance;
public static volatile Instance getInstance(){
if(instance!=null){
return instance;
synchronized(Instance.class){
if(instance == null){
instance=new Instance();
}
}
}
}
为什么需要双重检测,原因在于,如果只是简单地进行一次判断
if(instance==null){
synchronized{
这两个语句并不是原子的,同时从另外一个角度考量
外层的判断语句直接返回对象也可以避免在多线程情况下,当变量已经初始化时,直接返回对象,避免其他线程等待的问题。
if(instance!=null){
return instance;
4.3 sync.Map的load函数:加载数据
// Load returns the value stored in the map for a key, or nil if no
func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
//首先依赖atomic的原子操作Load获取一个只读的readOnly数据结构
read, _ := m.read.Load().(readOnly)
//查找readOnly中的数据,不需要加锁。
e, ok := read.m[key]
//如果没找到,且amended为true即dirty中有新数据,从dirty中查找,并且加锁。
if !ok && read.amended {
m.mu.Lock()
//再次获取readOnly,double-checking,避免加锁的时候m.dirty被提升为m.read,这个时候m.read已经不对应。
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
if !ok && read.amended {
e, ok = m.dirty[key]
//不管e是否存在,misses计数均加一,当满足条件后提升dirty
m.missLocked()
}
m.mu.Unlock()
}
if !ok {
return nil, false
}
return e.load()
}
需要注意的是:
e, ok := read.m[key]
if !ok && read.amended {
m.mu.Lock()
这几个语句也不是原子执行的,在高并发的情况下,存在一种场景:当!ok && read.amended
条件满足,但是在加锁之前,m.dirty可能被提升为m.read,此时read已经被改变,所以加锁后还要再检查m.read。
4.4 missLocked函数:dirty如何提升为read
func (m *Map) missLocked() {
m.misses++
if m.misses < len(m.dirty) {
return
}
//当满足一定条件时,调用store函数进行提升
m.read.Store(readOnly{m: m.dirty})
//m.dirty清空
m.dirty = nil
//重新计数
m.misses = 0
}
4.5 store函数:更新/增加一个entry
// Store sets the value for a key.
func (m *Map) Store(key, value interface{}) {
//获取readOnly
read, _ := m.read.Load().(readOnly)
//如果m.read存在这个键,并且这个entry没有被标记删除,直接存储。
if e, ok := read.m[key]; ok && e.tryStore(&value) {
return
}
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
if e, ok := read.m[key]; ok {
if e.unexpungeLocked() {
// The entry was previously expunged, which implies that there is a non-nil dirty map and this entry is not in it.
//标记成未被删除,往dirty中添加数据
m.dirty[key] = e
}
//更新
e.storeLocked(&value)
//dirty中存在这个k,更新数据
} else if e, ok := m.dirty[key]; ok {
e.storeLocked(&value)
} else {
//dirty中无新的数据,往dirty中加入一个新key
if !read.amended {
//从read中复制数据
m.dirtyLocked()
m.read.Store(readOnly{m: read.m, amended: true})
}
//将entry加入到dirty中
m.dirty[key] = newEntry(value)
}
//解锁
m.mu.Unlock()
}
4.6 Delete函数:删除一个entry
// Delete deletes the value for a key.
func (m *Map) Delete(key interface{}) {
read, _ := m.read.Load().(readOnly)
e, ok := read.m[key]
//从dirty中删除数据,直接删除
if !ok && read.amended {
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
e, ok = read.m[key]
//double-checking
if !ok && read.amended {
delete(m.dirty, key)
}
m.mu.Unlock()
}
//从read删除数据,打标记
if ok {
e.delete()
}
}
从read删除数据的情况
func (e *entry) delete() (hadValue bool) {
for {
p := atomic.LoadPointer(&e.p)
//检测是否已经标记过
if p == nil || p == expunged {
return false
}
//原子操作,e.p标记为nil
if atomic.CompareAndSwapPointer(&e.p, p, nil) {
return true
}
}
}
4.7 Range函数:遍历
使用sync.Map
无法利用内建特性for... range
进行遍历,只能通过函数进行遍历
func (m *Map) Range(f func(key, value interface{}) bool) {
read, _ := m.read.Load().(readOnly)
//如果m.dirty中有新数据,先升级dirty后再遍历
if read.amended {
// m.dirty contains keys not in read.m. Fortunately, Range is already O(N)
m.mu.Lock()
read, _ = m.read.Load().(readOnly)
//double-checking双重检测
if read.amended {
read = readOnly{m: m.dirty}
m.read.Store(read)
m.dirty = nil
m.misses = 0
}
m.mu.Unlock()
}
//遍历取数据
for k, e := range read.m {
v, ok := e.load()
if !ok {
continue
}
if !f(k, v) {
break
}
}
}
五、总结
1、 sync.Map的核心思想是用空间换时间,用两个map来存储数据(read和dirty),read支持原子操作,操作一般不加锁,可以看作是dirty 的cache;
2、 sync.Map的4种操作
**Load:**先到read中读取,如果有则直接返回结果,如果没有或者是被删除,则到dirty加锁中读取,如果有则返回结果,无论是否存在均更新miss
Store:
- [ 1] 更新:先到read中看看有没有,如果有直接更新key,如果没有则到dirty中更新
- [ 2]增加key:直接增加到dirty中
Delete:先到read中看看有没有,如果有则直接更新为nil,如果没有则到dirty中直接删除
Range::遍历sync.Map,可能涉及提升操作
3、map、sync.Map,map(RWMutex)
- go中的map不是并发安全的
- go1.9版本之前,可以使用map+mutex的方式实现并发安全,但是每次操作,无论读取都要加锁,性能不太好,可以将map和mutex(rwmutex)包装成一个结构体,优化性能。
- go 1.9之后,新增了sync.Map,是并发安全的,效率较高,适用于读多写少的场景,用两个map来存储数据(read和dirty),read支持原子操作,操作一般不加锁,可以看作是dirty 的cache,dirty加锁。
六、分段锁
- 熟悉java的程序员大概都知道juc中的
ConcurrentHashMap
, 内部使用多个锁,每个区间共享一把锁,这样减少了全局共享一把锁带来的性能影响。 - 实际上,
sync.Map
并不优雅,尽管用两个数据结构实现了读写分离,但是全局只有一把锁,在高并发的写场景下多个线程(goroutine)争抢一把锁,无异于直接粗暴地给map直接加mutex, - 故类似java的
ConcurrentHashMap
的实现(shard的思想),可以对key进行分段,一个段内使用一个锁,这样操作不同的key时,避免锁的阻塞开 销,大大提高效率,orcaman
提供了这个思路的一个实现:concurrent map
,感兴趣的同学可以移步github
但是对于go官方,目前还没有支持这样一种数据结构。