Go 1.22でのforループの修正

Fixing For Loops in Go 1.22 by David Chase and Russ Cox

Go 1.21には、Go 1.22で提供を予定している for ループのスコープに関する変更のプレビューが含まれています。 この変更によって、Goで最もよくあるミスの一つが取り除かれます。

問題

Goのコードをある程度書いたことがある人なら、ループ変数への参照をそのイテレーションが終わったあとまで 保持してしまい、意図しない新しい値に変わってしまうというミスをおそらく経験したことがあるでしょう。 たとえば、次のようなプログラムを考えてみます。

func main() {
    done := make(chan bool)

    values := []string{"a", "b", "c"}
    for _, v := range values {
        go func() {
            fmt.Println(v)
            done <- true
        }()
    }

    // すべてのゴルーチンが完了するのを待ってから終了する
    for _ = range values {
        <-done
    }
}

作成される3つのゴルーチンはすべて同じ変数 v を出力するので、通常は “a”、“b”、“c” のいずれかの順で 出力されるのではなく、“c”、“c”、“c” と出力されます。

「クロージャがゴルーチンとして実行されるとどうなるか」というGo FAQのエントリ でもこの例が取り上げられていて、「並行性とクロージャを組み合わせると混乱が生じることがある」と述べられています。

並行性が絡んでいることが多いものの、必ずしもそうとは限りません。次の例はゴルーチンを使っていませんが、 同じ問題を抱えています。

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

この種のミスは多くの企業で実際の障害を引き起こしてきました。その一つが Let’s Encryptで公開されている問題です。 このケースでは、ループ変数の意図しないキャプチャが複数の関数にまたがっていたため、 気づくのがずっと難しくなっていました。

// authz2ModelMapToPBは、ドメイン名とauthz2Modelとの対応関係を表すマップを
// protobufのauthorizationsマップに変換する
func authz2ModelMapToPB(m map[string]authz2Model) (*sapb.Authorizations, error) {
    resp := &sapb.Authorizations{}
    for k, v := range m {
        // ループごとに再代入されるので、kのコピーを作る。
        kCopy := k
        authzPB, err := modelToAuthzPB(&v)
        if err != nil {
            return nil, err
        }
        resp.Authz = append(resp.Authz, &sapb.Authorizations_MapElement{
            Domain: &kCopy,
            Authz: authzPB,
        })
    }
    return resp, nil
}

このコードを書いた人は問題の本質を理解していたからこそ k のコピーを作っていたのですが、 実は modelToAuthzPB が結果を組み立てる際に v のフィールドへのポインタを使っていたため、 ループでは v についてもコピーを作る必要がありました。

このようなミスを発見するためのツールも作られてきましたが、ある変数への参照がそのイテレーションより 長く生き続けるかどうかを解析するのは簡単ではありません。こうしたツールは偽陰性と偽陽性のどちらかを 選ばなければなりません。 go vetgopls が使っている loopclosure アナライザは偽陰性側に 倒れていて、問題があると確信できる場合にしか報告しない代わりに、他の問題は見逃します。 一方、偽陽性側に倒れているチェッカーもあり、正しいコードを誤って指摘してしまいます。 私たちはオープンソースのGoコードにおいて x := x という行を追加しているコミットを調査し、 バグ修正が見つかることを期待していました。ところが実際には不要な行が数多く追加されているのが見つかり、 広く使われているチェッカーにはかなりの偽陽性率があるにもかかわらず、開発者はチェッカーを 黙らせるためにとにかくその行を追加している、ということがうかがえました。

私たちが見つけた例の中で、特に示唆に富んでいた一組を紹介します。

あるプログラムでは、次のような差分がありました。

     for _, informer := range c.informerMap {
+        informer := informer
         go informer.Run(stopCh)
     }

そして別のプログラムでは、次のような差分がありました。

     for _, a := range alarms {
+        a := a
         go a.Monitor(b)
     }

これら2つの差分のうち片方はバグ修正で、もう片方は不要な変更です。関係する型や関数について もっと詳しく知らない限り、どちらがどちらなのかを見分けることはできません。

修正

Go 1.22では、これらの変数がループ単位のスコープではなく、イテレーション単位のスコープを持つように for ループを変更する予定です。この変更によって、上記の例はもはやバグのあるGoプログラムでは なくなります。この種のミスによる障害はなくなり、また、ユーザーに不要な変更を促してしまう 精度の低いツールも不要になります。

既存のコードとの後方互換性を確保するため、新しいセマンティクスは go.mod ファイルで go 1.22 以降を 宣言しているモジュールに含まれるパッケージにのみ適用されます。このモジュール単位の判断によって、 開発者はコードベース全体にわたって新しいセマンティクスへの移行を段階的にコントロールできます。 また、 //go:build 行を使ってファイル単位でこの判断をコントロールすることも可能です。

既存のコードは今日と全く同じ意味を持ち続けます。つまり、この修正は新規または更新されたコードにのみ 適用されます。これにより、開発者は特定のパッケージにおいてセマンティクスがいつ変わるかを コントロールできます。前方互換性に関する私たちの取り組みの結果として、 Go 1.21は go 1.22 以降を宣言しているコードをコンパイルしようとしなくなります。 Go 1.20.8とGo 1.19.13のポイントリリースにも同様の効果を持つ特別な対応を組み込んであるため、 Go 1.22がリリースされる頃には、非常に古くサポート対象外のGoバージョンを 使っている場合を除いて、新しいセマンティクスに依存して書かれたコードが古いセマンティクスで コンパイルされることは決してありません。

修正のプレビュー

Go 1.21には、このスコープの変更のプレビューが含まれています。環境変数に GOEXPERIMENT=loopvar を 設定してコードをコンパイルすると、新しいセマンティクスがすべてのループに適用されます (go.modgo 行は無視されます)。たとえば、自分のパッケージとすべての依存先に新しいループの セマンティクスを適用した状態でテストが通るかどうかを確認するには、次のようにします。

GOEXPERIMENT=loopvar go test

私たちはGoogle社内のGoツールチェインにパッチを当て、2023年5月初めからすべてのビルドでこのモードを 強制するようにしましたが、その後4か月間、プロダクションコードでの問題報告は一件もありませんでした。

また、プログラムの先頭に // GOEXPERIMENT=loopvar というコメントを含めることで、Go Playground上でも このプログラムのようにテストプログラムを試し、 セマンティクスへの理解を深められます。(このコメントはGo Playground内でのみ有効です。)

バグのあるテストの修正

プロダクションで問題は起きていませんでしたが、この切り替えに備えて、私たちは意図した通りには テストできていなかった、次のようなバグのあるテストを数多く修正する必要がありました。

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)
            }
        })
    }
}

Go 1.21では、 t.Parallel がループ全体の終了までそれぞれのサブテストをブロックし、そのあとで すべてのサブテストを並行に実行するため、このテストは通ってしまいます。ループが終わった時点で v は常に6なので、すべてのサブテストは6が偶数であることを確認することになり、テストは通ります。 もちろん、1は偶数ではないので、このテストは本来失敗するべきです。forループの修正によって、 この種のバグのあるテストが表面化します。

こうした問題の発見に備えられるように、私たちはGo 1.21で loopclosure アナライザの精度を改善し、 この問題を検出して報告できるようにしました。このプログラムで Go Playground上のレポートを見られます。もし go vet が自分のテストでこの種の問題を 報告しているなら、それを修正しておくことでGo 1.22への備えがより万全になります。

他の問題に遭遇した場合は、FAQ に、新しいセマンティクスが適用されたときにどのループがテストの失敗を引き起こしているのかを 特定するために私たちが書いたツールの使い方についての例や詳細情報へのリンクがあります。

詳しい情報

この変更について詳しくは、設計ドキュメントFAQを参照してください。

By David Chase and Russ Cox