map
Map
1 Go Map 的底层实现原理
Go 中 Map 是一个指针,指向 hmap 结构体。hmap 包含 buckets(Pointer) 和 oldbuckets(Pointer),Pointer 指针的地址是 bmap 数组作为 hash 表,散列的方式是将 bmap 连成链表来进行散列。
bmap 就是我们说的 bucket(桶)。一个 bmap 中最多装着 8 个 key,hash 值低 B 位相同的 key 会落入到一个桶内,而 hash 值高 8 位决定 key 在这个桶中的位置。(一个桶最多有8个位置,如果满了就通过链表散列,如果散列的桶过多,就会触发 rehash)。bmap 中 key 和 value 是分开存储的,因为内存需要对齐,所以分开存储可以节省内存空间。
2 Go Map 遍历为什么是无序的
-
map 在遍历的时候,是从一个随机的 bucket 开始遍历的
-
map 在扩容后,会发生 key 的迁移,所以遍历顺序就会改变
3 Go Map 为什么是非线程安全的
在大部分场景下,map 只被单个 goroutine 访问,如果要为了线程安全而加锁,那么会让大部分场景为了小部分场景而付出加锁的性能代价。
map 在写的时候会将写标志位置为 1,表示当前 goroutine 正在写,其他 goroutine 在读/写之前检查写标志位,如果为1,说明其他 goroutine 正在写,那么就 panic。
如何实现 map 线程安全?
-
Map + sync.RWMutex (使用读写锁)
-
使用 Go 提供的 sync.Map
4 Go Map 如何查找
-
检查写标志位是否为 1,为 1 则 panic
-
计算 hash 值
-
找到 bucket
-
遍历 bucket 查找
-
返回 key 对应的指针
5 Go Map 如何扩容
扩容时机:
在向 map 插入 key 是检查扩容条件。
扩容条件:
-
超出负载:map 元素个数 > 6.5 * 桶个数
-
溢出桶过多:溢出桶个数 >= 桶总数 或 溢出桶个数 >= 2 ^ 15
扩容机制:
- 双倍扩容:针对条件1,新建一个容量为原来两倍的 buckets 数组,将旧数据搬迁过去。使用渐进式扩容,插入删除修改key的时候,进行搬迁。
func growWork(t *maptype, h *hmap, bucket uintptr) {
// 为了确认搬迁的 bucket 是我们正在使用的 bucket
// 即如果当前key映射到老的bucket1,那么就搬迁该bucket1。
evacuate(t, h, bucket&h.oldbucketmask())
// 如果还未完成扩容工作,则再搬迁一个bucket。
if h.growing() {
evacuate(t, h, h.nevacuate)
}
}
- 等量扩容:针对条件2,不扩大容量,bucket 数量维持不变,将松散的键值对重新排列。
6 Go Map 和 sync.Map 区别
sync.map 源码实现
Go 语言的 sync.Map
支持并发读写,采取了 “空间换时间” 的机制,冗余了两个数据结构,分别是:read 和 dirty
type Map struct {
mu Mutex
read atomic.Value // readOnly
dirty map[interface{}]*entry
misses int
}
说明 | 类型 | 作用 |
---|---|---|
mu | Mutex | 加锁作用。保护后文的dirty字段 |
read | atomic.Value | 存读的数据。因为是atomic.Value类型,只读,所以并发是安全的。实际存的是readOnly的数据结构。 |
misses | int | 计数作用。每次从read中读失败,则计数+1。 |
dirty | map[interface{}]*entry | 包含最新写入的数据。当misses计数达到一定值,将其赋值给read。 |
type readOnly struct {
m map[interface{}]*entry
amended bool
}
说明 | 类型 | 作用 |
---|---|---|
m | map[interface{}]*entry | 单纯的map结构 |
amended | bool | Map.dirty的数据和这里的 m 中的数据不一样的时候,为true |
type entry struct {
//可见value是个指针类型,虽然read和dirty存在冗余情况(amended=false),但是由于是指针类型,存储的空间应该不是问题
p unsafe.Pointer // *interface{}
}
这个结构体主要是想说明。虽然前文read和dirty存在冗余的情况,但是由于value都是指针类型,其实存储的空间其实没增加多少。
与 map 的对别
对比原始map:
和原始map+RWLock的实现并发的方式相比,减少了加锁对性能的影响。它做了一些优化:可以无锁访问read map,而且会优先操作read map,倘若只操作read map就可以满足要求,那就不用去操作write map(dirty),所以在某些特定场景中它发生锁竞争的频率会远远小于map+RWLock的实现方式
优点:
适合读多写少的场景
缺点:
写多的场景,会导致 read map 缓存失效,需要加锁,冲突变多,性能急剧下降
思考
想一想,mysql加锁,是不是有表级锁、行级锁,前文的sync.RWMutex加锁方式相当于表级锁。
而sync.Map其实也是相当于表级锁,只不过多读写分了两个map,本质还是一样的。
既然这样,那就自然知道优化方向了:就是把锁的粒度尽可能降低来提高运行速度。
思路:对一个大map进行hash,其内部是n个小map,根据key来来hash确定在具体的那个小map中,这样加锁的粒度就变成1/n了。
网上找了下,真有大佬实现了:GitHub - orcaman/concurrent-map: a thread-safe concurrent map for go
线程安全的Map
解决 map 并发 panic 的两个方法:加锁和分片.
在我个人使用并发 map 的过程中,加锁和分片加锁这两种方案都比较常用,如果是追求更高的性能,显然是分片加锁更好,因为它可以降低锁的粒度,进而提高访问此 map 对象的吞吐。如果并发性能要求不是那么高的场景,简单加锁方式更简单。
加读写锁
type RWMap struct { // 一个读写锁保护的线程安全的map
sync.RWMutex // 读写锁保护下面的map字段
m map[int]int
}
// 新建一个RWMap
func NewRWMap(n int) *RWMap {
return &RWMap{
m: make(map[int]int, n),
}
}
func (m *RWMap) Get(k int) (int, bool) { //从map中读取一个值
m.RLock()
defer m.RUnlock()
v, existed := m.m[k] // 在锁的保护下从map中读取
return v, existed
}
func (m *RWMap) Set(k int, v int) { // 设置一个键值对
m.Lock() // 锁保护
defer m.Unlock()
m.m[k] = v
}
func (m *RWMap) Delete(k int) { //删除一个键
m.Lock() // 锁保护
defer m.Unlock()
delete(m.m, k)
}
func (m *RWMap) Len() int { // map的长度
m.RLock() // 锁保护
defer m.RUnlock()
return len(m.m)
}
func (m *RWMap) Each(f func(k, v int) bool) { // 遍历map
m.RLock() //遍历期间一直持有读锁
defer m.RUnlock()
for k, v := range m.m {
if !f(k, v) {
return
}
}
}
正如这段代码所示,对 map 对象的操作,无非就是增删改查和遍历等几种常见操作。我们可以把这些操作分为读和写两类,其中,查询和遍历可以看做读操作,增加、修改和删除可以看做写操作。如例子所示,我们可以通过读写锁对相应的操作进行保护。
分片加锁: 更高效的并发 map
虽然使用读写锁可以提供线程安全的 map,但是在大量并发读写的情况下,锁的竞争会非常激烈。锁是性能下降的重要原因之一 !
在并发编程中,我们的一条原则就是尽量减少锁的使用。一些单线程单进程的应用(比如 Redis 等),基本上不需要使用锁去解决并发线程访问的问题,所以可以取得很高的性能。但是对于 Go 开发的应用程序来说,并发是常用的一个特性,在这种情况下,我们能做的就是,尽量减少锁的粒度和锁的持有时间。
你可以优化业务处理的代码,以此来减少锁的持有时间,比如将串行的操作变成并行的子任务执行。不过,这就是另外的故事了,今天我们还是主要讲对同步原语的优化,所以这里我重点讲如何减少锁的粒度。
减少锁的粒度常用的方法就是分片(Shard),将一把锁分成几把锁,每个锁控制一个分片。Go 比较知名的分片并发 map 的实现是
GitHub - orcaman/concurrent-map: a thread-safe concurrent map for go
它默认采用 32 个分片,GetShard 是一个关键的方法,能够根据 key 计算出分片索引。
sync.Map
Go 官方线程安全 map 的标准实现。虽然是官方标准,反而是不常用的,为什么呢?一句话来说就是 map 要解决的场景很难描述,很多时候在做抉择时根本就不知道该不该用它。但是呢,确实有一些特定的场景,我们需要用到 sync.Map 来实现.
使用sync.Map的场景
Go 内建的 map 类型不是线程安全的,所以 Go 1.9 中增加了一个线程安全的 map,也就是 sync.Map。但是,我们一定要记住,这个 sync.Map 并不是用来替换内建的 map 类型的,它只能被应用在一些特殊的场景里。
那这些特殊的场景是啥呢?官方的文档中指出,在以下两个场景中使用 sync.Map,会比使用 map+RWMutex 的方式,性能要好得多:
-
只会增长的缓存系统中,一个 key 只写入一次而被读很多次;
-
多个 goroutine 为不相交的键集读、写和重写键值对。
这两个场景说得都比较笼统,而且,这些场景中还包含了一些特殊的情况。所以,官方建议你针对自己的场景做性能评测,如果确实能够显著提高性能,再使用 sync.Map。
这么来看,我们能用到 sync.Map 的场景确实不多。即使是 sync.Map 的作者 Bryan C. Mills,也很少使用 sync.Map,即便是在使用 sync.Map 的时候,也是需要临时查询它的 API,才能清楚记住它的功能。
所以,我们可以把 sync.Map 看成一个生产环境中很少使用的同步原语。
sync.Map 的实现
那 sync.Map 是怎么实现的呢?它是如何解决并发问题提升性能的呢?其实 sync.Map 的实现有几个优化点,这里先列出来,我们后面慢慢分析。
-
空间换时间。通过冗余的两个数据结构(只读的 read 字段、可写的 dirty),来减少加锁对性能的影响。
-
对只读字段(read)的操作不需要加锁。优先从 read 字段读取、更新、删除,因为对 read 字段的读取不需要锁。
-
动态调整。miss 次数多了之后,将 dirty 数据提升为 read,避免总是从 dirty 中加锁读取。
-
double-checking。加锁之后先还要再检查 read 字段,确定真的不存在才操作 dirty 字段。
-
延迟删除。删除一个键值只是打标记,只有在提升 dirty 字段为 read 字段的时候才清理删除的数据。
要理解 sync.Map 这些优化点,我们还是得深入到它的设计和实现上,去学习它的处理方式。
我们先看一下 map 的数据结构:
type Map struct {
mu Mutex
// 基本上你可以把它看成一个安全的只读的map
// 它包含的元素其实也是通过原子操作更新的,但是已删除的entry就需要加锁操作了
read atomic.Value // readOnly
// 包含需要加锁才能访问的元素
// 包括所有在read字段中但未被expunged(删除)的元素以及新加的元素
dirty map[interface{}]*entry
// 记录从read中读取miss的次数,一旦miss数和dirty长度一样了,就会把dirty提升为read,并把dirty置空
misses int
}
type readOnly struct {
m map[interface{}]*entry
amended bool // 当dirty中包含read没有的数据时为true,比如新增一条数据
}
// expunged是用来标识此项已经删掉的指针
// 当map中的一个项目被删除了,只是把它的值标记为expunged,以后才有机会真正删除此项
var expunged = unsafe.Pointer(new(interface{}))
// entry代表一个值
type entry struct {
p unsafe.Pointer // *interface{}
}