[インデックス 12476] ファイルの概要
このコミットは、Go言語の標準ライブラリである net
パッケージのテストコード src/pkg/net/unicast_test.go
における、ローカルポートの利用方法に関する問題を修正するものです。具体的には、テストで使用する利用可能なローカルポートを特定する既存のメカニズムが、特定のWindows 7環境で競合状態を引き起こし、テストの失敗につながる問題を解決しています。
コミット
commit a385f38dfa0e05ef51422e2910e0928062258339
Author: Russ Cox <rsc@golang.org>
Date: Wed Mar 7 12:06:22 2012 -0500
net: delete usableLocalPort from test
The old way to find a port was to listen :0 and then
look at what port it picked, close the listener, and then
immediately try to listen on that port.
On some Windows 7 machines that sequence fails at
the second listen, because the first one is still lingering
in the TCP/IP stack somewhere. (Ironically, most of these
are used in tests of a "second listen", which in this case
ends up being the third listen.)
Instead of this race, just return the listener from the
function, replacing usableLocalPort+Listen with
usableListenPort.
Fixes #3219.
R=golang-dev, bradfitz
CC=golang-dev
https://golang.org/cl/5769045
GitHub上でのコミットページへのリンク
https://github.com/golang/go/commit/a385f38dfa0e05ef51422e2910e0928062258339
元コミット内容
このコミットは、src/pkg/net/unicast_test.go
ファイルから usableLocalPort
関数を削除し、その代わりに usableListenPort
および usableListenPacketPort
関数を導入することで、テストにおけるローカルポートの取得方法を変更しています。これにより、ポートの再利用に関する競合状態を回避し、テストの信頼性を向上させています。
変更の背景
Go言語の net
パッケージのテストでは、動的に利用可能なローカルポートを見つけるために、まずアドレス :0
でリスナー(TCPの場合は net.Listen
、UDPの場合は net.ListenPacket
)を作成し、OSが割り当てたポート番号を取得していました。その後、そのリスナーをすぐに閉じ、取得したポート番号を使って再度リスナーを作成するという手順を踏んでいました。
しかし、この一連の操作が特定のWindows 7環境で問題を引き起こしていました。具体的には、最初のリスナーを閉じた後も、そのソケットがTCP/IPスタック内でしばらくの間「ぶら下がった」状態(例えば TIME_WAIT
状態)になることがあり、その結果、同じポート番号で即座に2回目のリスナーを作成しようとすると失敗するという競合状態が発生していました。
この問題は、特に「2回目のリスナー作成」をテストするシナリオで顕著でした。本来のテスト目的は、既に使われているポートで再度リスナーを作成しようとした場合の挙動を確認することでしたが、ポートの取得方法自体が不安定であるため、テストが意図せず失敗してしまうことがありました。この不安定性は、テストの信頼性を損ない、開発者が実際のバグとテスト環境の問題を区別することを困難にしていました。
この問題を解決するため、usableLocalPort
関数を削除し、ポートの取得とリスナーの作成を単一の操作として行う新しいヘルパー関数を導入することで、この競合状態を根本的に回避することが目的とされました。
前提知識の解説
このコミットの変更内容を理解するためには、以下のネットワークおよびGo言語に関する基本的な知識が必要です。
- TCP/IPソケットプログラミングの基本:
- ポート番号: ネットワーク通信において、アプリケーションを識別するために使用される論理的な番号。0から65535までの範囲があり、特に1024未満は特権ポートとされることが多いです。
- ソケット: ネットワーク通信のエンドポイント。IPアドレスとポート番号の組み合わせで一意に識別されます。
- リスナー (Listener): サーバーアプリケーションがクライアントからの接続要求を待ち受けるために使用するソケット。TCPでは
net.Listener
、UDPではnet.PacketConn
がこれに相当します。 Listen
/ListenPacket
: 指定されたネットワークアドレスとポートで接続を待ち受けるソケットを作成する操作。ポート番号に0
を指定すると、OSが利用可能なポートを自動的に割り当てます。Close
: 開いているソケットやリスナーを閉じる操作。これにより、関連するリソースが解放されます。
- TCPの
TIME_WAIT
状態:- TCP接続が終了する際、通常はFINパケットの交換が行われます。接続を閉じた側のソケットは、相手からの最後のACKパケットが確実に届くように、しばらくの間
TIME_WAIT
状態にとどまります。この状態の間、そのソケットが使用していたポート番号は、同じIPアドレスとポート番号の組み合わせで新しい接続を開始するために再利用できない場合があります。 TIME_WAIT
状態の目的は、遅延パケットが新しい接続に誤って配信されるのを防ぐこと、およびリモートエンドポイントが接続を正常に終了したことを確認することです。- この状態の持続時間はOSによって異なり、通常は数秒から数分です。Windows環境では、この状態が比較的長く続くことがあり、今回の問題の根本原因となりました。
- TCP接続が終了する際、通常はFINパケットの交換が行われます。接続を閉じた側のソケットは、相手からの最後のACKパケットが確実に届くように、しばらくの間
- Go言語の
net
パッケージ:- Go言語の標準ライブラリで、ネットワークI/O機能を提供します。TCP/UDPソケットの作成、接続、データの送受信など、低レベルなネットワーク操作を抽象化して提供します。
net.Listen(network, address string)
: 指定されたネットワーク(例: "tcp", "tcp4", "tcp6")とアドレスでリスナーを作成します。アドレスにポート番号が含まれていない場合や:0
が指定された場合、OSが自動的にポートを割り当てます。net.ListenPacket(network, address string)
: UDPなどのパケット指向ネットワークでパケットコネクションを作成します。net.Listener.Addr()
/net.PacketConn.LocalAddr()
: リスナーまたはパケットコネクションがバインドされているローカルネットワークアドレスを返します。このアドレスからポート番号を抽出できます。net.SplitHostPort(hostport string)
: "host:port" 形式の文字列をホストとポートに分割します。
- Go言語のテストフレームワーク:
testing
パッケージ: Go言語に組み込まれているテストフレームワーク。*testing.T
: テスト関数に渡される構造体で、テストの実行制御、エラー報告、ログ出力などの機能を提供します。t.Fatalf(...)
: テストを失敗としてマークし、メッセージを出力してテストの実行を停止します。
- 競合状態 (Race Condition):
- 複数のプロセスやスレッドが共有リソースにアクセスする際に、そのアクセス順序によって結果が非決定的に変わってしまう状態。今回のケースでは、ポートの解放と再利用のタイミングがOSの内部状態に依存し、テストの成功/失敗が非決定的に変化していました。
技術的詳細
このコミットの技術的な核心は、テストにおける「利用可能なローカルポートの取得」ロジックの変更にあります。
変更前のアプローチ (usableLocalPort
関数):
net.Listen(net, laddr+":0")
またはnet.ListenPacket(net, laddr+":0")
を呼び出し、OSに利用可能なポートを割り当てさせる。- 割り当てられたリスナー(またはパケットコネクション)の
Addr()
メソッドから、実際に割り当てられたポート番号を抽出する。 - 抽出後、すぐにリスナーを
Close()
する。 - 抽出したポート番号を文字列として呼び出し元に返す。
- 呼び出し元では、返されたポート番号を使って再度
net.Listen
またはnet.ListenPacket
を呼び出す。
このアプローチの問題点は、ステップ3でリスナーを Close()
した直後に、ステップ5で同じポートを再利用しようとすることです。特にWindows環境では、ソケットが TIME_WAIT
状態に移行するのに時間がかかったり、その状態が長く維持されたりすることがあり、その結果、ポートがまだ完全に解放されていないとOSが判断し、2回目の Listen
呼び出しが address already in use
のようなエラーで失敗することがありました。これは、テストが意図する「ポートが利用可能であること」の確認ではなく、OSのネットワークスタックの挙動に依存した不安定なテストになっていました。
変更後のアプローチ (usableListenPort
および usableListenPacketPort
関数):
net.Listen(net, laddr+":0")
またはnet.ListenPacket(net, laddr+":0")
を呼び出し、OSに利用可能なポートを割り当てさせる。- 割り当てられたリスナー(またはパケットコネクション)の
Addr()
メソッドから、実際に割り当てられたポート番号を抽出する。 - リスナーを
Close()
せずに、割り当てられたリスナーと抽出したポート番号の両方を呼び出し元に返す。 - 呼び出し元では、返されたリスナーをそのまま使用し、テストの目的が完了した後に
defer l.Close()
などで適切にクローズする。
この新しいアプローチにより、ポートの取得と最初のリスナーの作成が単一の原子的な操作として扱われます。ポートがOSによって割り当てられ、リスナーがアクティブな状態のまま呼び出し元に渡されるため、ポートが「ぶら下がった」状態になることによる競合状態が完全に回避されます。テストは、実際にアクティブなリスナーとそれに関連付けられたポートを使って、その後のロジック(例: 同じポートでの2回目のリスナー作成試行)を検証できるようになります。
これにより、テストの信頼性が向上し、特定のOS環境に依存しない安定したテスト実行が可能になります。
コアとなるコードの変更箇所
変更は src/pkg/net/unicast_test.go
ファイルに集中しています。
--- a/src/pkg/net/unicast_test.go
+++ b/src/pkg/net/unicast_test.go
@@ -56,11 +56,7 @@ func TestTCPListener(t *testing.T) {
if tt.ipv6 && !supportsIPv6 {
continue
}
- port := usableLocalPort(t, tt.net, tt.laddr)
- l1, err := Listen(tt.net, tt.laddr+":"+port)
- if err != nil {
- t.Fatalf("First Listen(%q, %q) failed: %v", tt.net, tt.laddr+":"+port, err)
- }
+ l1, port := usableListenPort(t, tt.net, tt.laddr)
checkFirstListener(t, tt.net, tt.laddr+":"+port, l1)
l2, err := Listen(tt.net, tt.laddr+":"+port)
checkSecondListener(t, tt.net, tt.laddr+":"+port, err, l2)
@@ -105,11 +101,7 @@ func TestUDPListener(t *testing.T) {
continue
}
tt.net = toudpnet(tt.net)
- port := usableLocalPort(t, tt.net, tt.laddr)
- l1, err := ListenPacket(tt.net, tt.laddr+":"+port)
- if err != nil {
- t.Fatalf("First ListenPacket(%q, %q) failed: %v", tt.net, tt.laddr+":"+port, err)
- }
+ l1, port := usableListenPacketPort(t, tt.net, tt.laddr)
checkFirstListener(t, tt.net, tt.laddr+":"+port, l1)
l2, err := ListenPacket(tt.net, tt.laddr+":"+port)
checkSecondListener(t, tt.net, tt.laddr+":"+port, err, l2)
@@ -138,11 +130,7 @@ func TestSimpleTCPListener(t *testing.T) {
if tt.ipv6 {
continue
}
- port := usableLocalPort(t, tt.net, tt.laddr)
- l1, err := Listen(tt.net, tt.laddr+":"+port)
- if err != nil {
- t.Fatalf("First Listen(%q, %q) failed: %v", tt.net, tt.laddr+":"+port, err)
- }
+ l1, port := usableListenPort(t, tt.net, tt.laddr)
checkFirstListener(t, tt.net, tt.laddr+":"+port, l1)
l2, err := Listen(tt.net, tt.laddr+":"+port)
checkSecondListener(t, tt.net, tt.laddr+":"+port, err, l2)
@@ -177,11 +165,7 @@ func TestSimpleUDPListener(t *testing.T) {
continue
}
tt.net = toudpnet(tt.net)
- port := usableLocalPort(t, tt.net, tt.laddr)
- l1, err := ListenPacket(tt.net, tt.laddr+":"+port)
- if err != nil {
- t.Fatalf("First ListenPacket(%q, %q) failed: %v", tt.net, tt.laddr+":"+port, err)
- }
+ l1, port := usableListenPacketPort(t, tt.net, tt.laddr)
checkFirstListener(t, tt.net, tt.laddr+":"+port, l1)
l2, err := ListenPacket(tt.net, tt.laddr+":"+port)
checkSecondListener(t, tt.net, tt.laddr+":"+port, err, l2)
@@ -276,12 +260,8 @@ func TestDualStackTCPListener(t *testing.T) {
if tt.xerr == nil {
tt.xerr = nil
}
- }
- port := usableLocalPort(t, tt.net1, tt.laddr1)
+ }
+ l1, port := usableListenPort(t, tt.net1, tt.laddr1)
laddr := tt.laddr1 + ":" + port
- l1, err := Listen(tt.net1, laddr)
- if err != nil {
- t.Fatalf("First Listen(%q, %q) failed: %v", tt.net1, laddr, err)
- }
checkFirstListener(t, tt.net1, laddr, l1)
laddr = tt.laddr2 + ":" + port
l2, err := Listen(tt.net2, laddr)
@@ -327,12 +307,8 @@ func TestDualStackUDPListener(t *testing.T) {
if tt.xerr == nil {
tt.xerr = nil
}
- }
- port := usableLocalPort(t, tt.net1, tt.laddr1)
+ }
+ l1, port := usableListenPacketPort(t, tt.net1, tt.laddr1)
laddr := tt.laddr1 + ":" + port
- l1, err := ListenPacket(tt.net1, laddr)
- if err != nil {
- t.Fatalf("First ListenPacket(%q, %q) failed: %v", tt.net1, laddr, err)
- }
checkFirstListener(t, tt.net1, laddr, l1)
laddr = tt.laddr2 + ":" + port
l2, err := ListenPacket(tt.net2, laddr)
@@ -341,29 +317,44 @@ func TestDualStackUDPListener(t *testing.T) {
}
}
-func usableLocalPort(t *testing.T, net, laddr string) string {
+func usableListenPort(t *testing.T, net, laddr string) (l Listener, port string) {
var nladdr string
+ var err error
switch net {
+ default:
+ panic("usableListenPort net=" + net)
case "tcp", "tcp4", "tcp6":
- l, err := Listen(net, laddr+":0")
+ l, err = Listen(net, laddr+":0")
if err != nil {
t.Fatalf("Probe Listen(%q, %q) failed: %v", net, laddr, err)
}
- defer l.Close()
nladdr = l.(*TCPListener).Addr().String()
+ }
+ _, port, err = SplitHostPort(nladdr)
+ if err != nil {
+ t.Fatalf("SplitHostPort failed: %v", err)
+ }
+ return l, port
+}
+
+func usableListenPacketPort(t *testing.T, net, laddr string) (l PacketConn, port string) {
+ var nladdr string
+ var err error
+ switch net {
+ default:
+ panic("usableListenPacketPort net=" + net)
case "udp", "udp4", "udp6":
- c, err := ListenPacket(net, laddr+":0")
+ l, err = ListenPacket(net, laddr+":0")
if err != nil {
t.Fatalf("Probe ListenPacket(%q, %q) failed: %v", net, laddr, err)
}
- defer c.Close()
- nladdr = c.(*UDPConn).LocalAddr().String()
+ nladdr = l.(*UDPConn).LocalAddr().String()
}
- _, port, err := SplitHostPort(nladdr)
+ _, port, err = SplitHostPort(nladdr)
if err != nil {
t.Fatalf("SplitHostPort failed: %v", err)
}
- return port
+ return l, port
}
func differentWildcardAddr(i, j string) bool {
@@ -535,15 +526,11 @@ func TestProhibitionaryDialArgs(t *testing.T) {
return
}
- port := usableLocalPort(t, "tcp", "[::]")
- l, err := Listen("tcp", "[::]"+":"+port)
- if err != nil {
- t.Fatalf("Listen failed: %v", err)
- }
+ l, port := usableListenPort(t, "tcp", "[::]")
defer l.Close()
for _, tt := range prohibitionaryDialArgTests {
- _, err = Dial(tt.net, tt.addr+":"+port)
+ _, err := Dial(tt.net, tt.addr+":"+port)
if err == nil {
t.Fatalf("Dial(%q, %q) should fail", tt.net, tt.addr)
}
コアとなるコードの解説
このコミットの主要な変更点は、usableLocalPort
関数の削除と、それに代わる usableListenPort
および usableListenPacketPort
関数の導入です。
削除された usableLocalPort
関数:
func usableLocalPort(t *testing.T, net, laddr string) string {
var nladdr string
switch net {
case "tcp", "tcp4", "tcp6":
l, err := Listen(net, laddr+":0")
if err != nil {
t.Fatalf("Probe Listen(%q, %q) failed: %v", net, laddr, err)
}
defer l.Close() // ここでリスナーを閉じてしまう
nladdr = l.(*TCPListener).Addr().String()
case "udp", "udp4", "udp6":
c, err := ListenPacket(net, laddr+":0")
if err != nil {
t.Fatalf("Probe ListenPacket(%q, %q) failed: %v", net, laddr, err)
}
defer c.Close() // ここでパケットコネクションを閉じてしまう
nladdr = c.(*UDPConn).LocalAddr().String()
}
_, port, err := SplitHostPort(nladdr)
if err != nil {
t.Fatalf("SplitHostPort failed: %v", err)
}
return port // ポート番号のみを返す
}
この関数は、OSが割り当てたポート番号を取得するために一時的にリスナーを作成し、その直後に defer l.Close()
または defer c.Close()
でリスナーを閉じていました。そして、抽出したポート番号(文字列)のみを呼び出し元に返していました。この「閉じる」操作が、Windows 7環境での競合状態の原因となっていました。
新しく追加された usableListenPort
および usableListenPacketPort
関数:
func usableListenPort(t *testing.T, net, laddr string) (l Listener, port string) {
var nladdr string
var err error
switch net {
default:
panic("usableListenPort net=" + net)
case "tcp", "tcp4", "tcp6":
l, err = Listen(net, laddr+":0") // リスナーを作成
if err != nil {
t.Fatalf("Probe Listen(%q, %q) failed: %v", net, laddr, err)
}
// defer l.Close() がない!
nladdr = l.(*TCPListener).Addr().String()
}
_, port, err = SplitHostPort(nladdr)
if err != nil {
t.Fatalf("SplitHostPort failed: %v", err)
}
return l, port // リスナーとポート番号の両方を返す
}
func usableListenPacketPort(t *testing.T, net, laddr string) (l PacketConn, port string) {
var nladdr string
var err error
switch net {
default:
panic("usableListenPacketPort net=" + net)
case "udp", "udp4", "udp6":
l, err = ListenPacket(net, laddr+":0") // パケットコネクションを作成
if err != nil {
t.Fatalf("Probe ListenPacket(%q, %q) failed: v", net, laddr, err)
}
// defer l.Close() がない!
nladdr = l.(*UDPConn).LocalAddr().String()
}
_, port, err = SplitHostPort(nladdr)
if err != nil {
t.Fatalf("SplitHostPort failed: %v", err)
}
return l, port // パケットコネクションとポート番号の両方を返す
}
これらの新しい関数は、usableLocalPort
とは異なり、リスナー(またはパケットコネクション)を作成した後、それを閉じずに、作成されたリスナーオブジェクトと割り当てられたポート番号の両方を呼び出し元に返します。これにより、呼び出し元は既にアクティブなリスナーを使ってテストを続行できるため、ポートの解放と再利用の間の競合状態が完全に回避されます。リスナーのクローズは、テストケースの終了時に呼び出し元で行われるようになります(例: defer l.Close()
)。
テストケースの変更:
各テスト関数(TestTCPListener
, TestUDPListener
など)では、usableLocalPort
を呼び出してポート番号を取得し、そのポート番号で再度 Listen
を呼び出す代わりに、usableListenPort
または usableListenPacketPort
を呼び出し、返されたリスナーとポート番号を直接使用するように変更されています。
例: 変更前:
port := usableLocalPort(t, tt.net, tt.laddr)
l1, err := Listen(tt.net, tt.laddr+":"+port)
変更後:
l1, port := usableListenPort(t, tt.net, tt.laddr)
この変更により、l1
は usableListenPort
から返された、既にアクティブなリスナーとなり、その後の checkFirstListener
などの関数に渡されます。これにより、テストの安定性が大幅に向上しました。
関連リンク
- Go Issue #3219: net: usableLocalPort fails on Windows 7
- Go Code Review: https://golang.org/cl/5769045
参考にした情報源リンク
- TCP TIME_WAIT State and its Effect on Socket Reuse: https://docs.microsoft.com/en-us/troubleshoot/windows-server/networking/tcp-ip-port-exhaustion-and-ephemeral-ports (WindowsにおけるTCP/IPポート枯渇とエフェメラルポートに関する情報ですが、TIME_WAIT状態の挙動について参考になります)
- Go
net
package documentation: https://pkg.go.dev/net - Go
testing
package documentation: https://pkg.go.dev/testing - Go
net.SplitHostPort
documentation: https://pkg.go.dev/net#SplitHostPort - Go
net.Listener
interface: https://pkg.go.dev/net#Listener - Go
net.PacketConn
interface: https://pkg.go.dev/net#PacketConn