できない.dev

Go の for range でループ変数のキャプチャができない(goroutine が同じ値を見る)

Go 1.21 以前は for range のループ変数が全反復で共有されるため、goroutine やクロージャ内で参照すると最終値だけを見てしまう。
Go 1.22 以降は各反復で新しい変数になる仕様。
古い環境では明示的にローカル変数へ写す。

#go#for-range#goroutine#closure#go1-22

公開:

要約

Go 1.21 以前の for range はループ変数が全反復で共有されるため、goroutine やクロージャに渡すと最終値だけを見る。
Go 1.22 以降は各反復で新しい変数が作られる仕様に変わったので、go.modgo 1.22 以降を指定するのが根本解決。
古い環境では v := v でシャドウイングするか、goroutine の引数として値を渡す。

よくある原因

  1. Go 1.21 以前の挙動を前提に書いている: for _, v := range items { go func() { handle(v) }() }
  2. go.mod のバージョン指定が go 1.21 以下で、go 1.22 のループ変数セマンティクスが効いていない
  3. テーブル駆動テストで tt := tt のシャドウイングを忘れ、t.Parallel() を呼んだ結果テストケースが上書きされる
  4. ポインタを取って append している: result = append(result, &v) で全要素が同じアドレスを指す

解決策

1. Go 1.22 以降を使う

go.mod を更新する。

module example.com/myapp
 
go 1.22

Go 1.22 以降は各反復で新しい変数が作られるため、以下のコードが意図通り動く。

items := []int{1, 2, 3}
for _, v := range items {
    go func() { fmt.Println(v) }()
}

変更点の詳細は Go 1.22 Release Notes の language セクションに記載されている。

2. 旧バージョンではローカル変数にシャドウイング

for _, v := range items {
    v := v // 各反復ごとに新しい v を作る
    go func() { fmt.Println(v) }()
}

3. goroutine の引数として値渡し

for _, v := range items {
    go func(x int) { fmt.Println(x) }(v)
}

引数を介すと goroutine 起動時の値がコピーされ、ループ変数の参照が残らない。

4. go vet で検出する

go vet ./...

go vetloopclosure チェックがキャプチャ問題を警告する。
CI に組み込んでおくと事前に気付ける。
テーブル駆動テストの t.Parallel() を使うケースは特に検出価値が高い。

5. テーブル駆動テストの並列化

for _, tt := range tests {
    tt := tt // Go 1.21 以前では必須
    t.Run(tt.name, func(t *testing.T) {
        t.Parallel()
        check(t, tt.in, tt.want)
    })
}

Go 1.22 以降は tt := tt のシャドウイングは不要。go.mod を上げたタイミングで一斉に削除して構わない。

この記事は役立ちましたか?