原创分享 从 Go 汇编角度解释 for 循环的两个疑点

nobodynoname1943 · 2020年02月19日 · 最后由 astaxie 回复于 2020年02月19日 · 758 次阅读
本帖已被设为精华帖!

Go 常用的遍历方式有两种:for 和 for-range。实际上,for-range 也只是 for 的语法糖,本文试图从汇编代码入手解释 for 循环是如何工作的。

问题

首先来看看几个令人迷惑的地方。

问题 1:遍历过程中取值

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

上面这段代码里,会打印出什么?

问题 2:遍历过程中修改

arr := []int{1, 2, 3, 4, 5}
for v := range arr {
    arr = append(arr, v)
}

上面这段代码里,遍历前后arr有哪些变化?

窥探虚实

对于问题 1,我们期待会打印出 5 个不同的地址,实际上最终打印出来的都是同一个地址,我们可以猜测v在循环过程中只声明了一次。看看问题 1 的汇编代码:

0x0028 00040 (main.go:4)        MOVQ    ""..stmp_0(SB), AX
0x002f 00047 (main.go:4)        MOVQ    AX, "".arr+24(SP)
0x0034 00052 (main.go:4)        MOVUPS  ""..stmp_0+8(SB), X0
0x003b 00059 (main.go:4)        MOVUPS  X0, "".arr+32(SP)
0x0040 00064 (main.go:4)        MOVUPS  ""..stmp_0+24(SB), X0
0x0047 00071 (main.go:4)        MOVUPS  X0, "".arr+48(SP)
0x004c 00076 (main.go:5)        MOVQ    "".arr+24(SP), AX
0x0051 00081 (main.go:5)        MOVQ    AX, ""..autotmp_2+64(SP)
0x0056 00086 (main.go:5)        MOVUPS  "".arr+32(SP), X0
0x005b 00091 (main.go:5)        MOVUPS  X0, ""..autotmp_2+72(SP)
0x0060 00096 (main.go:5)        MOVUPS  "".arr+48(SP), X0
0x0065 00101 (main.go:5)        MOVUPS  X0, ""..autotmp_2+88(SP)
0x006a 00106 (main.go:5)        XORL    AX, AX
0x006c 00108 (main.go:5)        JMP     162
0x006e 00110 (main.go:5)        MOVQ    AX, ""..autotmp_7+16(SP)
0x0073 00115 (main.go:5)        MOVQ    ""..autotmp_2+64(SP)(AX*8), CX
0x0078 00120 (main.go:5)        MOVQ    CX, "".v+8(SP)
0x007d 00125 (main.go:6)        CALL    runtime.printlock(SB)
0x0082 00130 (main.go:6)        LEAQ    "".v+8(SP), AX
0x0087 00135 (main.go:6)        MOVQ    AX, (SP)
0x008b 00139 (main.go:6)        CALL    runtime.printpointer(SB)
0x0090 00144 (main.go:6)        CALL    runtime.printnl(SB)
0x0095 00149 (main.go:6)        CALL    runtime.printunlock(SB)
0x009a 00154 (main.go:5)        MOVQ    ""..autotmp_7+16(SP), AX
0x009f 00159 (main.go:5)        INCQ    AX
0x00a2 00162 (main.go:5)        CMPQ    AX, $5
0x00a6 00166 (main.go:5)        JLT     110

00040 行:MOVQ ""..stmp_0(SB), AXstmp_0变量里的内容放到 AX 寄存器里,stmp_0实际上就是arr数组,在生成的汇编代码里:

""..stmp_0 SRODATA size=40
        0x0000 01 00 00 00 00 00 00 00 02 00 00 00 00 00 00 00
        0x0010 03 00 00 00 00 00 00 00 04 00 00 00 00 00 00 00
        0x0020 05 00 00 00 00 00 00 00

由此可以看到 stmp_0 正是arr数组。

00106 行:XORL AX AX是初始化 AX 寄存器,AX 寄存器里包含当前循环位置。
00108 行:JMP 162表示跳转到 00162 行。
00162 行:CMPQ AX $5比较寄存器 AX 和 5,伪代码:i < 5,如果满足条件,则跳转到 00110 行。

00110 行 00159 行为循环体代码,注意到 00159 行INCQ AX, 意即 AX 寄存器值自增,到这里我们可以大致分析出来 for-range 在汇编层面的伪代码:

for i := 0; i < 5; i++ {
} 

这也就验证了上面说的 for-range 只是普通 for 的语法糖。

00110 到 00120 行是循环体代码的前半部分。从 Go 汇编文档上看:SP 寄存器指向当前栈帧的局部变量的开始位置,也就是说局部变量放在了 SP 寄存器的栈帧里。

00115 行:MOVQ ""..autotmp_2+64(SP)(AX*8), CX,autotmp_*是为临时变量自动生成的名字,这行汇编做的事情是将某个v值(注意,是值)放在 CX 寄存器里。

00120 行:MOVQ CX, "".v+8(SP)将 CX 寄存器里的内容放在 SP 寄存器指向的位置,00125 行代码是一个隔断,00125 之后的代码与println有关。重点在这行代码,每次循环都会将值放在"".v+8(SP)这个位置,在这个循环体代码里,我们并没有看到其他的临时变量声明,到这里,我们可以总结出:"".v+8(SP)这个位置就是变量v在栈帧中的位置,由于位置一直没有发生变化,在进行&v操作时取到的会是同一个地址。

对于问题 1,根据汇编代码的分析,我们得出结论:v在循环过程中只会声明一次,每次循环只是将 v 值替换,并未重新声明临时变量,这样解释了问题 1 代码的输出结果。

再回到问题 2,我们期待循环永远不会停下来,但实际上循环 5 次之后停了下来。我们有理由猜测:循环体中的arrarr = append(arr, v)中的并非同一个。

由于两段代码的汇编代码差不多,这里仍以上面的汇编代码来分析。00106 行是初始 AX 寄存器,也是循环的开始,所以我们关注 00106 行之前的代码。

根据上面的分析,在 00040 行已经将数组内容放到了 AX 寄存器里,00081 行到 00101 行,将数组拷贝到 autotmp_2 变量内,由 SP 所指向的栈顶。

在读这段代码的汇编时,发现编译器针对数组内容做了一个小优化,当数组长度小于 5 时候,编译器会认为这个数组只是临时变量,会直接做栈上赋值,直接将数组内容放到 autotmp_2 变量中(栈上),省略了从数据只读区到 AX 的过程(即 00040 行),数组长度小于 5 时,汇编代码如下:

0x0024 00036 (main.go:5)    MOVQ    $1, ""..autotmp_2+24(SP)
0x002d 00045 (main.go:5)    MOVQ    $2, ""..autotmp_2+32(SP)
0x0036 00054 (main.go:5)    MOVQ    $3, ""..autotmp_2+40(SP)
0x003f 00063 (main.go:5)    XORL    AX, AX

分析到这里,我们可以得到一段表示 for 循环的伪代码:

temp := {1, 2, 3, 4, 5}
for i := 0; i < 5; i++ {
    v := temp[i]
}

由此我们可以得到结论:for-range 时拷贝了被访问的列表(array、slice、hashmap 等)。问题 2 所带的思考:当数组比较大时,for-range 拷贝数组的开销也会比较大,在实际应用中应当避免这个开销。

总结

从上面的汇编代码分析过来看,总结两点:

  1. 循环过程中位置变量,只会声明一次,也就是说每次循环位置变量的地址都是相同的。
  2. for-range 时拷贝了被访问的列表(array、slice、hashmap 等)。

延申阅读

更多原创文章干货分享,请关注公众号
  • 加微信实战群请加微信(注明:实战群):gocnio
astaxie 将本帖设为了精华贴 02月19日 22:01

赞,深入浅出系列,对我们后面写出高性能代码、优化很有参考价值

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册