[インデックス 14504] ファイルの概要
このコミットは、Go言語の標準ライブラリである net パッケージ内の timeout_test.go ファイルにおける、不安定な(flaky)テストの修正に関するものです。具体的には、ネットワーク接続の確立とクローズに関連する競合状態を解消し、テストの信頼性を向上させています。
コミット
commit d6fd52c088ccdcfcb4fd860a18a15d90300ed18c
Author: Dmitriy Vyukov <dvyukov@google.com>
Date: Tue Nov 27 12:18:54 2012 +0400
net: fix flaky test
The test failed on one of the builders with:
timeout_test.go:594: ln.Accept: accept tcp 127.0.0.1:19373: use of closed network connection
http://build.golang.org/log/e83f4a152b37071b9d079096e15913811ad296b5
R=golang-dev, bradfitz, dave, mikioh.mikioh, remyoudompheng, rsc
CC=golang-dev
https://golang.org/cl/6859043
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/d6fd52c088ccdcfcb4fd860a18a15d90300ed18c
元コミット内容
このコミットは、net パッケージの timeout_test.go 内のテストが、ビルド環境の一つで不安定に失敗するという問題を修正するものです。具体的なエラーメッセージは「ln.Accept: accept tcp 127.0.0.1:19373: use of closed network connection」であり、これはリスナーが接続を受け入れる前にクローズされてしまう競合状態を示唆しています。この修正は、ln.Accept() が実際に接続を受け入れたことを、クライアント側の DialTCP が接続を試みる前に確実に通知するための同期メカニズムを追加することで、この競合状態を解消します。
変更の背景
Go言語のテストスイートは、様々な環境で実行されるため、環境依存の競合状態やタイミングの問題に起因する「不安定なテスト(flaky test)」が発生することがあります。このコミットで修正された問題もその典型例です。
timeout_test.go 内の TestProlongTimeout 関数は、ネットワーク接続のタイムアウト動作をテストすることを目的としています。このテストでは、ローカルリスナー(ln)を作成し、ゴルーチン内でそのリスナーが接続(ln.Accept())を受け入れるのを待ちます。同時に、メインのテストゴルーチンでは、そのリスナーに対して接続を試みます(DialTCP)。
問題は、ln.Accept() が接続を受け入れる準備が整う前に、メインゴルーチンが DialTCP を実行し、その結果としてリスナーが予期せずクローズされてしまう可能性があったことです。特に、テスト実行環境のCPU負荷やスケジューリングのタイミングによっては、この競合状態が発生しやすくなります。エラーメッセージ「use of closed network connection」は、まさにこの状況、つまり ln.Accept() が実行される前にリスナーがクローズされたことを示しています。
この不安定なテストは、継続的インテグレーション(CI)システムにおいてビルドの失敗を引き起こし、開発者の生産性を低下させるため、早急な修正が必要とされました。
前提知識の解説
このコミットの理解には、以下のGo言語の概念とネットワークプログラミングの基礎知識が役立ちます。
-
Go言語の並行処理:
- Goroutine(ゴルーチン): Go言語における軽量なスレッドのようなものです。
goキーワードを使って関数を呼び出すことで、新しいゴルーチンが生成され、並行して実行されます。 - Channel(チャネル): ゴルーチン間で値を送受信するための通信メカニズムです。チャネルは、Goにおける並行処理の同期と通信の主要な手段であり、競合状態を避けるために使用されます。チャネルへの送信(
ch <- value)と受信(<-ch)は、デフォルトでブロックします。これにより、ゴルーチン間の同期が保証されます。
- Goroutine(ゴルーチン): Go言語における軽量なスレッドのようなものです。
-
ネットワークプログラミングの基礎:
- TCP/IP: インターネットの基盤となる通信プロトコル群です。TCPは信頼性の高いコネクション指向のプロトコルであり、データの順序性や再送を保証します。
- Listener(リスナー): サーバー側で特定のネットワークアドレス(IPアドレスとポート番号)からの接続要求を待ち受けるオブジェクトです。Go言語の
net.Listen関数などで作成されます。 - Accept(接続受け入れ): リスナーがクライアントからの接続要求を受け入れ、新しいネットワーク接続(
net.Conn)を確立する操作です。この操作は、接続が確立されるまでブロックすることが一般的です。 - Dial(接続確立): クライアント側でサーバーのネットワークアドレスに対して接続を試みる操作です。Go言語の
net.Dial関数などで実行されます。 - Race Condition(競合状態): 複数のゴルーチンが共有リソース(この場合はネットワークリスナーや接続状態)に同時にアクセスしようとした際に、そのアクセス順序によって結果が非決定的に変わってしまう状態を指します。不安定なテストの主な原因の一つです。
-
テストにおける同期:
- 並行処理を含むテストでは、特定のイベントが発生したことを別のゴルーチンに通知し、そのイベントを待つための同期メカニズムが不可欠です。チャネルは、このような同期に非常に適しています。
技術的詳細
この修正は、TestProlongTimeout テスト関数におけるクライアント(DialTCP)とサーバー(ln.Accept())間のタイミングの問題を解決するために、Goのチャネルを用いた同期メカニズムを導入しています。
元のコードでは、リスナーの Accept 呼び出しがゴルーチン内で非同期に実行され、その直後にメインゴルーチンが DialTCP を呼び出していました。理想的には、Accept が接続を待ち受けている間に DialTCP が接続を確立し、Accept が成功するという流れです。しかし、特定の条件下では、以下の競合状態が発生する可能性がありました。
- メインゴルーチンが
DialTCPを呼び出す。 DialTCPが接続を試みる。- その間に、テストの終了処理(
defer ln.Close()など)が実行され、リスナーがクローズされてしまう。 - ゴルーチン内の
ln.Accept()が、クローズされたリスナーに対してAcceptを試み、エラー「use of closed network connection」が発生する。
この問題を解決するため、以下の変更が加えられました。
-
connectedチャネルの導入:connected := make(chan bool)- これは、バッファなしのブール型チャネルです。バッファなしチャネルは、送信側と受信側が同時に準備ができたときにのみ通信が成立し、それまでブロックするという特性があります。これにより、厳密な同期が実現されます。
-
サーバー側ゴルーチンでの送信:
connected <- trueln.Accept()が正常に接続を受け入れた直後に、このチャネルにtrueを送信します。これにより、サーバー側が接続準備ができたことを通知します。
-
クライアント側での受信:
<-connectedDialTCPが接続を確立した後、メインゴルーチンはconnectedチャネルから値を受信しようとします。サーバー側がconnected <- trueを実行するまで、ここでブロックします。
この同期メカニズムにより、以下の実行順序が保証されます。
newLocalListener(t)でリスナーが作成される。- ゴルーチンが起動し、
ln.Accept()を呼び出して接続を待ち始める。 DialTCPが接続を確立する。ln.Accept()がDialTCPからの接続を受け入れ、成功する。ln.Accept()が成功した直後に、サーバー側ゴルーチンがconnected <- trueを実行し、チャネルに値を送信する。- メインゴルーチンが
<-connectedでチャネルから値を受信し、ブロックが解除される。 - これで、クライアントとサーバー間の接続が完全に確立され、テストの残りの部分が安全に実行されることが保証される。
この修正により、ln.Accept() がクローズされた接続に対して呼び出されるという競合状態が解消され、テストの信頼性が向上しました。
コアとなるコードの変更箇所
変更は src/pkg/net/timeout_test.go ファイルに集中しています。
--- a/src/pkg/net/timeout_test.go
+++ b/src/pkg/net/timeout_test.go
@@ -588,8 +588,10 @@ func TestProlongTimeout(t *testing.T) {
ln := newLocalListener(t)
defer ln.Close()
+ connected := make(chan bool) // 追加: 同期用チャネルの宣言
go func() {
s, err := ln.Accept()
+ connected <- true // 追加: Accept成功後にチャネルに送信
if err != nil {
t.Fatalf("ln.Accept: %v", err)
}
@@ -619,6 +621,7 @@ func TestProlongTimeout(t *testing.T) {
t.Fatalf("DialTCP: %v", err)
}
defer c.Close()
+ <-connected // 追加: チャネルからの受信を待つ
for i := 0; i < 1024; i++ {
var buf [1]byte
c.Write(buf[:])
コアとなるコードの解説
-
connected := make(chan bool):TestProlongTimeout関数の冒頭で、connectedという名前のバッファなしチャネルが作成されます。このチャネルは、サーバー側(ln.Accept()を実行するゴルーチン)とクライアント側(DialTCPを実行するメインゴルーチン)の間の同期プリミティブとして機能します。
-
go func() { ... }内のconnected <- true:- リスナーの
Accept()メソッドが正常に接続を受け入れた後、つまりs, err := ln.Accept()がエラーなく完了した直後に、trueの値がconnectedチャネルに送信されます。この送信操作は、チャネルから値を受信する準備ができているゴルーチンが存在するまでブロックします。これにより、サーバー側が接続を受け入れる準備ができたことをクライアント側に通知する役割を果たします。
- リスナーの
-
メインゴルーチン内の
<-connected:DialTCPを呼び出してクライアント接続cが確立された後、メインゴルーチンはconnectedチャネルから値を受信しようとします。この受信操作は、チャネルに値が送信されるまでブロックします。つまり、サーバー側のln.Accept()が成功し、connected <- trueが実行されるまで、メインゴルーチンはここで待機します。
これらの変更により、DialTCP が接続を試みる前に ln.Accept() が接続を受け入れる準備が整っていることが保証され、テストが不安定になる原因となっていた競合状態が解消されます。
関連リンク
- Go言語の並行処理に関する公式ドキュメント: https://go.dev/tour/concurrency/1
- Go言語の
netパッケージに関する公式ドキュメント: https://pkg.go.dev/net - Go言語のテストに関する公式ドキュメント: https://go.dev/blog/testing
- Go言語におけるチャネルの利用例: https://go.dev/tour/concurrency/2
参考にした情報源リンク
- Go言語の公式ドキュメント
- Go言語のソースコード(特に
src/pkg/net/ディレクトリ内のテストファイル) - Go言語のビルドシステムとCIに関する一般的な知識
- 競合状態と並行処理の同期に関する一般的なプログラミングの概念