Go 團(tuán)隊(duì)將修改 for 循環(huán)變量的語義,Go1.21 新版本即可體驗(yàn)!
大家好,我是煎魚。
之前有提到 Go for 循環(huán)變量的問題,許多面試題和泄露與此有關(guān)。
Russ Cox(下稱:rsc)甚至一度表示他一直在研究這個(gè)問題,認(rèn)為當(dāng)前語義的代價(jià)是很大的,想看看能不能進(jìn)行變更。
經(jīng)過 Go1 向前兼容性和向后兼容性提案的鋪墊,循環(huán)變量的這個(gè)問題將得到解決。在 Go1.21 可以進(jìn)行嘗試使用,預(yù)計(jì) Go1.22 開始正式變更。
回顧問題現(xiàn)象
第一個(gè)例子
在 Go 語言中,我們寫 for 語句時(shí)有時(shí)會(huì)出現(xiàn)運(yùn)行和猜想的結(jié)果不一致。例如以下第一個(gè)案例的代碼:
var all []*Item
for _, item := range items {
all = append(all, &item)
}
這段代碼有問題嗎?變量 all 內(nèi)的 item 變量,存儲(chǔ)進(jìn)去的是什么?是每次循環(huán)的 item 值,每次都不一樣,對(duì)嗎?
實(shí)際上在 for 循環(huán)時(shí),每次存入變量 all 的都是相同的 item,也就是最后一個(gè)循環(huán)的 item 值。這是 Go 面試?yán)锝?jīng)常出現(xiàn)的題目,結(jié)合 goroutine 更風(fēng)騷,畢竟還會(huì)存在亂序執(zhí)行等問題。
如果你想解決這個(gè)問題,就需要把程序改寫成如下:
var all []*Item
for _, item := range items {
item := item
all = append(all, &item)
}
要重新聲明一個(gè)局部變量 item 變量,把 for 循環(huán)的 item 變量給存儲(chǔ)下來,再追加進(jìn)去。
第二個(gè)例子
接下來是第二個(gè)案例的代碼:
var prints []func()
for _, v := range []int{1, 2, 3} {
prints = append(prints, func() { fmt.Println(v) })
}
for _, print := range prints {
print()
}
這段程序的輸出結(jié)果是什么?沒有 & 取地址符,是輸出 1,2,3 嗎?
結(jié)果程序一運(yùn)行,輸出結(jié)果是 3,3,3。這又是為什么?
問題的重點(diǎn)之一:關(guān)注到閉包函數(shù),實(shí)際上所有閉包都打印的是相同的 v,也就是輸出 3,原因是在 for 循環(huán)結(jié)束后,最后 v 的值被設(shè)置為了 3,僅此而已。
如果想要達(dá)到預(yù)期的效果,依然是使用萬能的再賦值。改寫后的代碼如下:
for _, v := range []int{1, 2, 3} {
v := v
prints = append(prints, func() { fmt.Println(v) })
}
增加 v := v 語句,程序輸出結(jié)果為 1,2,3。仔細(xì)翻翻你寫過的 Go 工程,是不是都很熟悉?就這改造方法,贏了。
尤其是配合上 Goroutine 的寫法,很多同學(xué)會(huì)更容易在此翻車。
解決方案
GOEXPERIMENT=loopvar
在 Go1.21 的新版本起,我們可以開啟 GOEXPERIMENT=loopvar 來構(gòu)建 Go 程序,來體驗(yàn)上面提到的 for 循環(huán)變量的問題。
構(gòu)建命令:
GOEXPERIMENT=loopvar go install my/program
GOEXPERIMENT=loopvar go build my/program
GOEXPERIMENT=loopvar go test my/program
GOEXPERIMENT=loopvar go test my/program -bench=.
...
預(yù)計(jì)在 Go1.22 起,新的 for 循環(huán)語義,將會(huì)在 go.mod 文件中的 go 行(版本聲明)大于等于 Go1.22 下默認(rèn)應(yīng)用。
我們對(duì)應(yīng)到上述的第二個(gè)例子,程序的運(yùn)行結(jié)果將發(fā)生如下改變:
$ go run demo.go
3
3
3
$ GOEXPERIMENT=loopvar gotip run demo.go
1
2
3
以后就不再需要寫 v := v 語句了。
模塊版本控制開關(guān)
go.mod 方面,具體可以參照以下案例:
圖片
像上圖的配置,Go 1.30 或更高版本將會(huì)每次迭代變量(也就是新的 for 循環(huán)語義),而早期 Go 版本的將每次循環(huán)變量,也就是 go.mod 的 Go 版本控制了新特性的語義,不同 modules 都可能會(huì)因此不一樣。
如此一來上述提到的 for 循環(huán)問題都會(huì)在一定范圍(版本)內(nèi)被解決。
查看影響范圍
可以在命令行執(zhí)行以下指令進(jìn)行構(gòu)建:
$ go build -gcflags=all=-d=loopvar=2 cmd/go
...
modload/import.go:676:7: loop variable d now per-iteration, stack-allocated
modload/query.go:742:10: loop variable r now per-iteration, heap-allocated
我們就可以看到對(duì)應(yīng)的文件、行數(shù)、變量。知道目前對(duì)應(yīng)的是迭代還是循環(huán),變量分配在哪里。不用靠再翻版本號(hào)再看再猜。
實(shí)際應(yīng)用實(shí)驗(yàn)
在 2023 年 5 月初起,Google 一直在內(nèi)部使用 for 循環(huán)的新語義。截止目前為止,沒有報(bào)告任何新問題。
另外還在 Kubernetes 中嘗試了新的 Go1.21 版本和新的 for 循環(huán)語義測(cè)試:
圖片
將 Kubernetes 從 Go 1.20 更新到 Go 1.21 時(shí),發(fā)現(xiàn)了 3 個(gè)新失敗的測(cè)試。而 for 循環(huán)變量的語義更改,則造成了 2 個(gè)新的失敗。與普通版本更新相比,Go 官方團(tuán)隊(duì)認(rèn)為并不是一個(gè)重大的新負(fù)擔(dān)。
綜合認(rèn)為這不是一個(gè)大變動(dòng),且影響面可以控制。所以可變!
總結(jié)
在本次 Go 新版本更新中,Go 官方核心團(tuán)隊(duì)終于解決了這個(gè)十年之痛的問題。前面鋪墊了真的是非常久了,這么多年,為了兼容性還出臺(tái)了幾個(gè)兼容性提案。真的是用心良苦!
大家要關(guān)注一下自己的應(yīng)用程序,可以在 Go1.21 提前把開關(guān)開起來,看看是否有影響。如果沒有影響,那就是最好的了。如果有影響,那么需要注意在后續(xù)升級(jí)新版本(Go1.22 時(shí)),要控制好 go.mod 中的 Go 版本信息。
在下個(gè)版本(Go1.21/Go1.22)起,Go 代碼的 v := v 語句將會(huì)逐漸變少??赡苁莻€(gè)好事?
面試官們也請(qǐng)記得修改一下你的題庫了。