SODA Engineering Blog
🤌

Go1.22からfor loopの挙動が変わるかも!?~Goの仕様を考える~

2023/06/15に公開

導入

Go1.21のドラフトのリリースノートを見ていると興味深い記述を見つけました。

Go 1.21 includes a preview of a language change we are considering for a future version of Go: making for loop variables per-iteration instead of per-loop, to avoid accidental sharing bugs. For details about how to try that language change, see the LoopvarExperiment wiki page.

(意訳) Go1.21は、将来のGoのバージョンに対して私たち (おそらくGoのチーム) が考慮している言語の変更のプレビューを含みます、意図しないバグを共有することを避けるために、ループごとよりイテレーションごとのループ変数を作成することです。この言語の変更に対するチャレンジの詳細については、LoopvarExperimantのwikiページを参照してください。

どうやら、Go1.21はループ変数に関する仕様の変更のプレビューが入るようです。詳しく見てみましょう。

この記事で知れること

  • ループ変数に関する既存のGoの仕様の問題
  • 将来のGoのバージョンでどう変更が入るのか

結論

  • Go1.22からfor loopの仕様の変更が入る可能性がある
  • 具体的には 「:= を使っているfor loopでは、ループ変数は異なるインスタンスとして宣言される」という変更が入る可能性がある
  • 仕様の変更によって既存のプログラムが壊れたり、パフォーマンスが落ちたりする可能性はあるが、それらを検知できる仕組みがいろいろある
    • 余分にメモリを確保するようになるが、その影響は pprof --alloc_objects を使えば検知できる
    • ビルドに対しては -gcflags=all=-d=loopvar=2 のフラグで影響のある箇所が確認できる
    • テストに対してはbisectという新しいツールで検知が可能になる

記事の内容

  • この記事はLoopvarExperimantのwikiページの一部を日本語で解説したものになります。

今の仕様

以下のようなテストコードを考えてみます。

func TestAllEvenBuggy(t *testing.T) {
	testCases := []int{1, 2, 4, 6}
	for _, v := range testCases {
		t.Run("sub", func(t *testing.T) {
			t.Parallel()
			if v&1 != 0 {
				t.Fatal("odd v", v)
			}
		})
	}
}

このテストにはバグがあります。実際に実行してみると、以下のような結果となりテストが通ります。

=== RUN   TestAllEvenBuggy
=== RUN   TestAllEvenBuggy/sub
=== PAUSE TestAllEvenBuggy/sub
=== RUN   TestAllEvenBuggy/sub#01
=== PAUSE TestAllEvenBuggy/sub#01
=== RUN   TestAllEvenBuggy/sub#02
=== PAUSE TestAllEvenBuggy/sub#02
=== RUN   TestAllEvenBuggy/sub#03
=== PAUSE TestAllEvenBuggy/sub#03
=== CONT  TestAllEvenBuggy/sub
=== CONT  TestAllEvenBuggy/sub#03
=== CONT  TestAllEvenBuggy/sub#02
=== CONT  TestAllEvenBuggy/sub#01
--- PASS: TestAllEvenBuggy (0.00s)
    --- PASS: TestAllEvenBuggy/sub (0.00s)
    --- PASS: TestAllEvenBuggy/sub#03 (0.00s)
    --- PASS: TestAllEvenBuggy/sub#02 (0.00s)
    --- PASS: TestAllEvenBuggy/sub#01 (0.00s)
PASS

Program exited.

なぜでしょうか?

t.Parallel() を呼んでいるので、 TestAllEvenBuggy がreturnするときにサブテストが実行されます。しかし、TestAllEvenBuggy がreturnする時にはloopは終了しており、 v=6 の状態です。この状態のままサブテストが実行されるので、全てのテストがPASSしてしまいます。

Go1.21では GOEXPERIMENT=loopvar という指定が可能になり、この指定をすれば TestAllEvenBuggy は失敗するようになります。

実際にやってみます。

❯ go version
go version go1.21rc1 darwin/amd64

❯ go test
PASS
ok  	github.com/k3forx/scrap/loopvar	0.329s

❯ GOEXPERIMENT=loopvar go test
--- FAIL: TestAllEvenBuggy (0.00s)
    --- FAIL: TestAllEvenBuggy/sub (0.00s)
        main_test.go:11: odd v 1
FAIL
exit status 1
FAIL	github.com/k3forx/scrap/loopvar	0.227s

確かに GOEXPERIMENT=loopvar という指定でテストが落ちるようになっていますね。

どう仕様を変えるのか?

この問題に対するGoチームの解決策は以下のようになっています。

The solution is to make loop variables declared in for loops using := be a different instance of the variable on each iteration.

(意訳) 解決策は := を使用するfor loopの中で宣言されるループ変数をそれぞれのイテレーションで異なるインスタンスにすることです。

この変更はプログラムを破壊することがあるのか?

:= を使用するfor loopで、ループ変数をそれぞれのイテレーションで異なるインスタンスにした場合に挙動が異なるプログラムはあるのでしょうか?

Goのチームの見解は以下のようになっています。

Yes, it is possible to write programs that this change would break.

(意訳) はい、この変更によって壊れるプログラムを書くことは可能です。


以下のようなコードが例として挙げられていたので考えてみます。

func sum(list []int) int {
	m := make(map[*int]int)
	for _, x := range list {
		m[&x] += x
	}
	for _, sum := range m {
		return sum
	}
	return 0
}

sum([]int{1, 2, 3}) のケースを考えてみます。
現状の仕様だと &x はfor loopごとに同じになるので、以下のように m に要素が追加されていきます。

func sum(list []int) int {
	m := make(map[*int]int)
	// &xは for _, x := range list で同じ値です
	// ここでは0xc00001c030としてみます
	// for _, x := range list {
	//	 m[&x] += x
	// }
	// は以下のようになります
	m[0xc00001c030] += 1
	m[0xc00001c030] += 2
	m[0xc00001c030] += 3

        // m[0xc00001c030] = 6の状態になる
	
	// for _, sum := range m {
	// 	return sum
	// }
	// こちらのfor loopは0xc00001c030が無視されて、6がreturnされます
	
	return 6
}

挙動としては正しく 6 が返ってきます。


GOEXPERIMENT=loopvar が指定されている場合はどうでしょうか?
&x がfor loopごとに違うので、以下のようになると思います。

func sum(list []int) int {
	m := make(map[*int]int)
	// &xは for _, x := range list で異なる値になる
	// for _, x := range list {
	//	 m[&x] += x
	// }
	// は以下のようになります
	m[0xc00001c030] += 1
	m[0xc00001c031] += 2
	m[0xc00001c032] += 3

        // m[0xc00001c031] = 1
	// m[0xc00001c032] = 2
	// m[0xc00001c033] = 3
	// の状態になります
	
	// for _, sum := range m {
	// 	return sum
	// }
	// こちらのfor loopは0xc00001c031が無視されて、1がreturnされます
	
	return 1
}

実際に試してみましょう。

❯ go version
go version go1.21rc1 darwin/amd64

❯ cat ./main.go
package main

import "fmt"

func main() {
        fmt.Println(sum([]int{1, 2, 3}))
}

func sum(list []int) int {
        m := make(map[*int]int)
        for _, x := range list {
                m[&x] += x
        }
        for _, sum := range m {
                return sum
        }
        return 0
}

❯ go run ./main.go
6GOEXPERIMENT=loopvar go run ./main.go
1

これは意図した挙動ではありません。for loopでのループ変数に異なるインスタンスを割り当てることによってプログラムの挙動が壊れる一例です。

この変更がどれくらいの頻度で実際のプログラムを壊すのか?

Goチームは上記のような「:= を使用するfor loopでのループ変数がイテレーションごとに異なるインスタンスを使用する」ことによって、どれくらい実際のプログラムの挙動が壊れるかについても調査しているようです。

結論としては、以下のように述べています。

Empirically, almost never. Testing on Google's codebase found many tests were fixed. It also identified some buggy tests incorrectly passing due to bad interactions between loop variables and t.Parallel, like in TestAllEvenBuggy above. We rewrote those tests to correct them.

(意訳) 経験的には、ほとんどない。Googleのコードベースでのテストでは、多くのテストが修正されていることが確認されています。TestAllEvenBuggy のようにループ変数と t.Paralll の間の悪い相互作用の影響で、間違ってパスしていたbuggyテストがあることもわかりました。私たちはそれらのテストを正しいものに書き直しました。

Googleのコードベースで既に試されていて、一部のテストは間違ったやり方でコードが書かれていて、今回それを検知できたとのことでした。

実際どのくらいの割合でコードが壊れるかは、LoopvarExperimantのwikiページに詳細な調査が載っているので興味のある方は読んでみると良いと思います。

この変更がより多くのアロケーションを引き起こすことでプログラムが遅くなことはあるのか?

Possibly. In some cases, that extra allocation is inherent to fixing a latent bug.

(意訳) 可能性はある。場合によっては、その余分な割り当ては、潜在的なバグを修正するために必要なものです。

例えば以下のような Print123 という関数を考えてみます。

func Print123() {
	var prints []func()
	for i := 1; i <= 3; i++ {
		prints = append(prints, func() { fmt.Println(i) })
	}
	for _, print := range prints {
		print()
	}
}

Go Playgroundで動かしてみると、以下のようになります。

4
4
4

Program exited.

これもループ変数が 4 で上書きされてしまっているので、4 4 4が表示されています。

GOEXPERIMENT=loopvarが指定されている場合、for loopのintごとにメモリがアロケーションされますが、これは2つ目のfor loopで print() を正しく出力するために必要です。

こちらも実際に確かめてみます。

❯ go version
go version go1.21rc1 darwin/amd64

❯ cat ./main.go
package main

import "fmt"

func main() {
        Print123()
}

func Print123() {
        var prints []func()
        for i := 1; i <= 3; i++ {
                prints = append(prints, func() { fmt.Println(i) })
        }
        for _, print := range prints {
                print()
        }
}GOEXPERIMENT=loopvar go run ./main.go
1
2
3

確かに GOEXPERIMENT=loopvar の指定をしておけば、ループ変数ごとにメモリが割り当てられているようです。

メモリアローケーションが大量に起こることでプログラムが遅くなる可能性はありますが、pprof --alloc_objects でプロファイルを行えば、そのようなコードはわかるようになっているようです。

プロポーザルが採用された場合、どのようにして変更がデプロイされるのか?

Consistent with Go's general approach to compatibility, the new for loop semantics will only apply when the package being compiled is from a module that contains a go line declaring Go 1.22 or later, like go 1.22 or go 1.23.

(意訳) 互換性に対するGoの一般的なアプローチと同様に、新しいfor loopセマンティクスは、コンパイルされるパッケージが、go 1.22やgo 1.23のようにGo 1.22以降を宣言するgoのコードを含むモジュールから得られる場合にのみ、適用されます。

ちゃんと互換性を守った上でコードに変更が入るようです。

変更によって影響を受けるコードのリストを見ることができるか?

Yes. You can build with -gcflags=all=-d=loopvar=2 on the command line.

(意訳) はい、-gcflags=all=-d=loopvar=2 のフラグをつけてビルドすることで見ることができます。

以下のような結果を出力してくれるそうです。

$ 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

all= はビルドに含まれるすべてのパッケージに対してチェックを行います。all=なしの場合はコマンドラインで指定するパッケージか、カレントディレクトリのコードのみが対象になります。

テストが変更によって失敗するようになったが、どうやってデバグするか?

A new tool called bisect enables the change on different subsets of a program to identify which specific loops trigger a test failure when compiled with the change.

(意訳) bisect と呼ばれる新しいツールは、プログラムの異なるサブセットで変更を可能にし、変更を加えてコンパイルしたときに、どの特定のループがテストの失敗を引き起こすかを特定します。

テストについてもbisectという新しいツールで検知可能になりそうです。


こちらも実際に TestAllEvenBuggy のテストコードに対して試してみます。

❯ go version
go version go1.21rc1 darwin/amd64

❯ go install golang.org/x/tools/cmd/bisect@latest

❯ bisect -compile=loopvar go test
bisect: checking target with all changes disabled
bisect: run: GOCOMPILEDEBUG=loopvarhash=n go test... ok (12 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=n go test... ok (12 matches)
bisect: checking target with all changes enabled
bisect: run: GOCOMPILEDEBUG=loopvarhash=y go test... FAIL (12 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=y go test... FAIL (12 matches)
bisect: target succeeds with no changes, fails with all changes
bisect: searching for minimal set of enabled changes causing failure
bisect: run: GOCOMPILEDEBUG=loopvarhash=+0 go test... FAIL (7 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+0 go test... FAIL (7 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+00 go test... FAIL (4 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+00 go test... FAIL (4 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+000 go test... ok (3 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+000 go test... ok (3 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+100 go test... FAIL (1 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=+100 go test... FAIL (1 matches)
bisect: confirming failing change set
bisect: run: GOCOMPILEDEBUG=loopvarhash=v+x0e7e4 go test... FAIL (1 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=v+x0e7e4 go test... FAIL (1 matches)
bisect: FOUND failing change set
--- change set #1 (enabling changes causes failure)
./main_test.go:7:9: loop variable v now per-iteration
---
bisect: checking for more failures
bisect: run: GOCOMPILEDEBUG=loopvarhash=-x0e7e4 go test... ok (11 matches)
bisect: run: GOCOMPILEDEBUG=loopvarhash=-x0e7e4 go test... ok (11 matches)
bisect: target succeeds with all remaining changes enabled

./main_test.go:7:9: loop variable v now per-iteration というメッセージが表示されました。「ループ変数 v は今はイテレーションごとです」という旨のメッセージで、この変化がテストを失敗させますという内容のようです。

確かにbisectを使えば、落ちるテストがわかりそうでした。

結論

まとめると以下のような結論になります!

  • Go1.22からfor loopの仕様の変更が入る可能性がある
  • 具体的には 「:= を使っているfor loopでは、ループ変数は異なるインスタンスとして宣言される」という変更が入る可能性がある
  • 仕様の変更によって既存のプログラムが壊れたり、パフォーマンスが落ちたりする可能性はあるが、それらを検知できる仕組みがいろいろある
    • ビルドに対しては -gcflags=all=-d=loopvar=2 のフラグで影響のある箇所が確認できる
    • テストに対してはbisectという新しいツールで検知が可能になる

仕様が変わるとはいえ、変わることによって引き起こされる変化を十分に議論し、それらを検知するための仕組みが整えられていそうでした。

SODA Engineering Blog
SODA Engineering Blog

Discussion