Common mistakes with for loops in Go

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


关于作者: 网站小编

码农网专注IT技术教程资源分享平台,学习资源下载网站,58码农网包含计算机技术、网站程序源码下载、编程技术论坛、互联网资源下载等产品服务,提供原创、优质、完整内容的专业码农交流分享平台。

热门文章