GoProgrammingGo バックエンドデベロッパー

**Go** のテストパッケージ内で、サブテストの階層の `-parallel` フラグの制限を管理する同期プライミティブは何ですか?

Hintsage AIアシスタントで面接を突破

質問への回答。

歴史

Go テストフレームワークは、巨大なコードベースにおける CI パイプラインの長時間化に対処するために t.Parallel() を導入しました。マルチコアプロセッサの普及前は、テストはデフォルトで逐次実行されていました。プロジェクトが数千のテストにスケールすると、純粋な逐次実行はボトルネックになり、無制限の並列実行ではファイルディスクリプタやデータベース接続といったプロセスリソースを使い果たす可能性がありました。設計目標は、開発者が各テストスイートごとにワーカプールや複雑な同期を手動で管理する必要なく、グローバルな制限を尊重する組み込みのオプトイン並行モデルを提供することでした。

問題

開発者が t.Parallel() を呼び出すと、テストは他のテストと並行して実行できることをランナーに知らせる必要があります。ただし、フレームワークはリソースの欠乏を防ぐために厳格な並行性の上限を課す必要があります(デフォルトは GOMAXPROCS ですが、 -parallel フラグを介して設定可能です)。この課題はネストされたサブテストがある場合にさらに複雑になります:親テストが t.Run を複数回呼び出し、各サブテストが独自に t.Parallel() を呼び出す可能性があります。解決策は、親がすべての子孫が完了する前に実行スロットを解放しないようにし、深くネストされた平行サブテストが同じグローバルプールからスロットを正しく取得し、親をデッドロックさせたり上限を超えさせないようにする必要があります。

解決策

testing パッケージは、 -parallel フラグの値にサイズを調整された空の構造体のバッファーチャネル( chan struct{} )として実装されたセマフォを利用します。このチャネルはパッケージ内のすべてのテストで共有されます。各 T インスタンスは、この parallel チャネルと、親と調整するための内部 signal チャネルへの参照を保持します。

t.Parallel() が呼び出されると:

  1. signal チャネルが閉じられ、親の t.Run 呼び出しがブロックされ解除され、親はサブテストを並行して実行している間に続行または終了できます。
  2. 現在のゴルーチンは、 parallel セマフォチャネルに送信することでブロックされ、実行スロットを取得します。
  3. テスト関数がリターンし、すべての t.Cleanup フックが実行された後、テストランナー内の遅延関数がスロットを解放するために parallel チャネルから受信します。

階層に関しては、 t.Run は、サブテストが完全に完了するまで親ゴルーチンをブロックします。たとえサブテストが並行して実行されていても、これにより親はそのスロットを保持(または待機)し、サブテストの全ツリーが完了するまで待つことができ、深くネストされた平行テストの急増でグローバルな上限が超過されるのを防ぎます。

// テストパッケージ内部の概念モデル type T struct { parallel chan struct{} // 共有セマフォ signal chan struct{} // Parallel() が呼ばれたことを親に通知 parent *T wg sync.WaitGroup // サブテストの完了を待機 } func (t *T) Parallel() { // 親を続行させる close(t.signal) // グローバルプールからスロットを取得 t.parallel <- struct{}{} // テストの完了時にスロットを解放 t.Cleanup(func() { <-t.parallel }) } func (t *T) Run(name string, f func(t *T)) bool { t.wg.Add(1) sub := &T{parallel: t.parallel, signal: make(chan struct{})} go func() { defer t.wg.Done() f(sub) }() <-sub.signal // サブテストが開始するまで待つか、Parallelを呼ぶ t.wg.Wait() // 完了を待機 return !sub.Failed() }

実生活からの状況

コンテキスト

プラットフォームチームは、マイクロサービスアーキテクチャのための 2,000 の統合テストを含むモノレポを維持していました。各テストは、 PostgresRedis のためのエフェメラルな Docker コンテナを立ち上げました。テストを逐次実行するには 45 分を要し、迅速なフィードバックが不可能でした。しかし、 go test -parallel 100 を実行すると CI ランナーがカーネルの max_user_namespaces 制限を使い果たし、ホストがクラッシュしビルドキャッシュが壊れることになりました。

問題

チームは、カーネルの制限を尊重するためにコンテナ集約型のテストを 5 つの同時インスタンスに制限する必要がありましたが、最大スループットを確保するために純粋な単体テストは -parallel 32 で実行できる必要がありました。Go の標準テストパッケージは、同じ呼び出し内で異なるテストカテゴリに異なる上限を適用する方法を提供しておらず、単一のグローバル -parallel 値のみを受け入れます。

検討された解決策

  • Bazel を用いた外部オーケストレーション。* テストシャーディングやリソースタグ付けをサポートするため、Bazel への移行が提案されました(例: tags = ["resources:postgres:1"])。これにより、スケジューラが同時データベーステストの制限を正確に行うことができます。しかし、これにはビルドシステム全体の書き直しが必要で、 go test のシンプルさが失われてしまいました。学習曲線は急で、ローカル開発ワークフローが大きく変わり、Bazel のクエリ言語に不慣れな開発者の作業が遅くなる可能性がありました。

  • テストスイート内での手動セマフォ。* 開発者は、パッケージレベルの var dbSem = make(chan struct{}, 5) を追加し、すべての統合テストが開始時に手動で取得することを考慮しました。これにより、詳細な制御が可能になりましたが、ボイラープレートの増加と、セマフォを保持している間にテストがパニックを起こした場合のデッドロックのリスクが生じました。また、並行モデルが断片化され、一部のテストは -parallel フラグを尊重し、他のテストはカスタムセマフォを尊重し、デバッグを困難にし、一貫性のないリソースアカウンティングをもたらしました。

  • CI ステージによるビルドタグの分離。* チームはビルドタグを使用してテストを分離することを選択しました。すべてのコンテナ化されたテストに //go:build integration を追加し、単体テストにはタグを付けませんでした。 CI パイプラインは最初に go test -short -parallel 32 ./... を単体テスト用に実行し、その後別に go test -tags=integration -parallel 5 ./... を実行しました。これにより、既存の Go ツールチェーン機能を活用でき、テストロジックを変更することなく済みました。欠点は、単体テストと統合テスト間のパッケージ間並列性が失われ、両方のステージが逐次実行されることでした。しかし、単体テストは 3 分で完了するため、合計時間(3m + 20m)は許容可能で安定していました。

選択した解決策と結果

彼らはビルドタグの分離を選びました。ファイルヘッダーへのタグ追加のみの最小限のコード変更で済み、カスタム同期なしで標準の testing パッケージのセマフォを自然に利用しました。 CI は安定し、カーネルの制限が尊重され、開発者はデバッグ用に go test -tags=integration -parallel 4 をローカルで実行できるようになりました。CI の全体の時間は 45 分から 23 分に短縮され、ホストのクラッシュは完全に無くなりました。

候補者が見逃しがちなこと

なぜ t.Parallel() を呼び出した後にゴルーチンを生成すると、そのゴルーチンが誤ったテストの出力にログを記録したり、パニックを起こすことがあるのでしょうか?

t.Parallel() が呼び出されると、現在のテストゴルーチンはセマフォでブロックされ、親のテストランナーは次のテストに進みます。しかし、生成されたゴルーチンは T インスタンスを継承します。メインテスト関数がリターンし、ゴルーチンがまだ実行されている場合、テストパッケージは T を終了済みとマークし、その出力バッファを閉じます。オーファンゴルーチンからの t.Log または t.Error への後続の呼び出しは、「テストが完了した後にゴルーチンでログを記録する」とパニックを引き起こす可能性があります。正しいアプローチは、 sync.WaitGroup を使ってゴルーチンの完了を同期するか、t.Cleanup が彼を待つことを確実にすることです。なぜなら、 t.Parallel() はデタッチされたゴルーチンを自動的に待機するわけではなく、テスト関数のライフサイクルをランナーと調整するだけだからです。

どのようにしてテストパッケージは親テストが、いくつかは t.Parallel() を呼び出すかもしれないすべてのサブテストが実行を完了する前にその並列スロットを解放できないようにしていますか?

T 構造体は sync.WaitGroup を埋め込んでいます。サブテストを作成するために t.Run が呼び出されると、親はサブテストゴルーチンを起動する前に t.wg.Add(1) を呼び出し、サブテストは完了時に遅延関数内で t.wg.Done() を呼び出します。重要なのは、サブテストが自身で t.Parallel() を呼び出す場合、親の WaitGroup が直ちにデクリメントされ(親が自分の関数本体を完了する可能性を許可します)、親テストの全体の完了、すなわちそのセマフォトークンのリリースは、クリーンアップチェーンの最終的な t.wg.Wait() によってブロックされます。これにより、ルート並列テストがスロットを保持し、系列的および並列のサブテスト全体が結論するまで適切に並列テストの制限を維持できるようになります。