内部使用Go:在循环中包装循环变量


今天,我决定为您翻译一篇关于所谓的闭包或闭包实现内部的简短文章。 此外,您还将学习Go在不同情况下如何尝试自动确定是使用指针/链接还是值。 了解这些事情可以避免错误。 我认为,所有这些内幕都非常有趣!


我也想邀请您参加将于10月7日在莫斯科举行的Golang Conf 2019 我是会议计划委员会的成员,我和我的同事们选择了许多同样具有说服力且非常有趣的报告。 我爱什么!


在削减下,我把这个词传给了作者。



Go Wiki上有一个页面,标题为“ 频繁错误” 。 奇怪的是,只有一个示例:将循环变量与goroutines滥用:


for _, val := range values { go func() { fmt.Println(val) }() } 

此代码将从len(值)次的值数组中输出最后一个值。 修改代码非常简单:


 // assume the type of each value is string for _, val := range values { go func(val string) { fmt.Println(val) }(val) } 

这个例子足以理解问题,而不会再犯错误。 但是,如果您想了解实现细节,本文将使您对问题和解决方案都有深刻的了解。


基本内容:通过价值传递和通过引用传递


在Go中,按值和按引用[1]传递对象有所不同。 让我们从示例1 [2]开始:


 func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go foobyval(i) } time.Sleep(100 * time.Millisecond) } 

几乎没有人会怀疑结果将显示为0到4之间的值。可能是以某种随机顺序显示的。


让我们来看示例2


 func foobyref(n *int) { fmt.Println(*n) } func main() { for i := 0; i < 5; i++ { go foobyref(&i) } time.Sleep(100 * time.Millisecond) } 

结果,将显示以下内容:


5
5
5
5
5


了解结果的确切原因将使我们对问题的本质有80%的理解。 因此,让我们花一些时间来查找原因。


答案就在Go语言规范中 。 规范内容如下:


初始化语句中声明的变量将在每个循环中重用。

这意味着在程序运行时,变量i只有一个对象或一块内存,而每个周期都不会创建一个新对象。 该对象在每次迭代时取一个新值。


让我们看一下示例1和2中为循环生成的机器代码[3]的区别。让我们从示例1开始。


 0x0026 00038 (go-func-byval.go:14) MOVL $8, (SP) 0x002d 00045 (go-func-byval.go:14) LEAQ "".foobyval·f(SB), CX 0x0034 00052 (go-func-byval.go:14) MOVQ CX, 8(SP) 0x0039 00057 (go-func-byval.go:14) MOVQ AX, 16(SP) 0x003e 00062 (go-func-byval.go:14) CALL runtime.newproc(SB) 0x0043 00067 (go-func-byval.go:13) MOVQ "".i+24(SP), AX 0x0048 00072 (go-func-byval.go:13) INCQ AX 0x004b 00075 (go-func-byval.go:13) CMPQ AX, $5 0x004f 00079 (go-func-byval.go:13) JLT 33 

Go语句成为对runtime.newproc函数的调用。 这个过程的机制非常有趣,但是让我们将其留给下一篇文章。 现在,我们对变量i发生了什么更感兴趣。 它存储在AX寄存器中,然后按值通过堆栈传递给foobyval函数[4]作为其参数。 在这种情况下,“按值”看起来就像将AX寄存器的值复制到堆栈上。 而且将来更改AX不会影响传递给foobyval函数的内容。


这是示例2的样子:


 0x0040 00064 (go-func-byref.go:14) LEAQ "".foobyref·f(SB), CX 0x0047 00071 (go-func-byref.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-func-byref.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-func-byref.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-func-byref.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-func-byref.go:13) INCQ (AX) 0x005e 00094 (go-func-byref.go:13) CMPQ (AX), $5 0x0062 00098 (go-func-byref.go:13) JLT 57 

代码非常相似-只有一个但非常重要的区别。 现在在AX中是地址i,而不是其值。 还要注意,循环的增量和比较是在(AX)而非AX上完成的。 然后,当我们将AX放在堆栈上时,事实证明,我们将地址i传递给了函数。 更改(AX)也将以这种方式在goroutine中显示。


没什么好奇怪的 最后,我们在foobyref函数中传递一个指向数字的指针。
在运行期间,该循环结束的速度比任何已创建的goroutine开始工作的速度都要快。 当它们开始工作时,它们将具有指向相同变量i而不是副本的指针。 我此时的价值是多少? 值是5。周期停止的那个值。 这就是为什么所有goroutine都派生5的原因。


带有值的方法VS带有指针的方法


创建调用任何方法的goroutine时,可以观察到类似的行为。 这由同一Wiki页面指示。 看例子3


 type MyInt int func (mi MyInt) Show() { fmt.Println(mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) } 

本示例显示ms数组的元素。 正如我们预期的那样,以随机顺序。 一个非常相似的示例4将指针方法用于Show方法:


 type MyInt int func (mi *MyInt) Show() { fmt.Println(*mi) } func main() { ms := []MyInt{50, 60, 70, 80, 90} for _, m := range ms { go m.Show() } time.Sleep(100 * time.Millisecond) } 

尝试猜测得出的结论是:90,打印五次。 原因与更简单的示例2相同。在这里,由于使用指针方法时Go中的语法糖,该问题不太明显。 如果在示例中,当从示例1切换到示例2时,我们将i更改为&i,此处的调用看起来相同! 在两个示例中,m.Show()的行为都不同。


在我看来,这不是两个Go功能的完美结合。 呼叫处没有任何内容表示通过引用进行传输。 您将需要查看Show方法的实现,以确切了解调用将如何发生(当然,该方法可以位于完全不同的文件或包中)。


在大多数情况下,此功能很有用。 我们编写更干净的代码。 但是在这里,通过引用会导致意想不到的效果。


短路


最后,我们来谈谈闭包。 让我们看一下示例5


 func foobyval(n int) { fmt.Println(n) } func main() { for i := 0; i < 5; i++ { go func() { foobyval(i) }() } time.Sleep(100 * time.Millisecond) } 

他将打印以下内容:


5
5
5
5
5


尽管有这样一个事实,我在闭包中按值将我传递给foobyval。 与示例1类似。但是为什么呢? 让我们看一下汇编器循环视图:


 0x0040 00064 (go-closure.go:14) LEAQ "".main.func1·f(SB), CX 0x0047 00071 (go-closure.go:14) MOVQ CX, 8(SP) 0x004c 00076 (go-closure.go:14) MOVQ AX, 16(SP) 0x0051 00081 (go-closure.go:14) CALL runtime.newproc(SB) 0x0056 00086 (go-closure.go:13) MOVQ "".&i+24(SP), AX 0x005b 00091 (go-closure.go:13) INCQ (AX) 0x005e 00094 (go-closure.go:13) CMPQ (AX), $5 0x0062 00098 (go-closure.go:13) JLT 57 

该代码与示例2非常相似:请注意,i由AX寄存器中的地址表示。 也就是说,我们通过引用传递了i。 尽管事实上调用了foobyval。 循环的主体使用runtime.newproc调用该函数,但是该函数来自何处?


Func1由编译器创建,它是一个闭包。 编译器已将关闭代码分配为单独的函数,并从main调用它。 这种分配的主要问题是如何处理闭包使用的变量,但显然不是参数。


这是func1的主体外观:


 0x0000 00000 (go-closure.go:14) MOVQ (TLS), CX 0x0009 00009 (go-closure.go:14) CMPQ SP, 16(CX) 0x000d 00013 (go-closure.go:14) JLS 56 0x000f 00015 (go-closure.go:14) SUBQ $16, SP 0x0013 00019 (go-closure.go:14) MOVQ BP, 8(SP) 0x0018 00024 (go-closure.go:14) LEAQ 8(SP), BP 0x001d 00029 (go-closure.go:15) MOVQ "".&i+24(SP), AX 0x0022 00034 (go-closure.go:15) MOVQ (AX), AX 0x0025 00037 (go-closure.go:15) MOVQ AX, (SP) 0x0029 00041 (go-closure.go:15) CALL "".foobyval(SB) 0x002e 00046 (go-closure.go:16) MOVQ 8(SP), BP 0x0033 00051 (go-closure.go:16) ADDQ $16, SP 0x0037 00055 (go-closure.go:16) RET 

这里有趣的是,该函数在24(SP)中有一个参数,它是一个指向int的指针:看一下MOVQ(AX)行AX,它在将值传递给foobyval之前需要一个值。 实际上,func1看起来像这样:


 func func1(i *int) { foobyval(*i) }    main   - : for i := 0; i < 5; i++ { go func1(&i) } 

收到了与示例2相同的结果,因此可以得出结论。 用技术语言,我们可以说i是闭包内部的自由变量,并且此类变量是通过Go中的引用捕获的。


但是,总是这样吗? 令人惊讶的是,答案是否定的。 在某些情况下,自由变量按值捕获。 这是我们示例的一个变体:


 for i := 0; i < 5; i++ { ii := i go func() { foobyval(ii) }() } 

本示例将以随机顺序输出0、1、2、3、4。 但是为什么这里的行为与示例5不同?


事实证明,此行为是Go编译器与闭包一起使用时启发式的产物。


我们在引擎盖下看


如果您不熟悉Go编译器的体系结构,建议您阅读有关该主题的早期文章: 第1 部分第2部分


通过解析代码获得的特定(而非抽象)语法树如下所示:


 0: *syntax.CallStmt { . Tok: go . Call: *syntax.CallExpr { . . Fun: *syntax.FuncLit { . . . Type: *syntax.FuncType { . . . . ParamList: nil . . . . ResultList: nil . . . } . . . Body: *syntax.BlockStmt { . . . . List: []syntax.Stmt (1 entries) { . . . . . 0: *syntax.ExprStmt { . . . . . . X: *syntax.CallExpr { . . . . . . . Fun: foobyval @ go-closure.go:15:4 . . . . . . . ArgList: []syntax.Expr (1 entries) { . . . . . . . . 0: i @ go-closure.go:15:13 . . . . . . . } . . . . . . . HasDots: false . . . . . . } . . . . . } . . . . } . . . . Rbrace: syntax.Pos {} . . . } . . } . . ArgList: nil . . HasDots: false . } } 

被调用的函数由FuncLit节点(一个常数函数)表示。 当该树转换为AST(抽象语法树)时,将突出显示此常量函数为单独的函数。 这发生在位于gc / closure.go中的noder.funcLit方法中。


然后,tipe检查器完成了转换,并且在AST中获得了该函数的以下表示形式:


 main.func1: . DCLFUNC l(14) tc(1) FUNC-func() . DCLFUNC-body . . CALLFUNC l(15) tc(1) . . . NAME-main.foobyval a(true) l(8) x(0) class(PFUNC) tc(1) used FUNC-func(int) . . CALLFUNC-list . . . NAME-main.il(15) x(0) class(PAUTOHEAP) tc(1) used int 

请注意,传递给foobyval的值是NAME-main.i,也就是说,我们显式指向包装该闭包的函数中的变量。


在此阶段,称为capturevars的编译器阶段(即“捕获变量”)开始运行。 其目的是决定如何捕获“封闭变量”(即,闭包中使用的自由变量)。 以下是来自相应编译器函数的注释,该注释还描述了启发式方法:


//在所有类型检查之后,在单独的阶段中调用capturevars。
//它决定是通过值还是通过引用捕获变量。
//对于小于等于128个字节的值,我们使用按值捕获,捕获后不再更改值(基本上是常量)。


在示例5中调用capturevars时,它决定应通过引用捕获循环变量i,并向其添加适当的addrtaken标志。 可以在AST输出中看到:


 FOR l(13) tc(1) . LT l(13) tc(1) bool . . NAME-main.ia(true) g(1) l(13) x(0) class(PAUTOHEAP) esc(h) tc(1) addrtaken assigned used int 

对于循环变量,“按值”选择试探法不起作用,因为该变量在调用后会更改其值(请记住规范中的引用,即循环变量在每次迭代时都可以重复使用)。 因此,变量i是通过引用捕获的。
在我们的示例的变形中,我们有ii:= i,ii不再使用,因此被值[5]捕获。


因此,我们看到了一个惊人的例子,它以一种意想不到的方式重叠了一种语言的两种不同特征。 Go不会在循环的每次迭代中使用新变量,而是重复使用相同的变量。 反过来,这导致启发式方法的触发和通过引用选择捕获,这导致了意外的结果。 Go常见问题解答表示,此行为可能是设计错误。


在设计语言时,这种行为(不使用新变量)可能是一个错误。 也许我们会在将来的版本中修复它,但是由于向后兼容,我们无法在Go版本1中做任何事情。

如果您知道问题所在,则很可能不会踩此耙。 但是请记住,自由变量始终可以通过引用捕获。 为避免错误,请确保在使用goroutin时仅捕获只读变量。 由于数据传输的潜在问题,这一点也很重要。




[1]一些读者已经注意到,严格来讲,Go中没有“通过引用传递”的概念,因为所有内容都是通过值传递的,包括指针。 在本文中,当您看到“按引用传递”时,我的意思是“按地址传递”,在某些情况下它是显式的(例如,将&n传递给期望* int的函数),在某些情况下是隐式的,如后面的情况文章的各个部分。


[2]在下文中,我使用time.Sleep作为等待所有goroutine完成的快速而肮脏的方法。 没有这个,main将在goroutine开始工作之前就结束了。 正确的方法是使用WaitGroup或done通道。


[3]本文所有示例的汇编器表示形式都是使用go工具compile -l -S命令获得的。 -l标志禁用函数内联,并使汇编代码更具可读性。


[4] Foobyval不会直接调用,因为该调用通过go。 而是将地址作为第二个参数(16(SP))传递给runtime.newproc函数,并且foobyval的参数(在本例中为i)在堆栈中向上移动。


[5]作为练习,将ii = 10添加为for循环的最后一行(在调用go之后)。 你的结论是什么? 怎么了

Source: https://habr.com/ru/post/zh-CN468863/


All Articles