最近在看java的一些技术博客,发现java的map竟然不是线程安全的,我想,既然java的map都是不安全的,那go的map是安全的么?答案是否定的,纵然是go,也并非是线程安全的
什么是线程安全?
百度百科上是这样定义的:
多个线程访问同一个对象时,如果不用考虑这些线程在运行时环境下的调度和交替执行,也不需要进行额外的同步,或者在调用方进行任何其他操作,调用这个对象的行为都可以获得正确的结果,那么这个对象就是线程安全的
也就是说,多个线程在同时读写一个资源的时候,如果他们之间的读写不会导致资源的污染,那就是线程安全的。很可惜,go的map只有在读的时候是安全的
如果你不能理解,那我们可以看一个场景题:
for i:=0; i < 10000; i++ {
go func() {
mp["key"] = "value"
}
}
如果你写出了这样的代码,并且不识好歹的想要运行它,那么你肯定会收获一大堆报错...
如何实现线程安全?
如果你有学过数据库相关的内容,你可能会想到给资源上锁,那么这个到底可不可行呢?答案是:可行倒是可行,可是上太多的锁,多线程可真就是图一乐了,是吧,Python的GIL(坏笑)
一、上锁
回归正题:我们该如何给go的资源上锁呢?其实在go的sync包里,提供了一个叫RWMutex的锁,我们可以将map做如下封装:
type MTSafeMap struct {
sync.RWMutex
m: map[string]int
}
var SimpleMap = MTSafeMap{m:make(map[string]int)}
在这个结构体中,我们有一个原生的map和一个嵌入读写锁,想要从这个map中读取数据,可以使用如下方法:
SimpleMap.Lock()
SimpleMap.m["key"] = 12
SimpleMap.Unlock()
这样,我们就实现了一个最简单暴力的线程安全的map了,但是前文也说过,通过暴力上锁的方式,将会使得多线程对map读写的时候发生锁的争夺,导致各种冲突,从而大大降低map的读写性能,那么,我们有没有更好的方式呢?
二、降低锁的粒度(分片加锁)
当然有,我们可以尝试在使用锁的情况下,降低锁的粒度,即:我的锁只负责我这个分片,其余的分片我管不着。通过降低锁的粒度,我们就可以减少锁的争夺,提升map的读写性能
实现原理:
// 一个分片
type MTSafeMapShared struct {
sync.RWMutex
item: map[string]int
}
// 线程安全的map
type MTSafeMap []*MTSafeMapShared
创建一个线程安全的map:
var PEERS = 64 // 64个分片
func NewMTSafeMap() MTSafeMap {
mp := make(MTSafeMap, PEERS)
for i := 0; i < PEERS; i++ {
mp[i] = &MTSafeMapShared{item:make(map[string]int)}
}
return mp
}
func (mp MTSafeMap) GetShard(key string) *MTSafeMapShared{
return mp[uint(fnv32(key))%uint(PEERS)] // 对key做一次hash,不过这里似乎有点小问题,就是hash冲突似乎没有解决???大致看看思路就好
}
然后再实现一下map的get和set:
func (mp MTSafeMap) Get(key string, isOk bool) {
shard := mp.GetShard(key)
shard.Rlock()
val, ok := shard.item[key]
shard.RUnlock()
return val, ok
}
func (mp MTSafeMap) Get(key string, value int) {
shard := mp.GetShard(key)
shard.Lock()
shard.Item[key] = value
shard.Unlock()
}
通过降低map的粒度,我们就能实现更高效率的线程安全map
三、sync中的map
在go(1.9+)中的sync包内,提供了一个sync.map,它也是线程安全的,它是通过读写分离的方式实现的map线程安全,这也就意味着,他只能在某些特定的环境下才能使用,比如:
- 一写多读
- 各个协程操作的key集合没有交集
而这两种环境似乎因为非常苛刻,而导致在生产环境中很少使用它,但是这并不妨碍我们去深入探讨他的实现原理:
- 读写分离:读操作尽量通过不加锁的read实现,写操作尽量通过dirty加锁实现
- 动态调整:新写入的key都只存在于dirty中,如果dirty中的key被多次读取,那么就会将dirty上升成不需要要加锁的read
- 延迟删除:Delete只是把被删除的key标记成nil,新增key-value的时候,标记成enpunged;dirty上升成read的时候,标记删除的key被批量移出map,这样的好处是,dirty变成read之前,这些key都会命中read,而read不需要加锁,无论是读还是更新,性能都很高
聊完map的线程安全问题,我们再来看看slice
还是先来看看场景:
var s = []string{}
for i := 0; i < 100; i++ {
go func() {
s = append(s, "val")
}()
}
fmt.Println(len(s))
然后你会发现,每次运行这个程序,得到的结果似乎都不会一致,这是为什么呢?
答案很简单,因为追加进slice的值,发生了覆盖,因此在循环中追加的量,与最终的结果不能保持一致,而且与map不同,这种情况并不会发生报错,因此程序员应当检查一下是否自己的逻辑存在问题,并及时修改相关逻辑!
出处:https://grimoire.cn/golang/mt-safe-map.html
版权:本文《为什么golang的map和slice不是线程安全的?》版权归作者所有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任