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 是检查扩容条件。

扩容条件:

  1. 超出负载:map 元素个数 > 6.5 * 桶个数

  2. 溢出桶过多:溢出桶个数 >= 桶总数 或 溢出桶个数 >= 2 ^ 15

扩容机制:

  1. 双倍扩容:针对条件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)
    }
}
  1. 等量扩容:针对条件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 的方式,性能要好得多:

  1. 只会增长的缓存系统中,一个 key 只写入一次而被读很多次;

  2. 多个 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{}
}
posted @ 2023-02-04 10:21  kohn  阅读(56)  评论(0)    收藏  举报