go数据结构map,channel,sync.map | 豆包MarsCode AI刷题

44 阅读12分钟

map

总体结构

// A header for a Go map.
type hmap struct {
    // 元素个数,调用 len(map) 时,直接返回此值
    count     int
    flags     uint8
    // buckets 的对数 log_2
    B         uint8
    // overflow 的 bucket 近似数
    noverflow uint16
    // 计算 key 的哈希的时候会传入哈希函数
    hash0     uint32
    // 指向 buckets 数组,大小为 2^B
    // 如果元素个数为0,就为 nil
    buckets    unsafe.Pointer
    // 等量扩容的时候,buckets 长度和 oldbuckets 相等
    // 双倍扩容的时候,buckets 长度会是 oldbuckets 的两倍
    oldbuckets unsafe.Pointer
    // 指示扩容进度,小于此地址的 buckets 迁移完成
    nevacuate  uintptr //存扩容的已经完成迁移的buckets
    extra *mapextra // optional fields
}


type mapextra struct {
    overflow    *[]*bmap
    oldoverflow *[]*bmap

    // nextOverflow holds a pointer to a free overflow bucket.
    nextOverflow *bmap
}

type bmap struct {
    tophash  [8]uint8
    keys     [8]keytype
    values   [8]valuetype
    pad      uintptr
    overflow uintptr
}

1.查询

map 读流程主要分为以下几步:

(1)根据 key 取 hash 值;

(2)根据 hash 值对桶数组取模,确定所在的桶;

(3)沿着桶链表依次遍历各个桶内的 key-value 对;

(4)命中相同的 key,则返回 value;倘若 key 不存在,则返回零值.

2.写入

map 写流程主要分为以下几步:

(1)根据 key 取 hash 值;

(2)根据 hash 值对桶数组取模,确定所在的桶;

(3)倘若 map 处于扩容,则迁移命中的桶,帮助推进渐进式扩容;

(4)沿着桶链表依次遍历各个桶内的 key-value 对;

(5)倘若命中相同的 key,则对 value 中进行更新;

(6)倘若 key 不存在,则插入 key-value 对;

(7)倘若发现 map 达成扩容条件,则会开启扩容模式,并重新返回第(2)步.

3.删除

map 删除 kv 对流程主要分为以下几步:

(1)根据 key 取 hash 值;

(2)根据 hash 值对桶数组取模,确定所在的桶;

(3)倘若 map 处于扩容,则迁移命中的桶,帮助推进渐进式扩容;

(4)沿着桶链表依次遍历各个桶内的 key-value 对;

(5)倘若命中相同的 key,删除对应的 key-value 对;并将当前位置的 tophash 置为 emptyOne,表示为空;

(6)倘若当前位置为末位,或者下一个位置的 tophash 为 emptyRest,则沿当前位置向前遍历,将毗邻的 emptyOne 统一更新为 emptyRest.

4.扩容

溢出

结合链表法和开放寻址法两种方法实现

溢出桶的来源是hmap中的extra,如果有剩余就会从这里拿取:

I 倘若 hmap.extra 中还有剩余可用的溢出桶,则直接获取 hmap.extra.nextOverflow,并将 nextOverflow 调整指向下一个空闲可用的溢出桶;

II 倘若 hmap 已经没有空闲溢出桶了,则创建一个新的溢出桶.

III hmap 的溢出桶数量 hmap.noverflow 累加 1;

IV 将新获得的溢出桶添加到原桶链表的尾部;

V 返回溢出桶.

扩容机制

map 扩容机制的核心点包括:

(1)扩容分为增量扩容和等量扩容;

(2)当桶内 key-value 总数/桶数组长度 > 6.5 时发生增量扩容,桶数组长度增长为原值的两倍;

(3)当桶内溢出桶数量大于等于 2^B 时( B 为桶数组长度的指数,B 最大取 15),发生等量扩容,桶的长度保持为原值;

(4)采用渐进扩容的方式,当桶被实际操作到时,由使用者负责完成数据迁移,避免因为一次性的全量数据迁移引发性能抖动。

什么是渐进扩容?

扩容时,Go 并不会一次性完成整个 map 的扩容,而是将扩容分解为多个步骤,在未来的读写操作中逐渐完成。具体过程如下:

a. 分配新桶数组
  • 扩容开始时,Go 为 map 分配一个新的桶数组,其大小是当前桶数组的两倍。这是因为每次扩容,桶的数量都会翻倍(2^B 个桶,B 每次扩容增加 1)。
  • 原桶数组(buckets)保留,新的桶数组被称为 oldbuckets,用于存放旧的桶
b. 渐进迁移数据
  • 在扩容过程中,每次对 map 进行读写操作时,Go 会将一部分数据从旧桶数组(oldbuckets)迁移到新桶数组(buckets)。具体来说,每次操作会迁移一个或几个桶的数据,逐步将所有旧桶的数据搬移到新桶中。
  • 迁移的桶数由 nevacuate 字段记录,它表示当前迁移的进度。每当一个桶的数据被成功迁移,nevacuate 递增。
c. 读取数据时的处理
  • 在渐进扩容的过程中,

  • 可能会同时存在旧的桶和新的桶。因此,读操作需要检查数据是存储在旧桶中还是新桶中

    • 如果桶尚未迁移,数据会被读取自旧桶。
    • 如果桶已经被迁移到新桶,读操作直接从新桶中获取数据。
d. 插入数据时的处理
  • 插入操作也需要根据迁移进度判断是否将数据插入到旧桶还是新桶。

    • 如果数据对应的桶还没有迁移,数据会被插入到旧桶,并标记为该桶待迁移。
    • 如果数据对应的桶已经迁移,数据会直接插入到新桶

3. 逐步完成迁移

通过每次操作都迁移一部分桶的数据,Go 能够将一次性扩容的开销分摊到后续的多个操作中,减少了单次操作的性能抖动。

4. 扩容完成

当所有桶的数据都迁移完毕后,Go 会将旧的桶数组(oldbuckets)设置为 nil,从而完全切换到新的桶数组上。

5. 哈希值的重新计算

在迁移数据时,元素可能会被重新分配到新的桶,因为哈希表的桶数量增加了(翻倍)。因此,Go 会基于新桶数量(2^B)重新计算哈希值,从而确定元素的存储位置。

channel

type hchan struct {
    qcount   uint           // 当前 channel 中存在多少个元素
    dataqsiz uint           // 当前 channel 能存放的元素容量
    buf      unsafe.Pointer // channel 中用于存放元素的环形缓冲区
    elemsize uint16         // channel 元素类型的大小
    closed   uint32         // 标识 channel 是否关闭
    elemtype *_type         // channel 元素类型
    sendx    uint           // 发送元素进入环形缓冲区的 index
    recvx    uint           // 接收元素所处的环形缓冲区的 index
    recvq    waitq          // 因接收而陷入阻塞的协程队列
    sendq    waitq          // 因发送而陷入阻塞的协程队列
    lock mutex              // 锁
}

type waitq struct {
    first *sudog  //队列头部
    last  *sudog  //队列尾部
}

type sudog struct {
    g *g      //goroutine,协程
    next *sudog  //队列中的下一个节点
    prev *sudog  //队列中的前一个节点
    elem unsafe.Pointer // 读取/写入 channel 的数据的容器
    isSelect bool  //标识当前协程是否处在 select 多路复用的流程中
    c        *hchan   //标识与当前 sudog 交互的 chan.
}

chanel的存储结构是一个基于循环数组的队列,与一般对列的区别在于并发式的存取,其中buf是存储的位置

buf 指向底层循环数组,只有缓冲型的 channel 才有。

sendxrecvx 均指向底层循环数组,表示当前可以发送和接收的元素位置索引值(相对于底层数组)。

sendqrecvq 分别表示被阻塞的写入goroutine和读取goroutine,这些 goroutine 由于尝试读取 channel 或向 channel 发送数据而被阻塞。

waitqsudog 的一个双向链表,而 sudog 实际上是对 goroutine 的一个封装:

创建一个容量为 6 的,元素为 int 型的 channel 会得到如下结果:

1.写

case1写时存在读协程阻塞

直接写到阻塞的读协程队列里去

case2:写时无阻塞读协程但环形缓冲区仍有空间

写到缓冲区

• 加锁;

• 将当前元素添加到环形缓冲区 sendx 对应的位置;

• sendx++;

• qcount++;

• 解锁,返回.

case3:写时无阻塞读协程且环形缓冲区无空间

加入到写队列中去

• 加锁;

• 构造封装当前 goroutine 的 sudog 对象;

• 完成指针指向,建立 sudog、goroutine、channel 之间的指向关系;

• 把 sudog 添加到当前 channel 的阻塞写协程队列中;

• park 当前协程;

• 倘若协程从 park 中被唤醒,则回收 sudog(sudog能被唤醒,其对应的元素必然已经被读协程取走);

• 解锁,返回

2.读

case1:读空 channel

park 将该goroutine挂起,引起死锁;

case2:channel 已关闭且内部无元素

直接解锁返回默认值

case3:读时有阻塞的写协程

• 加锁;

• 从阻塞写协程队列中获取到一个写协程;

• 倘若 channel 无缓冲区,则直接读取写协程元素,并唤醒写协程;

• 倘若 channel 有缓冲区,则读取缓冲区头部元素,并将写协程元素写入缓冲区尾部后唤醒写写成;

• 解锁,返回.

case4:读时无阻塞写协程且缓冲区有元素

• 加锁;

• 获取到 recvx 对应位置的元素;

• recvx++

• qcount--

• 解锁,返回

case5:读时无阻塞写协程且缓冲区无元素

• 加锁;

• 构造封装当前 goroutine 的 sudog 对象;

• 完成指针指向,建立 sudog、goroutine、channel 之间的指向关系;

• 把 sudog 添加到当前 channel 的阻塞读协程队列中;

• park 当前协程;

• 倘若协程从 park 中被唤醒,则回收 sudog;

• 解锁,返回

关闭

• 关闭未初始化过的 channel 会 panic;

• 加锁;

• 重复关闭 channel 会 panic;

• 将阻塞读协程队列中的协程节点统一添加到 glist;

• 将阻塞写协程队列中的协程节点统一添加到 glist;

• 唤醒 glist 当中的所有协程.

sync.Map

type Map struct {
    mu Mutex  //锁
    read atomic.Value //无锁化的只读 map
    dirty map[any]*entry //加锁处理的读写 map,注意这里的key类型是any!
    misses int  //记录访问 read 的失效次数
}

type entry struct {
    p unsafe.Pointer //存的是指针!所以可以存任何类型
}

type readOnly struct {
    m       map[any]*entry
    amended bool // true if the dirty map contains some key not in m.
}

entry.p 的指向分为三种情况:

I 存活态:正常指向元素;

II 软删除态:指向 nil;(key不删除但是value会被删除)

III 硬删除态:指向固定的全局变量 expunged.(key和value都删除)

所有操作中遵循基本的路线就是

  1. 查询read
  2. 加锁查询read
  3. 查询dirty
  4. 返回是否检查到的结果或者进行操作

这就是为什么适用于多读少写的场景!如果说写的话,尤其是新增意味着要查找三次,读的话大多数情况只要一次

//查询
if value, ok := m.Load("name"); ok {
    fmt.Println(value)  // 输出 "Alice"
}
//查询,如果查询不到就用传入的值
actual, loaded := m.LoadOrStore("name", "Bob")
fmt.Println(actual, loaded)  // 输出 "Alice", true
  • 查看 read map 中是否存在 key-entry 对,若存在,则直接读取 entry 返回;

  • 倘若第一轮 read map 查询 miss,且 read map 不全,则需要加锁 double check;

  • 第二轮 read map 查询仍 miss(加锁后),且 read map 不全,则查询 dirty map 兜底;

  • 查询操作涉及到与 dirty map 的交互,misses 加一;(当miss大于等于key-value数量的时候将会触发dirty将read覆盖)

  • 解锁,返回查得的结果.

如果read map被击穿就会进入missLocked流程

  • 在读流程中,倘若未命中 read map,且由于 read map 内容存在缺失需要和 dirty map 交互时,会走进 missLocked 流程;

  • 在 missLocked 流程中,首先 misses 计数器累加 1;

  • 倘若 miss 次数小于 dirty map 中存在的 key-entry 对数量,直接返回即可;

  • 倘若 miss 次数大于等于 dirty map 中存在的 key-entry 对数量,则使用 dirty map 覆盖 read map,并将 read map 的 amended flag 置为 false;(出现大规模的修改会导致性能抖动!)

  • 新的 dirty map 置为 nil,misses 计数器清零.

var m sync.Map
m.Store("name", "Alice")
m.Store(1, 100)

由于sync的底层存储结构主要是map[any] type *entry

type entry struct { p unsafe.Pointer //存的是指针}`所以可以存一切的数据

(1)倘若 read map 存在拟写入的 key,且 entry 不为 expunged 状态,说明这次操作属于更新而非插入,直接基于 CAS 操作进行 entry 值的更新,并直接返回(存活态或者软删除,直接覆盖更新);

(2)倘若未命中(1)的分支,则需要加锁 double check;

(3)倘若第二轮检查中发现 read map 或者 dirty map 中存在 key-entry 对,则直接将 entry 更新为新值即可(存活态或者软删除,直接覆盖更新);

(4)在第(3)步中,如果发现 read map 中该 key-entry 为 expunged 态,需要在 dirty map 先补齐 key-entry 对,再更新 entry 值(从硬删除中恢复,然后覆盖更新);

(5)倘若 read map 和 dirty map 均不存在,则在 dirty map 中插入新 key-entry 对,并且保证 read map 的 amended flag 为 true.(插入值但是却不在只读map里面插入,意味着新插入的值的查询效率更低);

(6)第(5)步的分支中,倘若发现 dirty map 未初始化,需要前置执行 dirtyLocked 流程;

dirtyLocked

• 在写流程中,倘若需要将 key-entry 插入到兜底的 dirty map 中,并且此时 dirty map 为空(从未写入过数据或者刚发生过 missLocked),会进入 dirtyLocked 流程;

• 此时会遍历一轮 read map ,将未删除的 key-entry 对拷贝到 dirty map 当中;

• 在遍历时,还会将 read map 中软删除 nil 态的 entry 更新为硬删除 expunged 态,因为在此流程中,不会将其拷贝到 dirty map.

m.Delete("name")

(1)倘若 read map 中存在 key,则直接基于 cas(compare and swap) 操作将其删除;

(2)倘若read map 不存在 key,且 read map 有缺失(amended flag 为 true),则加锁 dou check;

(3)倘若加锁 double check 时,read map 仍不存在 key 且 read map 有缺失,则从 dirty map 中取元素,并且将 key-entry 对从 dirty map 中物理删除;

(4)走入步骤(3),删操作需要和 dirty map 交互,需要走进 3.3 小节介绍的 missLocked 流程;

(5)解锁;

(6)倘若从 read map 或 dirty map 中获取到了 key 对应的 entry,则走入 entry.delete() 方法逻辑删除 entry;

(7)倘若 read map 和 dirty map 中均不存在 key,返回 false 标识删除失败.

遍历

例如:

m.Range(func(key, value interface{}) bool {
    fmt.Println(key, value)
    return true
})

(1)在遍历过程中,倘若发现 read map 数据不全(amended flag 为 true),会额外加一次锁,并使用 dirty map 覆盖 read map;

(2)遍历 read map(通过步骤(1)保证 read map 有全量数据),执行用户传入的回调函数,倘若某次回调时返回值为 false,则会终止全流程.