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 exiting
for _ = range values {
<-done
}
}
/*
value=c, addr=0xc000014070
value=c, addr=0xc000014070
value=c, addr=0xc000014070
*/
解法有幾種,
第一種, 進到迭代時, 就把值給複製一份。
func main() {
done := make(chan bool)
values := []string{"a", "b", "c"}
for _, v := range values {
v1 := v
go func() {
fmt.Println(v1)
done <- true
}()
}
// wait for all goroutines to complete before exiting
for _ = 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 exiting
for _ = 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 main
import "fmt"
func main() {
var out []*int
for 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 3
Addresses: 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 map
func 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 2
Addresses: 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