[golang] for 和 range

578 阅读3分钟

Go 语言 for 循环对应的汇编代码。

package main

func main() {
    for i := 0; i < 10; i++ {
        println(i)
    }
}

"".main STEXT size=98 args=0x0 locals=0x18
    00000 (main.go:3)	TEXT	"".main(SB), $24-0
    ...
    00029 (main.go:3)	XORL	AX, AX                   ;; i := 0
    00031 (main.go:4)	JMP	75
    00033 (main.go:4)	MOVQ	AX, "".i+8(SP)
    00038 (main.go:5)	CALL	runtime.printlock(SB)
    00043 (main.go:5)	MOVQ	"".i+8(SP), AX
    00048 (main.go:5)	MOVQ	AX, (SP)
    00052 (main.go:5)	CALL	runtime.printint(SB)
    00057 (main.go:5)	CALL	runtime.printnl(SB)
    00062 (main.go:5)	CALL	runtime.printunlock(SB)
    00067 (main.go:4)	MOVQ	"".i+8(SP), AX
    00072 (main.go:4)	INCQ	AX                       ;; i++
    00075 (main.go:4)	CMPQ	AX, $10                  ;; 比较变量 i 和 10
    00079 (main.go:4)	JLT	33                       ;; 跳转到 33 行如果 i < 10
    ...

将上述汇编指令的执行过程分成三个部分进行分析:

  1. 0029 ~ 0031 行负责循环的初始化;
    1. 对寄存器 AX 中的变量 i 进行初始化并执行 JMP 75 指令跳转到 0075 行;
  2. 0075 ~ 0079 行负责检查循环的终止条件,将寄存器中存储的数据 i 与 10 比较;
    1. JLT 33 命令会在变量的值小于 10 时跳转到 0033 行执行循环主体;
    2. JLT 33 命令会在变量的值大于 10 时跳出循环体执行下面的代码;
  3. 0033 ~ 0072 行是循环内部的语句;
    1. 通过多个汇编指令打印变量中的内容;
    2. INCQ AX 指令会将变量加一,然后再与 10 进行比较,回到第二步; 经过优化的 for-range 循环的汇编代码有着相同的结构。无论是变量的初始化、循环体的执行还是最后的条件判断都是完全一样的,所以这里也就不展开分析对应的汇编指令了。
package main

func main() {
    arr := []int{1, 2, 3}
    for i, _ := range arr {
        println(i)
    }
}

使用 for-range 的控制结构最终也会被 Go 语言编译器转换成普通的 for 循环。

现象

循环永动机

func main() {
    arr := []int{1, 2, 3}
    for _, v := range arr {
        arr = append(arr, v)
    }
    fmt.Println(arr)
}
$ go run main.go
1 2 3 1 2 3

上述代码的输出意味着循环只遍历了原始切片中的三个元素,在遍历切片时追加的元素不会增加循环的执行次数,所以循环最终还是停了下来。

神奇的指针

func main() {
    arr := []int{1, 2, 3}
    newArr := []*int{}
    for _, v := range arr {
        // 正确的做法应该是使用 `&arr[i]` 替代 `&v`
        newArr = append(newArr, &v)
    }
    for _, v := range newArr {
        fmt.Println(*v)
    }
}
$ go run main.go
3 3 3

遍历清空数组

func main() {
    arr := []int{1, 2, 3}
    for i, _ := range arr {
        arr[i] = 0
    }
}

依次遍历数组、切片和哈希看起来是非常耗费性能的,因为其占用的内存空间都是连续的,所以最快的方法是直接清空这片内存中的内容,当编译上述代码时会得到以下的汇编指令:

"".main STEXT size=93 args=0x0 locals=0x30
    0x0000 00000 (main.go:3)	TEXT	"".main(SB), $48-0
    ...
    0x001d 00029 (main.go:4)	MOVQ	"".statictmp_0(SB), AX
    0x0024 00036 (main.go:4)	MOVQ	AX, ""..autotmp_3+16(SP)
    0x0029 00041 (main.go:4)	MOVUPS	"".statictmp_0+8(SB), X0
    0x0030 00048 (main.go:4)	MOVUPS	X0, ""..autotmp_3+24(SP)
    0x0035 00053 (main.go:5)	PCDATA	$2, $1
    0x0035 00053 (main.go:5)	LEAQ	""..autotmp_3+16(SP), AX
    0x003a 00058 (main.go:5)	PCDATA	$2, $0
    0x003a 00058 (main.go:5)	MOVQ	AX, (SP)
    0x003e 00062 (main.go:5)	MOVQ	$24, 8(SP)
    0x0047 00071 (main.go:5)	CALL	runtime.memclrNoHeapPointers(SB)
    ...

从生成的汇编代码可以看出,编译器会直接使用 runtime.memclrNoHeapPointers 清空切片中的数据。

随机遍历

func main() {
    hash := map[string]int{
        "1": 1,
        "2": 2,
        "3": 3,
    }
    for k, v := range hash {
        println(k, v)
    }
}
$ go run main.go
2 2
3 3
1 1

$ go run main.go
1 1
2 2
3 3

Go 语言在运行时为哈希表的遍历引入了不确定性,也是告诉所有 Go 语言的使用者,程序不要依赖于哈希表的稳定遍历。

经典循环

Go 语言中的经典循环在编译器看来是一个 OFOR 类型的节点:

  1. 初始化循环的 Ninit
  2. 循环的继续条件 Left
  3. 循环体结束时执行的 Right
  4. 循环体 NBody
for Ninit; Left; Right {
    NBody
}

在生成 SSA 中间代码的阶段,cmd/compile/internal/gc.state.stmt 方法在发现传入的节点类型是 OFOR 时会执行以下的代码块,这段代码会将循环中的代码分成不同的块。

func (s *state) stmt(n *Node) {
    switch n.Op {
    case OFOR, OFORUNTIL:
        bCond, bBody, bIncr, bEnd := ...

        b := s.endBlock()
        b.AddEdgeTo(bCond)
        s.startBlock(bCond)
        s.condBranch(n.Left, bBody, bEnd, 1)

        s.startBlock(bBody)
        s.stmtList(n.Nbody)

        b.AddEdgeTo(bIncr)
        s.startBlock(bIncr)
        s.stmt(n.Right)
        b.AddEdgeTo(bCond)
        s.startBlock(bEnd)
    }
}

image.png

范围循环

与简单的经典循环相比,范围循环在 Go 语言中更常见、实现也更复杂。这种循环同时使用 for 和 range 两个关键字,编译器会在编译期间将所有 for-range 循环变成经典循环。从编译器的视角来看,就是将 ORANGE 类型的节点转换成 OFOR 节点。
image.png
节点类型的转换过程都发生在中间代码生成阶段,所有的 for-range 循环都会被 cmd/compile/internal/gc.walkrange 转换成不包含复杂结构、只包含基本表达式的语句。接下来,按照循环遍历的元素类型依次介绍遍历数组和切片、哈希表、字符串以及管道时的过程。

数组和切片

对于数组和切片来说,Go 语言有三种不同的遍历方式,这三种不同的遍历方式分别对应着代码中的不同条件,它们会在 cmd/compile/internal/gc.walkrange 函数中转换成不同的控制逻辑。

func walkrange(n *Node) *Node {
    switch t.Etype {
    case TARRAY, TSLICE:
        if arrayClear(n, v1, v2, a) {
            return n
        }

cmd/compile/internal/gc.arrayClear 会优化 Go 语言遍历数组或者切片并删除全部元素的逻辑。

// 原代码
for i := range a {
    a[i] = zero
}

// 优化后
if len(a) != 0 {
    hp = &a[0]
    hn = len(a)*sizeof(elem(a))
    memclrNoHeapPointers(hp, hn)
    i = len(a) - 1
}

相比于依次清除数组或者切片中的数据,Go 语言会直接使用 runtime.memclrNoHeapPointers 或者 runtime.memclrHasPointers 清除目标数组内存空间中的全部数据,并在执行完成后更新遍历数组的索引(印证了在遍历清空数组一节中观察到的现象)。
处理了这种特殊的情况之后,回到 ORANGE 节点的处理过程。这里会设置 for 循环的 LeftRight 字段,也就是终止条件和循环体每次执行结束后运行的代码。

        ha := a

        hv1 := temp(types.Types[TINT])
        hn := temp(types.Types[TINT])

        init = append(init, nod(OAS, hv1, nil))
        init = append(init, nod(OAS, hn, nod(OLEN, ha, nil)))

        n.Left = nod(OLT, hv1, hn)
        n.Right = nod(OAS, hv1, nod(OADD, hv1, nodintconst(1)))

        if v1 == nil {
            break
        }

如果循环是 for range a {},那么就满足了上述代码中的条件 v1 == nil,即循环不关心数组的索引和数据,这种循环会被编译器转换成如下形式。

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
for ; hv1 < hn; hv1++ {
    ...
}

这是 ORANGE 结构在编译期间被转换的最简单形式,由于原代码不需要获取数组的索引和元素,只需要使用数组或者切片的数量执行对应次数的循环,所以会生成一个最简单的 for 循环。
如果在遍历数组时需要使用索引 for i := range a {},那么编译器会继续会执行下面的代码。

        if v2 == nil {
            body = []*Node{nod(OAS, v1, hv1)}
            break
        }

v2 == nil 意味着调用方不关心数组的元素,只关心遍历数组使用的索引。它会将 for i := range a {} 转换成下面的逻辑,与第一种循环相比,这种循环在循环体中添加了 v1 := hv1 语句,传递遍历数组时的索引。

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
for ; hv1 < hn; hv1++ {
    v1 = hv1
    ...
}

同时去遍历索引和元素也很常见。处理这种情况会使用下面这段的代码。

        tmp := nod(OINDEX, ha, hv1)
        tmp.SetBounded(true)
        a := nod(OAS2, nil, nil)
        a.List.Set2(v1, v2)
        a.Rlist.Set2(hv1, tmp)
        body = []*Node{a}
    }
    n.Ninit.Append(init...)
    n.Nbody.Prepend(body...)

    return n
}

这段代码处理的使用者同时关心索引和切片的情况。它不仅会在循环体中插入更新索引的语句,还会插入赋值操作让循环体内部的代码能够访问数组中的元素。

ha := a
hv1 := 0
hn := len(ha)
v1 := hv1
v2 := nil
for ; hv1 < hn; hv1++ {
    tmp := ha[hv1]
    v1, v2 = hv1, tmp
    ...
}

对于所有的 range 循环,Go 语言都会在编译期将原切片或者数组赋值给一个新变量 ha,在赋值的过程中就发生了拷贝,而又通过 len 关键字预先获取了切片的长度,所以在循环中追加新的元素也不会改变循环执行的次数,这也就解释了循环永动机一节提到的现象。
而遇到这种同时遍历索引和元素的 range 循环时,Go 语言会额外创建一个新的 v2 变量存储切片中的元素,循环中使用的这个变量 v2 会在每一次迭代被重新赋值而覆盖,赋值时也会触发拷贝。

func main() {
    arr := []int{1, 2, 3}
    newArr := []*int{}
    for i, _ := range arr {
        newArr = append(newArr, &arr[i])
    }
    for _, v := range newArr {
        fmt.Println(*v)
    }
}

因为在循环中获取返回变量的地址都完全相同,所以会发生神奇的指针一节中的现象。因此当想要访问数组中元素所在的地址时,不应该直接获取 range 返回的变量地址 &v2,而应该使用 &a[index] 这种形式。

哈希表

在遍历哈希表时,编译器会使用 runtime.mapiterinitruntime.mapiternext 两个运行时函数重写原始的 for-range 循环。

ha := a
hit := hiter(n.Type)
th := hit.Type
mapiterinit(typename(t), ha, &hit)
for ; hit.key != nil; mapiternext(&hit) {
    key := *hit.key
    val := *hit.val
}

上述代码是展开 for key, val := range hash {} 后的结果,在 cmd/compile/internal/gc.walkrange 处理 TMAP 节点时,编译器会根据 range 返回值的数量在循环体中插入需要的赋值语句。
image.png
这三种不同的情况分别向循环体插入了不同的赋值语句,遍历哈希表时会使用 runtime.mapiterinit 函数初始化遍历开始的元素。

func mapiterinit(t *maptype, h *hmap, it *hiter) {
    it.t = t
    it.h = h
    it.B = h.B
    it.buckets = h.buckets

    r := uintptr(fastrand())
    it.startBucket = r & bucketMask(h.B)
    it.offset = uint8(r >> h.B & (bucketCnt - 1))
    it.bucket = it.startBucket
    mapiternext(it)
}

该函数会初始化 runtime.hiter 结构体中的字段,并通过 runtime.fastrand 生成一个随机数帮助随机选择一个遍历桶的起始位置。
遍历哈希会使用 runtime.mapiternext(这里简化了很多逻辑,省去了一些边界条件以及哈希表扩容时的兼容操作,只需要关注处理遍历逻辑的核心代码),会将该函数分成桶的选择和桶内元素的遍历两部分,首先是桶的选择过程。

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

next:
    if b == nil {
        if bucket == it.startBucket && it.wrapped {
            it.key = nil
            it.value = nil
            return
        }
        b = (*bmap)(add(it.buckets, bucket*uintptr(t.bucketsize)))
        bucket++
        if bucket == bucketShift(it.B) {
            bucket = 0
            it.wrapped = true
        }
        i = 0
    }

这段代码主要有两个作用:

  1. 在待遍历的桶为空时,选择需要遍历的新桶;
  2. 在不存在待遍历的桶时。返回 (nil, nil) 键值对并中止遍历; runtime.mapiternext 剩余代码的作用是从桶中找到下一个遍历的元素,在大多数情况下都会直接操作内存获取目标键值的内存地址,不过如果哈希表处于扩容期间就会调用 runtime.mapaccessK 获取键值对。
    for ; i < bucketCnt; i++ {
        offi := (i + it.offset) & (bucketCnt - 1)
        k := add(unsafe.Pointer(b), dataOffset+uintptr(offi)*uintptr(t.keysize))
        v := add(unsafe.Pointer(b), dataOffset+bucketCnt*uintptr(t.keysize)+uintptr(offi)*uintptr(t.valuesize))
        if (b.tophash[offi] != evacuatedX && b.tophash[offi] != evacuatedY) ||
            !(t.reflexivekey() || alg.equal(k, k)) {
            it.key = k
            it.value = v
        } else {
            rk, rv := mapaccessK(t, h, k)
            it.key = rk
            it.value = rv
        }
        it.bucket = bucket
        it.i = i + 1
        return
    }
    b = b.overflow(t)
    i = 0
    goto next
}

当上述函数已经遍历了正常桶后,会通过 runtime.bmap.overflow 遍历哈希中的溢出桶。
image.png
简单总结一下哈希表遍历的顺序,首先会选出一个绿色的正常桶开始遍历,随后遍历所有黄色的溢出桶,最后依次按照索引顺序遍历哈希表中其他的桶,直到所有的桶都被遍历完成。

字符串

字符串是一个只读的字节数组切片,所以范围循环在编译期间生成的框架与切片非常类似,只是细节有一些不同。使用下标访问字符串中的元素时得到的就是字节,但是这段代码会将当前的字节转换成 rune 类型。如果当前的 rune 是 ASCII 的,那么只会占用一个字节长度,每次循环体运行之后只需要将索引加一,但是如果当前 rune 占用了多个字节就会使用 runtime.decoderune 函数解码。
for i, r := range s {} 的结构都会被转换成如下所示的形式。

ha := s
for hv1 := 0; hv1 < len(ha); {
    hv1t := hv1
    hv2 := rune(ha[hv1])
    if hv2 < utf8.RuneSelf {
        hv1++
    } else {
        hv2, hv1 = decoderune(ha, hv1)
    }
    v1, v2 = hv1t, hv2
}

通道

使用 range 遍历 Channel 也是比较常见的做法,一个形如 for v := range ch {} 的语句最终会被转换成如下的格式。

ha := a
hv1, hb := <-ha
for ; hb != false; hv1, hb = <-ha {
    v1 := hv1
    hv1 = nil
    ...
}

上述代码可能与编译器生成的稍微有一些出入,但是结构和效果是完全相同的。该循环会使用 <-ch 从管道中取出等待处理的值,这个操作会调用 runtime.chanrecv2 并阻塞当前的协程,当 runtime.chanrecv2 返回时会根据布尔值 hb 判断当前的值是否存在:

  • 如果不存在当前值,意味着当前的管道已经被关闭;
  • 如果存在当前值,会为 v1 赋值并清除 hv1 变量中的数据,然后重新陷入阻塞等待新数据;

参阅