golang 的 map 实现(二)

如何扩容

map 增长过程中,对空间的需求也在增加,那么如何完成透明扩容的同时,又不会太多影响性能,就是这里要讨论的。

仅考虑空间增长,map 的扩容方式有两种,overflow 以及 hashGrow

  • overflow 是溢出链,是 bucket 级别的扩容,理解成链表
  • hashGrow 则是再散列的实现。空间变大的同时,重新进行散列,不过 rehash 过程不是同步,而是被摊还到了 mapassign 以及 mapdelete变更 的操作上。同时,在这个过程中也涉及老空间的释放,如何释放的这个部分将放在下面 『如何迁移』里面讨论

overflow

map 存储数据的组织形式是通过 bucket 这个结构来做的。 然后在数据结构中的 h.bucket 是代表一组 bucket,其中的每个 bucket 与对应的 overflow 区域,组成了一个链表。后面用 表头 代表 h.bucket 中的 bucket, 用 bucket 代表 bucket 数据结构。 注意:这个表头不是一个 dummy 节点,也是要用来放数据的。

新的链表节点的增加是通过函数 newoverflow 实现的,主要工作就是先获取空间(可能是预先分配的,也有可能是从系统新申请),然后将其放在链表的末端。(针对 overflow[0] 的管理,目前不是很确定管理条件),对应代码和解释如下。

需要注意的就是:在针对预分配的空间和新申请空间的逻辑是有些不同的

func (h *hmap) newoverflow(t *maptype, b *bmap) *bmap {
    var ovf *bmap

    if h.extra != nil && h.extra.nextOverflow != nil {
        // 如果在预先创建了 overflow 区域,则先从本区域进行获取
        ovf = h.extra.nextOverflow
        if ovf.overflow(t) == nil {
            // overflow() 是读取的该 bucket 最后一个指针空间,是否有值
            // 有则代表预先申请的 overflow 区域已经用完,还记得 makeBucketArray 最后的设置吗?
            // 是保存的 h.buckets 的起始地址
            // 然后 nextOverFlow 维护预申请 overflow 域内的偏移量
            h.extra.nextOverflow = (*bmap)(add(unsafe.Pointer(ovf), uintptr(t.bucketsize)))
        } else {
            // 预先的已经用完了。此时的 ovf 代表了 overflow 内最后一个 bucket,将最后的指针位设置为 空
            // 并标记下预先申请的 overflow 区域已经用完
            ovf.setoverflow(t, nil) 
            h.extra.nextOverflow = nil
        }
    } else {
        // 没有预先申请或或者之前已经用完,则从系统中获取
        ovf = (*bmap)(newobject(t.bucket))
    }

    h.incrnoverflow()

    if t.bucket.kind&kindNoPointers != 0 {
        // 这个比较的意义我并没有 get 到,后面附上了大神的解释,希望以后能看明白
        if h.extra == nil {
            h.extra = new(mapextra)
        }
        if h.extra.overflow[0] == nil {
            h.extra.overflow[0] = new([]*bmap)
        }
        h.extra.overflow[0] = append(*h.extra.overflow[0], ovf)
    }
    b.setoverflow(t, ovf)
    // setoverflow 就是将一个节点添加到某个节点的后方,一般就是末位节点(链表结构)
    return ovf
}

关于 t.bucket.kind&kindNoPointers, 得到了大佬的回复如下(虽然我仍旧没有弄明白)

In that code t is *maptype, which is to say it is a pointer to the
type descriptor for the map, essentially the same value you would get
from calling reflect.TypeOf on the map value. t.bucket is a pointer
to the type descriptor for the type of the buckets that the map uses. This type is created by the compiler based on the key and value types
of the map. If the kindNoPointers bit is set in t.bucket.kind, then
the bucket type does not contain any pointers.

With the current implementation, this will be true if the key and value types do not themselves contain any pointers and both types are less than 128 bytes. Whether the bucket type contains any pointers is interesting because the garbage collector never has to look at buckets that contain no pointers. The current map implementation goes to some effort to preserve that property. See the comment in the mapextra type.

访问到 overflow 区域有两种情况
1. 空间满了
2. 发生了碰撞

hashGrow

hashGrow 的流程如下

  1. 申请新空间(可能同等大小。暂时不考虑)
  2. 将当前 buckets 和 overflow 的链,存储到 oldbuckets 以及对应的链
  3. 处理可能的 预申请 overflow 区域
  4. 数据迁移(异步摊还)

注意: 过程 2 中有一个细节问题,即在进行切换的时候,要求此时的 map 务必不能在 growing 中。即不能同时执行两个增长,map 在实现上确保了这个情况不会发生,后面会说。

数据迁移

hashGrow 是一个比较长的状态,从空间申请,一直到数据完成迁移才算结束。那么中间数据是如何迁移的,是这一小节的重点。有两个问题需要注意

  1. 数据如何迁移
  2. 如何保证了迁移的完成(即如何确保不会同时执行两个 hashGrow

数据迁移的入口代码如下,在 mapassignmapdelete 中都能找到

    bucket := hash & (uintptr(1)<<h.B - 1)
    if h.growing() {
        growWork(t, h, bucket)
    }

注意:迁移单元不是 一条数据,而是一整个 bucket !!!!

下面是 growWork 的代码

func growWork(t *maptype, h *hmap, bucket uintptr) {
    // make sure we evacuate the oldbucket corresponding
    // to the bucket we're about to use
    evacuate(t, h, bucket&h.oldbucketmask())

    // evacuate one more oldbucket to make progress on growing
    if h.growing() {
        evacuate(t, h, h.nevacuate)
    }
}

注意growWork 调用了两次 evacuate,一次是释放之前的数据所落在的 bucket 区域(正常逻辑就是这个),还一个有个是 h.nevacuate,而正是这个调用确保了,再次执行 hashGrow 的时候,老数据已经完成了迁移。

evacuate 的末尾有这么一段代码

    // Advance evacuation mark
    if oldbucket == h.nevacuate {
        h.nevacuate = oldbucket + 1
        // Experiments suggest that 1024 is overkill by at least an order of magnitude.
        // Put it in there as a safeguard anyway, to ensure O(1) behavior.
        stop := h.nevacuate + 1024
        if stop > newbit {
            stop = newbit
        }
        for h.nevacuate != stop && bucketEvacuated(t, h, h.nevacuate) {
            h.nevacuate++
        }
        if h.nevacuate == newbit { // newbit == # of oldbuckets
            // Growing is all done. Free old main bucket array.
            h.oldbuckets = nil
            // Can discard old overflow buckets as well.
            // If they are still referenced by an iterator,
            // then the iterator holds a pointers to the slice.
            if h.extra != nil {
                h.extra.overflow[1] = nil
            }
            h.flags &^= sameSizeGrow
        }
    }

这段代码 配合 growWork 中的第二次调用 evacuate ,确保了每次能迁移掉两个 bucket,那就不难理解 h.nevacuate 这个状态标签,其含义是,在此之前的 bucket 都已经被迁移。并且,每次都是在操作 一个 数据的时候,就会迁移两个 bucket,那么就在实际上保证了在执行下一次 hashGrow 的时候, oldbuckets 已经被清空了,也就是上次的扩容工作已经完成。这样就解决了最开始提出来的第二个问题。

下面再说如何迁移这个问题(只讨论容量变大的场景)。

func evacuate(t *maptype, h *hmap, oldbucket uintptr)

evacuate 接受的参数重点是 oldbucket ,代表的其实是一个偏移量,说明是 表头中的位置。然后迁移工作是将本表头及其有的溢出区域全部进行迁移。

那么遍历逻辑就如下所示了,遍历 overflow 区域,然后遍历内部的 tophash,获取到所有的 k/v 对

for ; b != nil; b = b.overflow(t) {
   ...
   for i := 0; i < bucketCnt; ... 

此时就可以执行 rehash 了,来确定存放在新空间什么位置。有意思的地方就在这里了。

因为空间是 2 倍扩容,那么 rehash 后的落点,要么仍旧是原来的位置,要么是原来的位置加上原先容量的位置。

基于这个逻辑,go 把扩容后的空间,分为 X、Y 两个区域,X 在前,Y在后,两个空间均与扩容前空间相同,那么如何进行快速的确认落点就是关键了,确认方式是通过下面这个公式计算。

    useX = hash&newbit == 0

还记得计算余数的公式么 mod = hash & (2^n -1) 这里是把 二进制形式的 hash 的第 n 位以上(包含第 n 位)给过滤掉了。那么判断是否放在 Y 则仅需要判断第 n 为是否为 1 即可了。

思考

看玩这个迁移逻辑的时候,考虑到一个细节问题,然后又去确认了 mapassign 的逻辑。

发现在写入一个 key 的时候,并不是碰见一个可以写入的位置就立刻写入的。而是优先找到相等的项,也就是优先考虑 更新操作 而不是 插入操作。 那么这里就与之前看到的一本书有所出入。这样就保证了一个 key 只可能出现一次。 那么删除操作也只需要删除一次就可以了。

如果不考虑 tophash 那么,性能上将,针对 增删改查 其实是比较平均的状态。 因为数据中没有冗余,那也就没有倾斜了。

阅读完后认为设计比较好的地方:

tophash

  1. 控制比对消耗:key 的长度可能不固定,如果使用原始字符串做碰撞检测,那么此处不可控
  2. 缓存:直接将 hash 进行计算,然后存储,能很大概率避免读取 key 的原始字符串
  3. 使用高 8 位(拆解成两个问题)
    1. 使用 8 位,可以肯定的是减少了内存消耗,但是为什么是 8 ,有待考证
    2. 之所以使用高 8 位,是因为比低 8 位的碰撞率低,因为低 8 位后几位的值相等

其他
1. key 和 value 存储的是引用,使得数据结构紧凑。然后因为分区域存储,那么也就使得定位方便了很多(很多资料都讲过这里)
2. 数据迁移的摊还时间是一个设计上的选择
3. 一个很妙的地方在于 rehash 时候对于 X Y 的选择,虽然没看懂
4. 还有一个很隐晦,但是很妙的地方就是隐式保证了不会同时指向两个 hashGrow
5. 用同事的话 『能够感受到一种说不出来的内功,贯穿整个代码,这可能就是所谓的 心手合一』

参考资料
https://www.gitbook.com/book/tiancaiamao/go-internals/details

文章首次发布于个人博客 https://blog.i19.me

猜你喜欢

转载自blog.csdn.net/totoro19/article/details/79581430