concurrent-map 和 sync.Map,我该选择哪个?
官方的map并不是线程安全的,如果我们在多线程中并发对一个map进行读写操作,是会引发panic的。解决方案除了使用锁来对map进行保护外,还有两种方式:
一,开源项目 concurrent-map 提供了可以用来做并发安全的map
二,Go1.9之后,标准库提供了一个sync.Map
这两种并发安全的map,我们应该怎么选择呢?
在concurrent-map我看到这么一段话:
标准库中的sync.Map是专为append-only场景设计的。因此,如果您想将Map用于一个类似内存数据库,那么使用我们的版本可能会受益。你可以在golang repo上读到更多,这里 and 这里 译注:sync.Map在读多写少性能比较好,否则并发性能很差
concurrent-map为什么会有这种表述呢?这篇文章就来庖丁解牛下。
concurrent-map
concurrent-map
是Golang中一个流行的并发安全的哈希表库,它允许多个goroutine同时对哈希表进行读写操作,而不需要使用显式的锁或同步原语。
该库的核心原理是使用分片锁,将哈希表分成多个小的哈希表片段,并为每个片段分配一个独立的锁。当多个goroutine尝试同时读写同一个片段时,只有该片段上的锁会被锁住,而其他片段的锁则不受影响,从而避免了整个哈希表被锁住的情况。
当进行写操作时,只需要锁住要写入的片段的锁,以确保原子性操作。当进行读操作时,则不需要锁住片段的锁,只需要对该片段上的读取操作进行同步即可。
此外,concurrent-map
库还使用了一些优化策略,如缓存哈希值和桶的地址,以减少计算和查找时间,从而提高并发读写性能。
总之,concurrent-map
库的原理是基于分片锁和其他优化策略来实现高效的并发安全哈希表。
我们先看它的使用方式:
// 创建一个新的 map.
m := cmap.New[string]()
// 设置变量m一个键为“foo”值为“bar”键值对
m.Set("foo", "bar")
// 从m中获取指定键值.
bar, ok := m.Get("foo")
// 删除键为“foo”的项
m.Remove("foo")
它的New方法创建了一个ConcurrentMap结构
type ConcurrentMap[K comparable, V any] struct {
shards []*ConcurrentMapShared[K, V]
sharding func(key K) uint32
}
我们看ConcurrentMap结构中的shards,是用来代表map分片之后的这些存储分片ConcurrentMapShared。
而sharing这个匿名函数代表的是分配的hash函数。
而存储分片是一个基础的,带有互斥锁的map
type ConcurrentMapShared[K comparable, V any] struct {
items map[K]V
sync.RWMutex
}
所以看到这里我们其实心里明白了个七七八八了,再看下它的New/Set/Get的流程如下:
是的,基本原理就是如上图所示。concurrent-map就是将一个大map拆分成若干个小map,然后用若干个小mutex 对这些小map进行保护。这样,通过降低锁的粒度提升并发程度。毕竟嘛,一个诸葛亮不如十个臭皮匠。
sync.Map
sync.Map
是Golang标准库中提供的一个并发安全的哈希表,它与常规的map相比,可以在多个goroutine并发访问时,保证数据的安全性和一致性。
理解sync.Map,最关键就是理解Map结构。
type Map struct {
mu Mutex //互斥锁,用于锁定dirty map
//优先读map,支持原子操作,注释中有readOnly不是说read是只读,而是它的结构体。read实际上有写的操作
read atomic.Value // readOnly
// dirty是一个当前最新的map,允许读写
dirty map[any]*entry
// 主要记录read读取不到数据加锁读取read map以及dirty map的次数,当misses等于dirty的长度时,会将dirty复制到read
misses int
}
这里的sync.Map的逻辑还是比较复杂的。我们再看它的Store函数和Load函数。
func (m *Map) Store(key, value any)
func (m *Map) Load(key any) (value any, ok bool)
我们先把Store的代码流程图画出来
我们看下,这里面有几个步骤是非常有细节的。
首先,第一次判断read中是否有key的时候是没有加锁的,所以当第一次判断结束后,一旦明确read中没有key,要做后续的操作之前,先做一次加锁操作,做完加锁操作之后,又判断了一次key是否在read中。这是为什么呢?其实是由于在加锁这个操作的前后,map还是有可能有变化的,人不可能两次踏入同一个河流,map也不可能在加锁前后两次都不变,所以这里必须进行二次判断,这里可以说是非常细节了。
其次,在判断read或者dirty中已经有key的时候,Store做的操作不是复制一份value到目标结构,而是使用原子替换atomic.StorePointer 来将目标map中key对应的value指针替换为参数value。为什么呢? - 这是极致的性能优化写法,原子替换能