深入理解 Go map (3)—— 遍历

前言

正常来说,哈希表的遍历其实很简单,比如java的HashMap:

  • 从头到尾遍历所有桶,对于每个桶再遍历其所有键值对的就完事了

但是go的map没这么简单,涉及到以下两个问题:

  1. 随机遍历
  2. 扩容过程当中遍历

随机遍历

当我们在遍历 map 时,并不是固定地从第0 号 bucket 开始遍历,而是每次都是从一个随机值序号的 bucket 开始遍历,并且是从这个 bucket 的一个随机序号的 cell 开始遍历。这样,即使你是一个写死的 map,仅仅只是遍历它,也大概率不会返回一个固定序列的 key/value

为什么要这么设计?

其实这个设计在go1就有了,官方的说明如下:


The old language specification did not define the order of iteration for maps, 
and in practice it differed across hardware platforms. 
This caused tests that iterated over maps to be fragile and non-portable, 
with the unpleasant property that a test might always pass on one machine
 but break on another. 

总结起来是说以前的编程语言没有定义map的迭代顺序,不同平台的遍历顺序可能不一样,这导致基于特定顺序的代码在不同平台之间的运行结果可能不一致

另外map扩容后,一个bucket的数据分散到两个bucket,也会导致键值对的相对次序产生变化

因此为了防止程序员依赖特定的迭代顺序,从go1开始map的迭代就不可预测

扩容过程中遍历

如果map触发了扩容操作,那么在很长时间里,map 的状态都是处于一个中间态:

有些 bucket 已经被搬迁,数据在新桶h.buckets里,而有些bucket还没被搬迁,数据在老桶h.oldbuckets里

正在扩容中,go的map是永远遍历新桶,即h.buckets,这里就分两种情况,新桶对应的老桶是否已经搬迁:

  • 已经搬迁:还好说,遍历新桶就行,不会漏掉键值对
  • 尚未搬迁:此时就需要去老桶中扁遍历,go map会去新桶对应的老桶中,遍历将来会搬迁到该新桶的这部分键值对

遍历过程

我们分析如下一个简单的for range代码:

func main() {
   m := map[int]int{}
   for k, v := range m {
      process(k, v)
   }
}

// 处理k,v
func process(k, v int) {
   fmt.Println(k)
   fmt.Println(v)
}

我们执行go tool compile -S -N -l main.go查看汇编实现

其遍历流程为:
在这里插入图片描述

初始化hiter

  1. 调用mapiterinit方法初始化用于遍历的结构体hiter

对应到汇编代码为:

0x00f7 00247 (main.go:7)        LEAQ    type.map[int]int(SB), AX 
0x00fe 00254 (main.go:7)        MOVQ    AX, (SP)    
0x0102 00258 (main.go:7)        MOVQ    ""..autotmp_3+224(SP), AX
0x010a 00266 (main.go:7)        MOVQ    AX, 8(SP)
0x010f 00271 (main.go:7)        LEAQ    ""..autotmp_4+280(SP), AX
0x0117 00279 (main.go:7)        MOVQ    AX, 16(SP)
0x011c 00284 (main.go:7)        CALL    runtime.mapiterinit(SB)

准备3个参数,分别放在SP+0,SP+8,SP+16,调用runtime.mapiterinit方法

注意第3个参数就是hiter,编译器将其分配在SP+280的位置,用LEAQ指令将SP+280这个地址传给mapiterinit

该方法只是初始化hiter,而不是实例化,因为编译器已经帮我们在栈上实例化好了,上面的汇编示例中hiter在SP+280的地方

hiter的结构如下:

type hiter struct {
   // 指向待遍历key的地址
   key         unsafe.Pointer 
   // 指向待遍历value的地址
   elem        unsafe.Pointer 
   // 应用hmap
   h           *hmap
   // 
   buckets     unsafe.Pointer
   bptr        *bmap          
   overflow    *[]*bmap       
   oldoverflow *[]*bmap   
   
   // 开始遍历时是第几个bucket    
   startBucket uintptr
   // 遍历每个桶时从第几个槽位开始       
   offset      uint8
   // 是否从头遍历了          
   wrapped     bool          
   B           uint8
   
   // 正在遍历的槽位
   i           uint8
   // 正在遍历的桶
   bucket      uintptr
   checkBucket uintptr
}

主要是维护遍历过程中必须要知道的信息,比如遍历的进度,当前遍历的key/value是什么

mapiterinit方法最重要的就是设置从什么位置开始遍历:

// 生成随机数
r := uintptr(fastrand())
if h.B > 31-bucketCntBits {
  r += uintptr(fastrand()) << 31
}
// 从哪个 bucket 开始遍历
it.startBucket = r & bucketMask(h.B)
// 从 bucket 的哪个 cell 开始遍历
it.offset = uint8(r >> h.B & (bucketCnt - 1))

遍历下一个键值对

在mapiterinit内部初始化好hiter结构后,紧接着调用mapiternext方法,开始第一次遍历:

func mapiternext(it *hiter) {
   h := it.h
   t := it.t
   bucket := it.bucket
   b := it.bptr
   i := it.i
   checkBucket := it.checkBucket

next:
   if b == nil {
      // 如果已经遍历完了,将key,elem置空,返回
      if bucket == it.startBucket && it.wrapped {
         it.key = nil
         it.elem = nil
         return
      }
      
      // 如果正在扩容,且老桶没有被搬迁,使用老桶,否则使用新桶
      if h.growing() && it.B == h.B {
         oldbucket := bucket & it.h.oldbucketmask()
         b = (*bmap)(add(h.oldbuckets, oldbucket*uintptr(t.bucketsize)))
         if !evacuated(b) {
            checkBucket = bucket
         } else {
            b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize)))
            checkBucket = noCheck
         }
      } else {
         //  没有在扩容,使用新桶
         b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize)))
         checkBucket = noCheck
      }
      
      // 推进bucket
      bucket++
      if bucket == bucketShift(it.B) {
         bucket = 0
         it.wrapped = true
      }
      i = 0
   }
   
   // 遍历每个槽
   for ; i < bucketCnt; i++ {
       // 从第offset个槽开始
      offi := (i + it.offset) & (bucketCnt - 1)
      if isEmpty(b.tophash[offi]) || b.tophash[offi] == evacuatedEmpty { 
         // 空的跳过       
         continue
      }
      k := add(unsafe.Pointer(b), dataOffset+uintptr(offi)*uintptr(t.keysize))
      if t.indirectkey() {
         k = *((*unsafe.Pointer)(k))
      }
      e := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+uintptr(offi)*uintptr(t.elemsize))
      if checkBucket != noCheck && !h.sameSizeGrow() {
         if t.reflexivekey() || t.key.equal(k, k) {
            // 如果当前遍历的新桶对应的老桶还没迁移,只去遍历老桶中将来会迁移到该新桶中的元素
            hash := t.hasher(k, uintptr(h.hash0))
            if hash&bucketMask(it.B) != checkBucket {
               continue
            }
         } else {
            // ...
         }
      }
      if (b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY) ||
         !(t.reflexivekey() || t.key.equal(k, k)) {
         // 遍历到一个键值对
         it.key = k
         if t.indirectelem() {
            e = *((*unsafe.Pointer)(e))
         }
         it.elem = e
      } else {
 
          // ...
      }
      it.bucket = bucket
      if it.bptr != b { // avoid unnecessary write barrier; see issue 14921
         it.bptr = b
      }
      it.i = i + 1
      it.checkBucket = checkBucket
      return
   }
   
   // 遍历溢出桶
   b = b.overflow(t)
   i = 0
   goto next
}

该方法主要就是从第hiter.bucket个桶,hiter.i个槽开始往后找非空的键值对,找到了就返回

需要注意一下几点:

  1. 遍历每个桶时,从第offset个槽位开始遍历,

    1. 例如offset = 6,那么在每个桶中的遍历顺序都为:6,7,0,1,2,3,4,5
  2. 如果map正在扩容中,且是两倍扩容方式,如果当前遍历的新桶对应的老桶还没迁移,只去遍历老桶中将来会迁移到该新桶中的元素

如果遍历完了,即bucket又回到了startBucket时,将key,elem设为空,返回

判断是否结束遍历

接下来的汇编代码如下:

0x0123 00291 (main.go:7)        CMPQ    ""..autotmp_4+280(SP), $0
0x012c 00300 (main.go:7)        JNE     304
0x012e 00302 (main.go:7)        JMP     390

即比较SP+280位置上得值是否为0,根据刚才的分析可知,SP+280位置代表hiter变量,那么同时也代表其第一个字段,也就是key

如果key为0,代表循环结束,这里会跳到390行结束循环,否则会往下继续执行

在循环中使用键值对

在for range中,用k,v两个临时遍历代表键,值:

for k, v := range m {
   process(k, v)
}

继续看汇编:

0x0130 00304 (main.go:7)        MOVQ    ""..autotmp_4+288(SP), AX  
0x013a 00314 (main.go:7)        MOVQ    (AX), AX
0x013d 00317 (main.go:7)        MOVQ    AX, ""..autotmp_10+56(SP)

0x0142 00322 (main.go:7)        MOVQ    ""..autotmp_4+280(SP), AX
0x014c 00332 (main.go:7)        MOVQ    (AX), AX
0x014f 00335 (main.go:7)        MOVQ    AX, "".k+32(SP)

0x0154 00340 (main.go:7)        MOVQ    ""..autotmp_10+56(SP), AX
0x015e 00350 (main.go:8)        MOVQ    "".k+32(SP), CX
0x0163 00355 (main.go:8)        MOVQ    CX, (SP)
0x0167 00359 (main.go:8)        MOVQ    AX, 8(SP)
0x016c 00364 (main.go:8)        CALL    "".process(SB)

在for range循环中使用kv,就是将元素从hiter中拷贝出来,传给process方法使用

  • 1
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值