golang sync.Map之如何设计一个并发安全的读写结构?

在 golang中,想要并发安全的操作map,可以使用sync.Map结构,sync.Map 是一个适合读多写少的数据结构,今天我们来看看它的设计思想,来看看为什么说它适合读多写少的场景。

如下,是golang 中sync.Map的数据结构,其中 属性read 是 只读的 map,dirty 是负责写入的map,sync.Map中的键值对value值本质上都是entry指针类型,entry中的p才指向了实际存储的value值

go 复制代码
// sync.Map的核心数据结构
type Map struct {
    mu Mutex                        // 对 dirty 加锁保护,线程安全
    read atomic.Value                 // read 只读的 map,充当缓存层
    dirty map[interface{}]*entry     // 负责写操作的 map,当misses = len(dirty)时,将其赋值给read
    misses int                        // 未命中 read 时的累加计数,每次+1
}
// 上面read字段的数据结构
type readOnly struct {
    m  map[interface{}]*entry // 
    amended bool // Map.dirty的数据和这里read中 m 的数据不一样时,为true
}

// 上面m字段中的entry类型
type entry struct {
    // value是个指针类型
    p unsafe.Pointer // *interface{}
}

我们从一个sync.Map的数据写入和数据查询 两个过程来分析这两个map中数据的变化。

我将不展示具体的代码,仅仅讲述数据的流动,相信懂了这个以后再去看代码应该不难。

步骤一: 首先是一个初始的sync.Map 结构,我们往其中写入数据,数据会写到dirty中,同时,由于sync.Map 刚刚创建,所以read map还不存在,所以这里会先初始化一个read map 。amended 是read map中的一个属性,为true代表 dirty 和read中数据不一致。

步骤二: 接着,如果后续再继续写入新数据, 在read map没有从dirty 同步数据之前,即amended 变为false之前,再写入新键值对都只会往dirty里写。

步骤三: 如果有读操作,sync.Map 都会尽可能的让其先读read map,read map读取不到并且amended 为true,即read 和dirty 数据不一致时,会去读dirty,读dirty的过程是上锁的。

步骤四: 当读取read map中miss次数大于等于dirty数组的长度时,会触发dirty map整体更新为readOnly map,并且这个过程是阻塞的。更新完成后,原先dirty会被置为空,amended 为false,代表read map同步了之前所有的数据。如下图所示,

整体更新的逻辑是直接替换变量的值,并非挨个复制,

go 复制代码
func (m *Map) missLocked() {
    m.misses++
    if m.misses < len(m.dirty) {
        return
    }
    
    // 将dirty置给read,因为穿透概率太大了(原子操作,耗时很小)
    m.read.Store(readOnly{m: m.dirty})
    m.dirty = nil
    m.misses = 0
}

步骤五: 如果后续sync.Map 不再插入新数据,那么读取时就可以一直读取read map中的数据了,直接读取read map 中的key是十分高效的,只需要用atomic.Load 操作 取到readOnly map结构体,然后从中取出特定的key就行。

如果读miss了,因为没有插入新数据,read.amended=false 代表read 是保存了所有的k,v键值对,读miss后,也不会再去读取dirty了,也就不会有读dirty加锁的过程。

go 复制代码
// 上面read字段的数据结构
type readOnly struct {
    m  map[interface{}]*entry // 
    amended bool // Map.dirty的数据和这里read中 m 的数据不一样时,为true
}

func (m *Map) Load(key interface{}) (value interface{}, ok bool) {
    // 因read只读,线程安全,优先读取
    read, _ := m.read.Load().(readOnly)
    e, ok := read.m[key]
    
    // 如果read没有,并且dirty有新数据,那么去dirty中查找(read.amended=true:dirty和read数据不一致)
    // 暂时省略 后续代码
    .......
	
    }

上面的获取key对应的value过程甚至比RWMutex 读锁下获取map中的value还要高效,毕竟RWmutex 读取时还需要加上读锁,其底层是用atomic.AddInt32 操作,而sync.Map 则是用 atomic.load 获取map,atomic.AddInt32 的开销比atomic.load 的开销要大。

📢📢📢,所以,为什么我们说golang的sync.Map 在大量读的情况下性能极佳,因为在整个读取过程中没有锁开销,atomic.load 原子操作消耗极低。

但是如果后续又写入了新的键值对数据,那么 dirty map中就会又插入到新的键值对,dirty和read的数据又不一致了,read 的amended 将改为true。

并且由于之前dirty整体更新为read后,dirty字段置为nil了,所以,在更改amended时,也会将read中的所有未被删除的key同步到 dirty中

📢📢📢注意,为什么在dirty整体更新一次read map后,再写入新的键值对时,需要将read map中的数据全部同步到dirty ,因为随着dirty的慢慢写入,后续读操作又会造成读miss的增加,最终会再次触发dirty map整体更新为readOnly map,amended 改为false,代表read map中又有所有键值对数据了,也就是会回到步骤三的操作,重复步骤三到步骤五的过程。

只有将read map中的数据全部同步到dirty ,才能保证后续的整体更新,不会造成丢失数据。

看到这里应该能够明白sync.Map的适合场景了,我来总结下,

sync.Map 适合读多写少的场景,大量的读操作可以通过只读取read map 拥有极好的性能。

而如果写操作增加,首先会造成read map中读取miss增加,会回源到dirty中读取,且dirty可能会频繁整体更新为read,回源读取,整体更新的步骤都是阻塞上锁的。

其次,写操作也会带来dirty和 read中数据频繁的不一致,导致read中的数据需要同步到dirty中,这个过程在键值对比较多时,性能损耗较大且整个过程是阻塞的。

所以sync.Map 并不适合大量写操作。

相关推荐
周三有雨4 小时前
【面试题系列Vue07】Vuex是什么?使用Vuex的好处有哪些?
前端·vue.js·面试·typescript
爱米的前端小笔记4 小时前
前端八股自学笔记分享—页面布局(二)
前端·笔记·学习·面试·求职招聘
好学近乎知o4 小时前
解决sql字符串
面试
哔哥哔特商务网5 小时前
一文探究48V新型电气架构下的汽车连接器
架构·汽车
007php0075 小时前
GoZero 上传文件File到阿里云 OSS 报错及优化方案
服务器·开发语言·数据库·python·阿里云·架构·golang
不爱说话郭德纲6 小时前
聚焦 Go 语言框架,探索创新实践过程
go·编程语言
码上有前7 小时前
解析后端框架学习:从单体应用到微服务架构的进阶之路
学习·微服务·架构
我明天再来学Web渗透9 小时前
【SQL50】day 2
开发语言·数据结构·leetcode·面试
程序员奇奥10 小时前
京东面试题目分享
面试·职场和发展
理想不理想v11 小时前
【经典】webpack和vite的区别?
java·前端·javascript·vue.js·面试