Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

[インデックス 17366] ファイルの概要

このコミットは、src/pkg/net/dialgoogle_test.go ファイルに影響を与えています。このファイルは、Go言語の標準ライブラリである net パッケージのテストコードの一部であり、特にDNSルックアップに関連するテストが含まれています。

コミット

このコミットは、net パッケージ内の TestDNSThreadLimit テストにおける競合状態(race condition)を修正するものです。具体的には、ゴルーチン内でループ変数が意図せず共有されることによって発生していた問題を解決しています。

GitHub上でのコミットページへのリンク

https://github.com/golang/go/commit/910a6faa93c6c003ff71ca40500ec03f5a54bd51

元コミット内容

net: fix race in TestDNSThreadLimit

R=golang-dev, bradfitz
CC=golang-dev
https://golang.org/cl/13141045

変更の背景

このコミットの背景には、Go言語の並行処理モデルにおける一般的な落とし穴、すなわち「ループ変数のクロージャキャプチャ」があります。TestDNSThreadLimit テストは、多数のDNSルックアップを並行して実行し、Goランタイムが同時に処理できるDNSスレッドの数に制限があることをテストすることを目的としていました。

元のコードでは、for ループ内でゴルーチンを起動し、そのゴルーチン内でループ変数 i を使用していました。しかし、Goのクロージャは、変数をその値ではなく「参照」としてキャプチャします。このため、ゴルーチンが実際に実行されるときには、i の値がループの最終値に達している可能性があり、複数のゴルーチンが同じ i の値(または予期しない値)で LookupIP を呼び出すという競合状態が発生していました。

この競合状態は、テストの信頼性を損ない、テストが不安定になる(intermittent failures)原因となっていました。テストが不安定であると、実際のバグではないにもかかわらずCI/CDパイプラインが失敗したり、開発者が問題の特定に時間を費やしたりすることになります。そのため、この競合状態を修正し、テストの信頼性を向上させることが必要でした。

前提知識の解説

Go言語の並行処理(GoroutinesとChannels)

Go言語は、並行処理を言語レベルでサポートしており、その中心となるのが「ゴルーチン(Goroutine)」と「チャネル(Channel)」です。

  • ゴルーチン: ゴルーチンは、Goランタイムによって管理される軽量なスレッドのようなものです。go キーワードを関数呼び出しの前に置くことで簡単に起動でき、数万、数十万といった多数のゴルーチンを同時に実行することが可能です。OSのスレッドよりもはるかにオーバーヘッドが小さく、Goランタイムが効率的にスケジューリングを行います。
  • チャネル: チャネルは、ゴルーチン間で値を安全に送受信するための通信メカニズムです。チャネルを使用することで、共有メモリを直接操作することなく、ゴルーチン間でデータをやり取りし、同期を取ることができます。これにより、競合状態の発生を抑制し、並行処理の安全性を高めます。

競合状態(Race Condition)

競合状態とは、複数の並行に実行される処理(この場合はゴルーチン)が共有リソース(この場合はループ変数 i)にアクセスし、そのアクセス順序によって結果が非決定的に変わってしまう状態を指します。特に、少なくとも1つの処理が共有リソースを書き換える場合に発生しやすくなります。

今回のケースでは、for ループ内で起動された複数のゴルーチンが、ループ変数 i を共有していました。ゴルーチンが起動されてから実際に実行されるまでの間に i の値が変化してしまうため、各ゴルーチンが期待する i の値で LookupIP を呼び出さない可能性がありました。

DNSルックアップと net パッケージ

Go言語の net パッケージは、ネットワークI/Oのプリミティブを提供します。これには、TCP/UDP接続、IPアドレスの解決(DNSルックアップ)、HTTPクライアント/サーバーなどが含まれます。

  • LookupIP: この関数は、指定されたホスト名に対応するIPアドレスを解決するために使用されます。内部的には、OSのDNSリゾルバやGoランタイムが持つDNSキャッシュなどを利用して名前解決を行います。
  • TestDNSThreadLimit: このテストは、GoランタイムがDNSルックアップのために起動する内部スレッドの数に制限があることを検証するためのものです。多数のDNSルックアップを並行して実行し、システムリソースの枯渇やデッドロックが発生しないことを確認します。

技術的詳細

問題の核心は、Goのクロージャが外部スコープの変数を「参照渡し」でキャプチャするという挙動にあります。

元のコードは以下のようになっていました。

for i := 0; i < N; i++ {
    go func() {
        LookupIP(fmt.Sprintf("%d.net-test.golang.org", i))
        c <- 1
    }()
}

このコードでは、for ループが高速にイテレーションを進める一方で、go func() { ... }() で起動されたゴルーチンは、Goランタイムのスケジューリングによって非同期に実行されます。ゴルーチンが実際に実行される時点では、i の値はすでにループの次のイテレーションに進んでいるか、あるいはループが終了して最終値になっている可能性があります。

例えば、N=10000 の場合、最初のゴルーチンが起動された直後に i0 であったとしても、そのゴルーチンが LookupIP を呼び出す前に i12、...、あるいは 9999 にまで進んでしまう可能性があります。これにより、複数のゴルーチンが同じ i の値(例えば 9999)を使って LookupIP を呼び出すことになり、テストの意図(各ゴルーチンがユニークなホスト名をルックアップすること)が損なわれ、競合状態が発生していました。

この競合状態は、テストが期待するDNSルックアップのパターンを乱し、テストの失敗やハングアップを引き起こす可能性がありました。

コアとなるコードの変更箇所

--- a/src/pkg/net/dialgoogle_test.go
+++ b/src/pkg/net/dialgoogle_test.go
@@ -62,10 +62,10 @@ func TestDNSThreadLimit(t *testing.T) {
 	const N = 10000
 	c := make(chan int, N)
 	for i := 0; i < N; i++ {
-		go func() {
+		go func(i int) {
 			LookupIP(fmt.Sprintf("%d.net-test.golang.org", i))
 			c <- 1
-		}()
+		}(i)
 	}
 	// Don't bother waiting for the stragglers; stop at 0.9 N.
 	for i := 0; i < N*9/10; i++ {

コアとなるコードの解説

変更は非常にシンプルですが、Goの並行処理における重要なイディオムを適用しています。

元のコード:

go func() {
    LookupIP(fmt.Sprintf("%d.net-test.golang.org", i))
    c <- 1
}()

修正後のコード:

go func(i int) { // ループ変数 i を引数として受け取るように変更
    LookupIP(fmt.Sprintf("%d.net-test.golang.org", i))
    c <- 1
}(i) // ゴルーチン起動時に現在の i の値を引数として渡す

この変更のポイントは、ゴルーチンを起動する際に、ループ変数 i の「現在の値」をゴルーチン関数の引数として明示的に渡している点です。

  1. go func(i int) { ... }: 無名関数(クロージャ)が i という名前の整数型の引数を受け取るように定義されました。
  2. }(i): ゴルーチンを起動する際に、for ループの現在のイテレーションにおける i の値を、この無名関数の引数 i として渡しています。

これにより、各ゴルーチンは起動された時点での i の値の「コピー」を受け取ります。このコピーは、ゴルーチン自身のスコープ内で独立した変数となるため、for ループの i がその後変化しても、ゴルーチン内部の i の値には影響を与えません。結果として、各ゴルーチンは意図したユニークなホスト名(例: 0.net-test.golang.org, 1.net-test.golang.org, ...)で LookupIP を呼び出すことが保証され、競合状態が解消されます。

このパターンは、Go言語でループ内でゴルーチンを起動する際のベストプラクティスとして広く知られています。

関連リンク

参考にした情報源リンク