MENU

为什么golang的map和slice不是线程安全的?

April 1, 2022 • Golang

最近在看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线程安全,这也就意味着,他只能在某些特定的环境下才能使用,比如:

  1. 一写多读
  2. 各个协程操作的key集合没有交集

而这两种环境似乎因为非常苛刻,而导致在生产环境中很少使用它,但是这并不妨碍我们去深入探讨他的实现原理:

  1. 读写分离:读操作尽量通过不加锁的read实现,写操作尽量通过dirty加锁实现
  2. 动态调整:新写入的key都只存在于dirty中,如果dirty中的key被多次读取,那么就会将dirty上升成不需要要加锁的read
  3. 延迟删除: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不同,这种情况并不会发生报错,因此程序员应当检查一下是否自己的逻辑存在问题,并及时修改相关逻辑!

作者:NorthCity1984
出处:https://grimoire.cn/golang/mt-safe-map.html
版权:本文《为什么golang的map和slice不是线程安全的?》版权归作者所有
转载:欢迎转载,但未经作者同意,必须保留此段声明;必须在文章中给出原文连接;否则必究法律责任

Last Modified: October 15, 2022