2023-09-21时, IThome有篇文章Go 1.22将正式修改for迴圈作用域,减少开发者犯错机会
这时刚最近公司专案, 有同事也不小心踩到这地雷,乾脆整理一下。
好读版放在Hashnode这blog干话王上
先来一段程式
func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { /* 在每次loop中,此程式码会启动一个Goroutine来执行匿名函数。 该函数将当前的值v输出出来,然后将true发送到done通道。 */ go func() { fmt.Println(v) done <- true }() } // wait for all goroutines to complete before exiting for _ = range values { <-done }}
直觉上应该会输出a,b,c
。
但因为Gorouting可能在loop的下一次迭代之后才执行,所以v的值可能已经改变。因此,所有Goroutines可能都会输出相同的值c
,而不是按顺序输出每个值。
再者是因为Go的for loop那个v, 其实是共用变数, 所以记忆体位置也是一样的, 让我们验证看看。
func main() {done := make(chan bool)values := []string{"a", "b", "c"}for _, v := range values {go func() {fmt.Printf("value=%s, addr=%p\n", v, &v)done <- true}()}// wait for all goroutines to complete before exitingfor _ = range values {<-done}}/*value=c, addr=0xc000014070value=c, addr=0xc000014070value=c, addr=0xc000014070*/
解法有几种,
第一种, 进到迭代时, 就把值给複製一份。
func main() {done := make(chan bool)values := []string{"a", "b", "c"}for _, v := range values {v1 := vgo func() {fmt.Println(v1)done <- true}()}// wait for all goroutines to complete before exitingfor _ = range values {<-done}}
第二种, 进到closure时, 就把值给传入closure做一份参数的複製。
func main() {done := make(chan bool)values := []string{"a", "b", "c"}for _, v := range values {go func(v string) {fmt.Println(v)done <- true}(v)}// wait for all goroutines to complete before exitingfor _ = range values {<-done}}
面试蛮常问的题目XD
但其实Go有提供tool来做静态程式码扫描, go vet
go tool vet提供这么多面向的check, 其中loopclosure check references to loop variables from within nested functions
就是能扫描出上述的议题。
To list the available checks, run "go tool vet help":* asmdecl report mismatches between assembly files and Go declarations* assign check for useless assignments* atomic check for common mistakes using the sync/atomic package* bools check for common mistakes involving boolean operators* buildtag check that +build tags are well-formed and correctly located* cgocall detect some violations of the cgo pointer passing rules* composites check for unkeyed composite literals* copylocks check for locks erroneously passed by value* httpresponse check for mistakes using HTTP responses* loopclosure check references to loop variables from within nested functions* lostcancel check cancel func returned by context.WithCancel is called* nilfunc check for useless comparisons between functions and nil* printf check consistency of Printf format strings and arguments* shift check for shifts that equal or exceed the width of the integer* slog check for incorrect arguments to log/slog functions* stdmethods check signature of methods of well-known interfaces* structtag check that struct field tags conform to reflect.StructTag.Get* tests check for common mistaken usages of tests and examples* unmarshal report passing non-pointer or non-interface values to unmarshal* unreachable check for unreachable code* unsafeptr check for invalid conversions of uintptr to unsafe.Pointer* unusedresult check for unused results of calls to some functions
能透过go tool bet help
能看看使用方法。
让我来透过go tool vet
来扫描看看
go vet main.go或者go vet -loopclosure main.go/*# command-line-arguments./main.go:11:38: loop variable v captured by func literal./main.go:11:42: loop variable v captured by func literal*/
看到loop variable v captured by func literal
就是告诉你第11行的v这个loop共享变数,正在被closure function给使用。
当我们改成上面的几个写法后,再执行go vet
就不会看到上述警告了。
好,上面的写法还算好察觉到。
接着来个很不容易察觉到的写法,也会踩到这地雷。
参考
package mainimport "fmt"func main() {var out []*intfor i := 0; i < 3; i++ {out = append(out, &i)}fmt.Println("Values:", *out[0], *out[1], *out[2])fmt.Println("Addresses:", out[0], out[1], out[2])}/*Values: 3 3 3Addresses: 0xc0000120e8 0xc0000120e8 0xc0000120e*/
咦, 这次没closure function了还会这样?
试试看go vet!go vet main.go
啥都没跑出来, 咦?
明明结果不如预期,go vet却觉得没问题! (拉G go vet呸?)
其实这里for loop每次迭代的i
,也都是指向同一个共享变数;
然后我们还做死, 取址&i, 新增进去out这ptr slice中。
i在最后一次迭代时真正指向的值是3, 只是地址都是同一个, 所以最后输出才都会是3
来看看publicly documented issue at Lets Encrypt
内讨论的一段程式码
// authz2ModelMapToPB converts a mapping of domain name to authz2Models into a// protobuf authorizations mapfunc authz2ModelMapToPB(m map[string]authz2Model) (*sapb.Authorizations, error) { resp := &sapb.Authorizations{} for k, v := range m { // Make a copy of k because it will be reassigned with each loop. kCopy := k authzPB, err := modelToAuthzPB(&v) if err != nil { return nil, err } resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{ Domain: &kCopy, Authz: authzPB, }) } return resp, nil}
ln7做了一行k的copy, 因为如果他不在这里做copy, 此时的k其实也是共享变数, 在ln13的地方用&k的话会发生上面不如预期的错误。
Go1.22终于要面对这常见的陷阱,Fixing For Loops in Go 1.22
但现在1.22
还没正式release阿?
不怕文章内有说1.21
有提供这新功能的Preview
只要加上GOEXPERIMENT=loopvar
GOEXPERIMENT=loopvar go run main.go/*Values: 0 1 2Addresses: 0xc0000120e8 0xc000012110 0xc000012118*/或者跑单元测试时GOEXPERIMENT=loopvar go test
完美!
总结一下,
在Go1.21之前的版本, 只要for loop有对迭代变数取址, 或者for+closure时, code review能相互提醒。
在CI或透过husky这git hook利用go vet
扫出这些低级错误先。
但Go 1.22我觉得是很值得升级的版本,因为这地雷真的太常踩到了。
参考资料:
Fixing For Loops in Go 1.22
What happens with closures running as goroutines?
Let's Encrypt: CAA Rechecking bug
CommonMistakes