takanakahiko’s blog

多分三日坊主で辞めます。

go test のときは toolchain ではなく go.mod のバージョンが参照されるらしいという話

株式会社U-NEXTでCMSチームに所属しているtakanakahikoです。 今回は、Goの挙動を調べる中で少しおもしろい仕様を知ることになったので紹介します。

この記事はU-NEXT Advent Calendar 2024の13日目の記事です。

adventar.org

国際色豊かなU-NEXTの開発メンバーを中心に、さまざまな記事が投稿されていますので、ぜひ他の記事もお読みください。

昨日の記事は Miao Jiang さんの 「Authentic Hotpot Restaurants from China」でした。 本場の味を知っている方々がおすすめする飲食店情報ですから必見ですね!

note.com

Go1.22 からの forループ変数のセマンティクス変更について

さて、Go 1.23がリリースされて久しいところですが、今回はまずGo 1.22による変更についてお話しします。 Go 1.21まではfor文のある特徴的な挙動があり、直感的ではないため困ることが多い状況でした。

たとえばこういったGoの処理があるとします。

func TestMain(t *testing.T) {
 done := make(chan bool)

 list := []int{1, 3, 5, 7}
 for i, v := range list {
  go func() {
   fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
   done <- true
  }()
 }

 for _ = range list {
  <-done
 }
}

このコードをGo 1.21以前で実行すると、以下のような出力が得られます。 なお、test内でfmt.Printfの出力を確認するには、 go test -v ./... のように -v オプションが必要です。

=== RUN   TestMain
index: 3, value: 7, address: 0x140000980d0
index: 3, value: 7, address: 0x140000980d0
index: 3, value: 7, address: 0x140000980d0
index: 3, value: 7, address: 0x140000980d0
--- PASS: TestMain (0.00s)
PASS

goroutineが起動してクロージャ内の処理が実行される前に、ループ変数の値が上書きされてしまうためです。 どのループでも同じアドレスを参照しているので、このような挙動になります。

これの対策として、以下のように同じ名前の変数を新しく作ってその値を新しい変数としてループ内で定義しておくなどの対応が必要でした。

 ...
 for i, v := range list {
  i, v := i, v
  ...

期待通りの出力になっていますね。毎回、vのアドレスが異なっていることがわかります。

=== RUN   TestMain
index: 3, value: 7, address: 0x1400000e1a8
index: 1, value: 3, address: 0x1400000e198
index: 2, value: 5, address: 0x1400000e1a0
index: 0, value: 1, address: 0x1400000e190
--- PASS: TestMain (0.00s)
PASS

と、ここまで話しておいて残念なお知らせですが、この挙動について今からGo言語に触れる方には関係がないと言えるでしょう。 なぜならば、Go 1.22以降ではGoの挙動に変更が加えられ、上記のiやvなどの変数は各ループで違うものが与えられるようになりました。

func TestMain(t *testing.T) {
 done := make(chan bool)

 list := []int{1, 3, 5, 7}
 for i, v := range list {
  go func() {
   fmt.Printf("index: %d, value: %d, address: %p\n", i, v, &v)
   done <- true
  }()
 }

 for _ = range list {
  <-done
 }
}
=== RUN   TestMain
index: 3, value: 7, address: 0x1400000e1a8
index: 1, value: 3, address: 0x1400000e198
index: 2, value: 5, address: 0x1400000e1a0
index: 0, value: 1, address: 0x1400000e190
--- PASS: TestMain (0.00s)
PASS

ポインターも毎回異なっていることが確認できますね。 と、こんな感じで、とくに気にすることなくfor文を利用することができるようになりました。

正確にはGo 1.21でも上げる方法があったり、コルーチンの起動時に変数を渡す方法でも回避できたりなど、色々話したいこともありますが本題からそれるのでここらへんで。

Go のバージョンを上げても for の挙動が変わらない

さて、僕が入社して取り組んだタスクの1つに、一部のシステムのGoのバージョンアップデートがありました。

まさに、 Go 1.20からGo 1.22までまずは上げようという事になりました。 上記のfor文のことを考慮してリポジトリ内の数百個にわたるfor文をすべて確認し、 v, i := v, i に当たるような処理をすべて削除しました。

その後testを実行してみましたが、なんということでしょう。 for文の挙動でたくさんのtestがfailしているではありませんか。 手元のGoのバージョンを上げる、 toolchainのバージョンを上げる、 setup-goでgo1.22を指定したGitHub Actionsでテストをする、など試してもfor文の挙動が古いままになっています。

色々試したところ、 go.modでgoのディレクティブをgo 1.22に設定することで、ひとまず問題は解消しました。

なぜ、ディレクティブを上げないと挙動が変わらなかったのか?

仕事としては解決しましたが、この挙動の原因を知りたくなり、独自に調査しました。

まず、 go test の挙動を調べてみます。 go test は内部で大きく3つのことを行っています。

  1. *_test.go を解析し、仮のGoファイルを生成
  2. 仮のGoファイルをコンパイルし、実行ファイルを作成
  3. 実行ファイルを実行して結果を出力

では、 上記の1から見てみましょう。 まず、例として、 github.com/takanakahiko/hoge モジュール内で go test ./... をするとしましょう。 中間成果物として以下のようなファイル(例: /var/folders/123/xxxxxxxxxx/T/go-build3124567/b001/_testmain.go)が作成されます。

// Code generated by 'go test'. DO NOT EDIT.

package main

import (
 "os"

 "testing"
 "testing/internal/testdeps"

 _test "github.com/takanakahiko/hoge"


)

var tests = []testing.InternalTest{

 {"TestMain", _test.TestMain},

}

var benchmarks = []testing.InternalBenchmark{

}

var fuzzTargets = []testing.InternalFuzzTarget{

}

var examples = []testing.InternalExample{

}

func init() {

 testdeps.ImportPath = "github.com/takanakahiko/hoge"
}

func main() {
 m := testing.MainStart(testdeps.TestDeps{}, tests, benchmarks, fuzzTargets, examples)

 os.Exit(m.Run())

}

ちなみに go test -work ./... のように -work をつけることで中間成果物のディレクトリの場所を教えてくれます。 ここらへんで出力していますね。 https://github.com/golang/go/blob/c8fb6ae617d65b42089202040d8fbd309d1a0fe4/src/cmd/go/internal/work/action.go#L303

また通常、中間成果物は処理終了後に削除されますが、-work オプションを付けると残ります。

go test の内部では、次にそのgoファイルを実行するためにコンパイラソースコードの情報を渡し実行ファイルをビルドする処理が行われます。

goはコンパイル機能を直接 go test が持っているわけではなく、 go/pkg/tool/<arch>/compileコンパイル処理を担当します。 よって、その実行ファイルへコンパイル処理を依頼するActorが go test 内部で動作します。

このとき、-lang=go1.xx というパラメーターを生成する処理が重要なポイントとなります。

https://github.com/golang/go/blob/c8fb6ae617d65b42089202040d8fbd309d1a0fe4/src/cmd/go/internal/work/gc.go#L70-L81

通常(go run など)の場合、 gover.Local() がインストールされているGoのバージョンを基に -lang=go1.22 のように指定します。 しかし、 go test の場合、 github.com/takanakahiko/hogeコンパイルするときにgo.modのgoディレクティブに設定された値(p.Module.GoVersion)を参照して、 -lang=go1.xx が決定されます。

なぜかというと、モジュールが直接実行される訳ではないためであると思われます。

今回の例では go test は今回の作業リポジトリであるモジュール github.com/takanakahiko/hoge をインポートしている別のモジュール(main という特別な?モジュール)をエントリーポイントとしています。 一方で、 go run main.go のように直接実行する場合、 github.com/takanakahiko/hoge は他のモジュールからインポートされません。この点が大きな違いです。

ちなみに compiler 内部では、渡されたフラグがこんな感じで受け流されてFileVersionsを経由して最終的にここらへんでfor文の挙動が決定されます。

なぜこのような仕様なのか?

一見すると、このような仕様は複雑で分かりづらいものに思えてしまいます。 しかし、これはgoの互換性維持にとても必要な機能であるとも言えると思います。

たとえば、手元のGoを1.22以降にしても、依存モジュールがすべて1.22に対応しているとは限りません。 基本的に、Go 1.22にアップグレードしてもfor文の挙動が原因で壊れることは稀です。 ただし、Kubernetesの実装では、for文の変更によって2件ほどテストが失敗した事例があります。 (とはいえ、2件のみであれば「稀」と考えてよさそうです。)

このように、依存先モジュールのgo.modに設定されたバージョンと、自分のGoバージョンが異なる場合、意図しない挙動が発生することがあります。 そのため、インポートされているモジュールは、それぞれの go.mod に記載されたバージョンを基にコンパイルされる仕組みになっているということなのかなと思っています。

まとめ

手元のGoやtoolchainを最新バージョンにしても、指定したGoバージョンの機能が有効にならない場合があります。 go test の場合や、外部から参照されるモジュールを作成する場合は、 go.mod のバージョンを適切に管理することが必要となりそうです。

また、今回の調査を通じて理解した go testコンパイラの挙動についても解説しました。 Goの互換性を維持するための仕様は非常によく考えられており、Goを用いた開発の快適さを改めて実感しました。

以上となります。

アドベントカレンダーはまだ続きます。 明日の記事は Leona Yulianis さんによるイベントに関する記事らしいですね。楽しみです!

ぜひ引き続きお楽しみください!

adventar.org