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 才有。
sendx,recvx 均指向底层循环数组,表示当前可以发送和接收的元素位置索引值(相对于底层数组)。
sendq,recvq 分别表示被阻塞的写入goroutine和读取goroutine,这些 goroutine 由于尝试读取 channel 或向 channel 发送数据而被阻塞。
waitq 是 sudog 的一个双向链表,而 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都删除)
所有操作中遵循基本的路线就是
- 查询read
- 加锁查询read
- 查询dirty
- 返回是否检查到的结果或者进行操作
这就是为什么适用于多读少写的场景!如果说写的话,尤其是新增意味着要查找三次,读的话大多数情况只要一次
读
//查询
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,则会终止全流程.