Slide 1

Slide 1 text

Fixing For Loops in Go 1.22 @uncle__ko

Slide 2

Slide 2 text

Self Introduction Kohei Ouchi(@uncle__ko) いままでは銀行の勘定系の開発をしたり、ECサイトの開発をしたり、DSP/DMPの開発 をしたり、クレジットカードの開発をしたりしてた 現在はDeveloper Productivity室に在籍 プライベートでは6歳児の父として育児に励んでる

Slide 3

Slide 3 text

Agenda ● The Problem ● The Fix ● The Impact ● Summary

Slide 4

Slide 4 text

The Problem

Slide 5

Slide 5 text

The Problem このコードを見てみましょう func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { go func() { fmt.Println(v) done <- true }() } // wait for all goroutines to complete before exiting for _ = range values { <-done } }

Slide 6

Slide 6 text

The Problem このコードはどのように Printされると思いますか? func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { go func() { fmt.Println(v) done <- true }() } // wait for all goroutines to complete before exiting for _ = range values { <-done } }

Slide 7

Slide 7 text

The Problem 実は... func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { go func() { fmt.Println(v) done <- true }() } // wait for all goroutines to complete before exiting for _ = range values { <-done } }

Slide 8

Slide 8 text

The Problem 実は... "c", "c", "c" とPrintされます https://go.dev/play/p/mjXjtKeT6JD func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { go func() { fmt.Println(v) done <- true }() } // wait for all goroutines to complete before exiting for _ = range values { <-done } }

Slide 9

Slide 9 text

Why?󰤇

Slide 10

Slide 10 text

The Problem ループの各繰り返しで変数vの 同じインスタンスが使用される つまり変数のスコープがループ全体 になってる 各クロージャは単一の変数を共有 func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { go func() { fmt.Println(v) done <- true }() } // wait for all goroutines to complete before exiting for _ = range values { <-done } }

Slide 11

Slide 11 text

The Problem クロージャが実行されると fmt.Printlnが実行された時点での 変数vの値を出力 func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { go func() { fmt.Println(v) done <- true }() } // wait for all goroutines to complete before exiting for _ = range values { <-done } }

Slide 12

Slide 12 text

The Problem 各クロージャが起動時に現在の変数vの値をバインドするには 内側のループを各繰り返しで新しい変数を作成するように変更する必要がある たとえば...

Slide 13

Slide 13 text

The Problem 各クロージャが起動時に現在の変数vの値をバインドするには 内側のループを各繰り返しで新しい変数を作成するように変更する必要がある たとえば... 変数をクロージャに引数として渡すとか

Slide 14

Slide 14 text

The Problem 変数vの値が無名関数に 引数として渡される その値はその後 関数内で変数uとしてアクセス可能 https://go.dev/play/p/xeWv2pldX6c func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { go func(u string) { fmt.Println(u) done <- true }(v) } // wait for all goroutines to complete before exiting for _ = range values { <-done } }

Slide 15

Slide 15 text

The Problem もっと簡単な方法は 新しい変数を作成すること これで想定通り動く https://go.dev/play/p/xeWv2pldX6c func main() { done := make(chan bool) values := []string{"a", "b", "c"} for _, v := range values { v := v // create a new 'v'. go func() { fmt.Println(v) done <- true }() } // wait for all goroutines to complete before exiting for _ = range values { <-done } }

Slide 16

Slide 16 text

The Problem 多くの場合では並行処理が関連しているが、必ずしもその限りではない 次の例は、同じ問題が発生するがgoroutineは使用されてない

Slide 17

Slide 17 text

The Problem この例だと "4", "4", "4" が出力される https://go.dev/play/p/lM8kkEPu8Tw func main() { var prints []func() for i := 1; i <= 3; i++ { prints = append(prints, func() { fmt.Println(i) }) } for _, print := range prints { print() } }

Slide 18

Slide 18 text

The Problem この問題はLet's Encryptを含む多くの企業で問題を引き起こした そしてこの種の問題はとても見つけにくい https://bugzilla.mozilla.org/show_bug.cgi?id=1619047

Slide 19

Slide 19 text

The Problem このようなミスを特定するためのツールも開発されているが 変数への参照がその反復より長いかどうかを分析するのは難しい go vetやgoplsで使われているloopclosure analyzerは偽陰性 問題があることが確実な場合のみ報告するが、それ以外は見逃す 偽陽性のチェッカーは、正しいコードが正しくないと指摘することもある

Slide 20

Slide 20 text

The Fix

Slide 21

Slide 21 text

The Fix Go 1.22では、forループを変更して、これらの変数がループごとではなく、各繰り返しで スコープを持つようになる

Slide 22

Slide 22 text

The Fix Go 1.22では、forループを変更して、これらの変数がループごとではなく、各繰り返しで スコープを持つようになる この変更により、いままで見てきた問題が修正され、この種のミスに起因する問題が解 消され、不正確なツールを使用してユーザーにコードを不要に変更させる必要がなくな る

Slide 23

Slide 23 text

後方互換性は大丈夫なん?🤔

Slide 24

Slide 24 text

The Fix この新しいセマンティクスは go.modファイルでgo 1.22以降を宣言しているモジュールに含まれるパッケージにのみ 適用

Slide 25

Slide 25 text

The Fix この新しいセマンティクスは go.modファイルでgo 1.22以降を宣言しているモジュールに含まれるパッケージにのみ 適用 コードベース全体で新しいセマンティクスへの段階的な更新を開発者が制御できるよう に また、//go:build行を使用してファイルごとに制御することも可能

Slide 26

Slide 26 text

The Fix 前方互換性の作業の結果として、Go 1.21はgo 1.22以降を宣言したコードをコンパイル しようとしない https://go.dev/blog/toolchain そのため、Go 1.22がリリースされたとき、新しいセマンティクスに従って書かれたコード が古いセマンティクスでコンパイルされることはない

Slide 27

Slide 27 text

The Fix ちなみに最初の例に出したコードをGo dev branchで実行すると “a”,”b”,”c”が出力されるようになってる https://go.dev/play/p/mjXjtKeT6JD?v=gotip

Slide 28

Slide 28 text

The Fix Go 1.21にはスコープ変更のプレビューが含まれてる

Slide 29

Slide 29 text

The Fix Go 1.21にはスコープ変更のプレビューが含まれてる GOEXPERIMENT=loopvar を指定してコードをコンパイルすると、新しいセマンティクスがすべてのループに適用さ れる(go.mod の go lineは無視される)

Slide 30

Slide 30 text

The Impact

Slide 31

Slide 31 text

The Impact このような偶数であることを確認する テストがあったとする 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) } }) } }

Slide 32

Slide 32 text

The Impact Go 1.21では、このテストはパスする t.Parallelは各サブテストをブロックし ループ全体が完了するまで待機 その後すべてのサブテストを 並行して実行するから 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) } }) } }

Slide 33

Slide 33 text

The Impact ループが終了するとvは常に6になる そのため、このテストはパスしてしまう このテストは実際には失敗するべきであり 1は偶数ではない forループを修正すると この種のバグのあるテストが露呈する 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) } }) } }

Slide 34

Slide 34 text

The Impact 実際、そんなに影響が出ることはないと思われる

Slide 35

Slide 35 text

The Impact 実際、そんなに影響が出ることはないと思われる Kubernetesでも試されてる

Slide 36

Slide 36 text

The Impact ベースとなるコードに潜在するループ変数のスコープ関連のバグが原因で、2つのテスト が失敗した 比較のために、KubernetesをGo 1.20からGo 1.21に更新したところ、新たに3つのテス トが失敗した ループ変数の変更によって失敗する2つのテストは、通常のリリースの更新と比較して、 新たな大きな負担にはならない

Slide 37

Slide 37 text

The Impact Russ Coxが試してるIssue https://github.com/golang/go/issues/60078 Wikiにも詳細が載ってる https://go.dev/wiki/LoopvarExperiment

Slide 38

Slide 38 text

より多くのallocationが発生して プログラムが遅くならんの?🤔

Slide 39

Slide 39 text

The Impact 遅くなることもある

Slide 40

Slide 40 text

The Impact 遅くなることもある …が多くの場合は気にしなくてよい程度 public “bent” bench suiteのベンチマークでは、統計的に有意なパフォーマンスの差は 見られなかった ほとんどのプログラムは影響を受けないものと思われる

Slide 41

Slide 41 text

The Impact なにかあったとしても pprof --alloc_objects などで特定は可能

Slide 42

Slide 42 text

Summary

Slide 43

Slide 43 text

Summary ● 変数がループ全体ではなく各繰り返しでスコープを持つようになる ● 後方互換性が確保 ○ go.modファイルでgo 1.22以降を宣言しているモジュールに含まれるパッケー ジにのみ適用 ● テストなどがコケるようになるかも ○ とはいえGo 1.22の導入に備えてloopclosure analyzerが強化されてる

Slide 44

Slide 44 text

Summary もっと詳しく知りたい人はぜひProposal見てみてね https://go.googlesource.com/proposal/+/master/design/60078-loopvar.md