【Go 1.21】ループ変数が共有される問題への対策(プレビュー)が公開されました

TL;DR

  • Goのfor文で宣言されるループ変数は、ループ(イテレーション)毎に同じアドレスに値が格納される仕様になっている
  • この仕様のよってループ内で並行処理を実行するなど特定のケースで、どのループでも毎回同じ値が取得されてしまうといった意図しないバグが発生することがあった
  • Go 1.21でこの問題への対策のプレビューが公開され、問題無ければ1.22で正式リリースされる予定です
  • 既存プログラムがこの変更によって正常に動作しなくなる可能性は殆ど無いとされています
  • 影響を受けるコードを検出するビルドのオプションが追加されたり、変更によって失敗するテストケースを特定するツールが提供されているので、それらで検証が可能です

※社内のテックブログに投稿した内容と同じです

【Go 1.21】ループ変数が共有される問題への対策(プレビュー)が公開されました - Money Forward Developers Blog

はじめに

先日Go 1.21がリリースされましたが、以前から多くのGoエンジニアを悩ませてきた「ループ変数が共有される問題」への対策が実験的に盛り込まれたので、今回はそちらについて紹介したいと思います。

Go 1.21 is released! - The Go Programming Language

※本記事で紹介するコードの実行結果は全てGo 1.21での挙動です ※本記事は主に以下ドキュメントの情報を基に作成しています(8月15日時点)

従来の仕様とその問題点について

ループ変数は同じアドレスに格納される

そもそも「ループ変数が共有される」とはどういったことなのかをサンプルコードを交えて紹介します。以下のコードはslice内の全要素をfor文で順次処理したものです。ループごとに各要素がループ変数vに格納されて、その値とアドレス(ポインタ)を標準出力します。

func main() {
    values := []int{0, 2, 4, 6}
    for i, v := range values {
        fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
    }
}
index: 0, value: 0, address: 0xc000012028
index: 1, value: 2, address: 0xc000012028
index: 2, value: 4, address: 0xc000012028
index: 3, value: 6, address: 0xc000012028

go.dev

ループごとに異なる値が出力されるのは想定通りだと思いますが、注目すべき点はaddressが全て同じであるところです。このようにGoではループごとに値が変わっていても、その値の格納場所に同じアドレスを使い回すようになっています。

この仕様自体はバグではなく想定されたもので、ループごとに新たなメモリ割り当てが発生しないのでパフォーマンス面だけでみると合理的な仕様だと言えます。しかし後述する特定のケースではこの仕様が影響して意図しない動作を引き起こしていました。

ケース1:goroutineでの並行処理

先程と同様にループ処理を組みますが、今度はgoroutineに渡したクロージャ内でループ変数を直接使って標準出力します。

func main() {
    var wg sync.WaitGroup

    values := []int{0, 2, 4, 6}
    for i, v := range values {
        wg.Add(1)
        go func() {
            // 実行されるごとに index/value/address が変更されていることを期待するが……
            fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
            wg.Done()
        }()
    }

    wg.Wait() // 全ての goroutine が終了するまで待機
}
index: 3, value: 6, address: 0xc000012050
index: 3, value: 6, address: 0xc000012050
index: 3, value: 6, address: 0xc000012050
index: 3, value: 6, address: 0xc000012050

go.dev

先程と異なり、全てのループで同じ結果(最後のループの結果)が出力されてしまいました。goroutineが起動してクロージャ内で標準出力の処理が行われる前に、後続のループ処理によってループ変数の値が上書きされたため、それぞれのループ開始時点の値が反映されなくなっています。

なので以下のように直前のgoroutineの処理が完了してから次のループに移るように書き換えれば、想定通りの値が出力されます。

func main() {
    var wg sync.WaitGroup

    values := []int{0, 2, 4, 6}
    for i, v := range values {
        wg.Add(1)
        go func() {
            fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
            wg.Done()
        }()
        wg.Wait() // 直前の goroutine の処理が終了してから次のループに移る
    }
}
index: 0, value: 0, address: 0xc0000a8018
index: 1, value: 2, address: 0xc0000a8018
index: 2, value: 4, address: 0xc0000a8018
index: 3, value: 6, address: 0xc0000a8018

go.dev

但し上記の方法だと別でgoroutineを起動する意味が無い(並行処理にならない)のでお勧め出来ません。

今回のケースであれば、ループ変数を直接使用せずクロージャの引数を経由すれば、同じアドレスを共有する問題を回避できるので想定通りに動きます。

func main() {
    var wg sync.WaitGroup

    values := []int{0, 2, 4, 6}
    for i, v := range values {
        wg.Add(1)
        go func(i, v int) {
            fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
            wg.Done()
        }(i, v) // クロージャの引数にループ変数を指定
    }

    wg.Wait()
}

go.dev

その他のアプローチで最もシンプルな方法は、ループ処理内で新たに変数を宣言する方法です。

func main() {
    var wg sync.WaitGroup

    values := []int{0, 2, 4, 6}
    for i, v := range values {
        i, v := i, v // 新たに変数を宣言
        wg.Add(1)
        go func() {
            fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
            wg.Done()
        }()
    }

    wg.Wait()
}

go.dev

ケース2:テストの並行実行

以下は奇数を判定する関数のテストです。t.Parallel()でテストを並行実行しています。

func IsOdd(num int) bool {
    return num%2 != 0
}

func TestIsOdd(t *testing.T) {
    tests := []struct {
        num  int
        want bool
    }{
        {num: 1, want: true},
        {num: 2, want: true}, // 失敗する想定のテストケース
        {num: 3, want: true},
        {num: 4, want: false},
    }
    for _, tt := range tests {
        t.Run(fmt.Sprintf("num(%v)", tt.num), func(t *testing.T) {
            t.Parallel() // 並行実行
            if got := IsOdd(tt.num); got != tt.want {
                t.Errorf("got = %v, want %v", got, tt.want)
            }
        })

    }
}
=== RUN   TestIsOdd
(中略)
--- PASS: TestIsOdd (0.00s)
    --- PASS: TestIsOdd/num=1 (0.00s)
    --- PASS: TestIsOdd/num=2 (0.00s)
    --- PASS: TestIsOdd/num=4 (0.00s)
    --- PASS: TestIsOdd/num=3 (0.00s)
PASS

go.dev

1箇所、失敗する想定のテストケースがパスしてしまっています。こちらも先程と同様に、ループ変数が後続のループ処理で書き換えられてしまったことが原因です。

こちらもループ処理内で新たに変数を宣言すれば解消されます。

func TestIsOdd(t *testing.T) {
    tests := []struct {
        num  int
        want bool
    }{
        {num: 1, want: true},
        {num: 2, want: true}, // 失敗する想定のテストケース
        {num: 3, want: true},
        {num: 4, want: false},
    }
    for _, tt := range tests {
        tt := tt // 新たに変数を宣言
        t.Run(fmt.Sprintf("num=%v", tt.num), func(t *testing.T) {
            t.Parallel()
            if got := IsOdd(tt.num); got != tt.want {
                t.Errorf("got = %v, want %v", got, tt.want)
            }
        })

    }
}
=== RUN   TestIsOdd
(中略)
--- FAIL: TestIsOdd (0.00s)
    --- PASS: TestIsOdd/num=1 (0.00s)
    --- PASS: TestIsOdd/num=4 (0.00s)
    --- PASS: TestIsOdd/num=3 (0.00s)
    --- FAIL: TestIsOdd/num=2 (0.00s)
FAIL

go.dev

ケース3:ループ変数のポインタを利用した処理

最後はintのポインタを格納するslice(ids)に、0から9の連番をポインタで格納する処理です。

func main() {
    var ids []*int
    for i := 0; i < 10; i++ {
        ids = append(ids, &i)
    }
    for i, v := range ids {
        fmt.Printf("index: %d, id: %d, address: %p\n", i, *v, v)
    }
}
index: 0, id: 10, address: 0xc000012028
index: 1, id: 10, address: 0xc000012028
index: 2, id: 10, address: 0xc000012028
index: 3, id: 10, address: 0xc000012028
index: 4, id: 10, address: 0xc000012028
index: 5, id: 10, address: 0xc000012028
index: 6, id: 10, address: 0xc000012028
index: 7, id: 10, address: 0xc000012028
index: 8, id: 10, address: 0xc000012028
index: 9, id: 10, address: 0xc000012028

go.dev

こちらも先程までのケースと同様、格納されたアドレスが全て同じなので、ループ最後の値で出力されてしまっています。

ループ処理内で新たに変数を宣言すれば解消されます。

func main() {
    var ids []*int
    for i := 0; i < 10; i++ {
        i := i // 新たに変数を宣言
        ids = append(ids, &i)
    }
    for i, v := range ids {
        fmt.Printf("index: %d, id: %d, address: %p\n", i, *v, v)
    }
}
index: 0, id: 0, address: 0xc0000a2000
index: 1, id: 1, address: 0xc0000a2008
index: 2, id: 2, address: 0xc0000a2010
index: 3, id: 3, address: 0xc0000a2018
index: 4, id: 4, address: 0xc0000a2020
index: 5, id: 5, address: 0xc0000a2028
index: 6, id: 6, address: 0xc0000a2030
index: 7, id: 7, address: 0xc0000a2038
index: 8, id: 8, address: 0xc0000a2040
index: 9, id: 9, address: 0xc0000a2048

go.dev

従来の解決策

上記のサンプルコードでも例示しましたが、この問題への最もシンプルな解決策は、新たな変数を宣言する方法になります。この方法は以下の公式のFAQにもサンプルコードとともに紹介されています。

Even easier is just to create a new variable, using a declaration style that may seem odd but works fine in Go:

for _, v := range values {
    v := v // create a new 'v'.
    go func() {
        fmt.Println(v)
        done <- true
    }()
}

What happens with closures running as goroutines?

Goエンジニアはこの仕様を考慮してループ処理を実装することが求められていましたが、見落としがちな仕様なので、新たにGoを利用する人はもちろん、使い慣れた人でも対策が漏れてしまい、バグを引き起こしてしまったという話はいくつも耳にしたことがあります。

その中でも特に大きな問題になったのが、Let's Encryptでのインシデントです。

Let's Encryptでのインシデント

無償でSSL証明書を発行しているLet's Encryptでは、ユーザーやドメインの検証に使用していたソフトウェアをGoで開発していますが、2020年にその検証ロジックにバグがあることが報告されました。

そのバグの原因となったのが、これまでに紹介したループ変数が共有されてしまう仕様によって引き起こされたものでした。

このバグは公式のwikiでも実際のコードと併せて紹介されています。 https://github.com/golang/go/wiki/LoopvarExperiment#what-is-the-problem-this-solves

これが原因で300万件近くの証明書が取り消されかけて(最終的に取り消さない判断になった)、複数のネットメディアで取り上げられたほど大きな問題に発展しました。

問題の大小はありますが同様の事例は様々報告されており、何年も前からこの仕様を変えるべきだという議論が行われていましたが、ついに今回対策の提案が承認されてGo1.21にプレビューとして組み込まれました。

今後の仕様変更と懸念事項

採用された提案内容

今回の提案内容では特にこれまでの記述方法を変えなくても、コンパイラが適切に判断して対策が適用されます。

先程紹介した「ケース1」「ケース2」のようなループ変数がクロージャやgoroutineに捕捉された場合や、「ケース3」のようにループを抜けた先でループ変数が参照される場合は、ループごとに変数が作成されるようになります。これによってループごとに保持された値が出力できます。

以前まで記述していた新たな変数の宣言(x := x)は不要になります。

⁠Go 1.21での検証方法

Go 1.21はプレビューなのでデフォルトでは従来通りの挙動ですが、ビルド時にGOEXPERIMENT=loopvarを指定すると変更内容を反映してビルドが行われます。

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=....

How do I try the change?

The Go Playground上でも先頭に// GOEXPERIMENT=loopvarとコメントを付けると、このオプションが有効になります。\ go.dev

変更によって影響を受けるコードの場所を確認したい場合、-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

Can I see a list of places in my code affected by the change?

またbisectというツールを使えば、この変更でテストが失敗を引き起こすループを特定できます。

go install golang.org/x/tools/cmd/bisect@latest
bisect -compile=loopvar go test

My test fails with the change. How can I debug it?

懸念1:既存プログラムへの破壊的な影響

これまでの仕組みが大きく変わるので、既存プログラムが正常に動作しなくなるなどの悪影響がまず気になります。

以下リンクにこれまで通り動かなくなるサンプルコードが紹介されています。従来の仕様に依存したコードは今回の変更で正常に動作しなくなる可能性があります。 Can this change break programs?

func main() {
    print(sum([]int{0, 2, 4, 6}))
}

func sum(list []int) int {
    m := make(map[*int]int)
    for _, x := range list {
        m[&x] += x // xがループ内で1つしか無いことに依存している
    }
    for _, sum := range m {
        return sum
    }
    return 0
}
Output(適用前): 12
Output(適用後): 0

go.dev

func main() {
    var f func()
    for i := 0; i < 10; i++ {
        if i == 0 {
            f = func() { print(i) } // i=0しか有り得ない筈が、関数呼び出し時点の`i`を使用されるので、ループごとに値が異なる
        }
        f()
    }
}
Output(適用前): 0123456789
Output(適用後): 0000000000

go.dev

但し上記サンプルはGoの一般的なループ処理の記述方法とは大きくかけ離れた書き方で、考慮すべきケースは極めて稀だと思われます(issues上の議論の中でも実際に存在を確認できないという意見が多数)

Googleでは2023年5月初旬から本番利用しているツールに変更を反映しているようですが、現時点で問題は一度も報告されていないようです。

なおC#でも同様の変更を加えた経緯があるそうですが、特に変更によって大きな問題は発生しなかったようです

How often does the change break real programs?

懸念2:⁠パフォーマンス低下

新たにメモリ割り当てされることになるのでパフォーマンス低下の懸念がありますが、通常のループ処理は従来通りコンパイルされる(変数共有)ので、それらのパフォーマンスに影響は出ません。影響を受けるループの場合でも、コンパイラエスケープ解析で必要無いと判断されれば新しい割り当てが行われないようです。

Googleの内部環境で使用した際もパフォーマンスの問題は観測されなかったということなので、大抵のプログラムは影響を受けないと考えられます。

Will the change make programs slower by causing more allocations?

まとめ

今回調査してみて、少なくとも私の携わっているプロジェクトでは影響が殆ど無さそうなのが分かって安心しました。

新しい変数を都度宣言する従来の対策方法は、この仕様を知らない人からすると意図が読めませんし、うっかり忘れてしまう可能性もあるので、実装時に気にする必要がなくなるのは非常にありがたいです。

私自身、Goを使い始めて暫くした時にこの事象に遭遇して、かなり苦い思いをしたので、正式リリースを心待ちにしたいと思います。