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

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

このコミットは、Go言語の標準ライブラリである crypto/tls パッケージ内のテストファイル src/pkg/crypto/tls/handshake_client_test.go に関連するものです。このファイルは、TLSクライアントのハンドシェイク処理に関するテストケースを定義しており、特に TestEmptyRecords というテスト関数が対象となっています。このテストは、TLS接続において空のレコードがどのように処理されるかを確認することを目的としています。

コミット

このコミットは、crypto/tls パッケージ内の不安定な(flaky)テストを修正することを目的としています。具体的には、TestEmptyRecords というテストが GOMAXPROCS の値によって失敗することがあった問題を解決しています。この問題は、defer ステートメントの実行順序が原因で、基盤となるネットワークパイプがTLSクライアント接続よりも先に閉じられてしまうことに起因していました。

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

https://github.com/golang/go/commit/21cf646bfcc35711ce0c728f3d1e44ffe6b054e8

元コミット内容

crypto/tls: fix flakey test.

A test added in b37d2fdcc4d9 didn't work with some values of GOMAXPROCS
because the defer statements were in the wrong order: the Pipe could be
closed before the TLS Client was.

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

変更の背景

b37d2fdcc4d9 で追加された TestEmptyRecords というテストは、特定の GOMAXPROCS の値(Goランタイムが同時に実行できるOSスレッドの最大数)において不安定に失敗するという問題(flaky test)を抱えていました。この不安定性は、テスト内で使用されている defer ステートメントの実行順序が原因でした。具体的には、TLSクライアント接続を閉じる defer が、その基盤となる net.Pipe 接続を閉じる defer よりも先に実行される可能性があり、これによりテストが意図しないエラーを検出してしまうことがありました。

並行処理のタイミングに依存するこのような問題は、GOMAXPROCS の値によってゴルーチンのスケジューリングが変わり、リソースの解放タイミングが変動するために発生します。このコミットは、この不安定性を解消し、テストが常に安定して動作するようにすることを目的としています。

前提知識の解説

Go言語の defer ステートメント

Go言語の defer ステートメントは、その関数がリターンする直前、またはパニックが発生して関数が終了する直前に、指定された関数呼び出しを延期(defer)するために使用されます。defer された関数は、LIFO(Last-In, First-Out)の順序で実行されます。つまり、複数の defer ステートメントがある場合、最後に defer されたものが最初に実行され、最初に defer されたものが最後に実行されます。これは、リソースのクリーンアップ(ファイルのクローズ、ロックの解放など)を確実に行うための非常に便利な機能です。

GOMAXPROCS 環境変数

GOMAXPROCS は、Goランタイムが同時に実行できるOSスレッドの最大数を制御する環境変数です。デフォルトでは、利用可能なCPUコア数に設定されます。この値が変更されると、GoスケジューラがゴルーチンをOSスレッドにどのようにマッピングし、実行するかに影響を与えます。これにより、並行処理のタイミングが変わり、特定の競合状態やタイミング依存のバグが顕在化することがあります。不安定なテストが GOMAXPROCS の値によって挙動を変えるのは、このスケジューリングの変化が原因であることが多いです。

net.Pipe() 関数

Go言語の net パッケージには、Pipe() という関数があります。これは、インメモリの双方向通信パイプを作成するために使用されます。Pipe() は2つの net.Conn インターフェースを実装するオブジェクトを返します。これらはそれぞれパイプの「両端」を表し、一方に書き込まれたデータはもう一方から読み取ることができます。この機能は、ネットワーク接続をシミュレートするテスト(特にクライアントとサーバー間の通信をテストする場合)で非常に有用です。実際のネットワークI/Oを伴わないため、テストを高速かつ決定論的に実行できます。

crypto/tls パッケージ

crypto/tls パッケージは、Go言語におけるTLS(Transport Layer Security)プロトコルの実装を提供します。TLSは、インターネット上での安全な通信を可能にするための暗号化プロトコルであり、ウェブブラウジング(HTTPS)、電子メール、その他の多くのアプリケーションで広く使用されています。このパッケージは、TLSクライアントとサーバーの実装、証明書の管理、ハンドシェイク処理など、TLS通信に必要な機能を提供します。

TLSハンドシェイク

TLSハンドシェイクは、TLS接続を確立するための初期プロトコルです。クライアントとサーバーが互いに認証し、暗号化アルゴリズムと共有秘密鍵をネゴシエートする一連のステップを含みます。ハンドシェイクが成功すると、その後の通信は確立された暗号化されたチャネルを通じて行われます。

Flaky Test(不安定なテスト)

Flaky Testとは、同じコードベースとテスト環境で実行しても、成功したり失敗したりするテストのことです。これは、テストが外部要因(ネットワークの遅延、データベースの状態、システム時刻など)や、並行処理におけるタイミングの競合、リソースリーク、不適切なテストのセットアップ/ティアダウンなどに依存している場合に発生します。Flaky Testは開発者の生産性を低下させ、CI/CDパイプラインの信頼性を損なうため、修正が強く推奨されます。

技術的詳細

このコミットの核心は、Goの defer ステートメントの実行順序と、それがリソースのクリーンアップに与える影響を正しく理解し、適用することにあります。

元のコードでは、TestEmptyRecords 関数内のゴルーチンで、以下の defer ステートメントがこの順序で記述されていました。

defer cli.Close() // TLSクライアント接続を閉じる
defer c.Close()   // 基盤となるnet.Pipe接続を閉じる

Goの defer はLIFO(Last-In, First-Out)で実行されるため、この順序では c.Close()cli.Close() よりも先に実行されます。

clitls.Conn 型であり、その内部で cnet.Pipe の一端)を使用しています。つまり、clic に依存しています。テストのシナリオでは、cli.Read(buf) がブロックしている間に、GOMAXPROCS の値やスケジューリングのタイミングによっては、ゴルーチンが終了し、defer された関数が実行される可能性があります。

問題は、c.Close()cli.Close() よりも先に実行されると、cli がまだ c を使用しようとしている間に、基盤となるパイプ接続 c が閉じられてしまう可能性がある点にありました。これにより、cli.Read が予期せぬエラーを返したり、cli が不正な状態になったりして、テストが不安定に失敗することがありました。

このコミットでは、defer ステートメントの順序を以下のように変更しました。

defer c.Close()   // 基盤となるnet.Pipe接続を閉じる
defer cli.Close() // TLSクライアント接続を閉じる

この変更により、LIFOの原則に従って、cli.Close()c.Close() よりも先に実行されるようになります。しかし、これは一見すると逆効果に見えます。

重要なのは、defer の登録順序と実行順序です。 defer は登録された順序とは逆(LIFO)に実行されます。

  • 変更前:

    1. defer cli.Close() が登録される。
    2. defer c.Close() が登録される。 実行時: c.Close() -> cli.Close()
  • 変更後:

    1. defer c.Close() が登録される。
    2. defer cli.Close() が登録される。 実行時: cli.Close() -> c.Close()

しかし、コミットメッセージと差分を再確認すると、私の理解が逆でした。 コミットメッセージには「the defer statements were in the wrong order: the Pipe could be closed before the TLS Client was.」とあります。 そして、差分は defer cli.Close()defer c.Close() の行を入れ替えています。

元のコード:

		defer cli.Close()
		defer c.Close()

この場合、LIFOなので c.Close() が先に実行され、次に cli.Close() が実行されます。 コミットメッセージの「Pipe could be closed before the TLS Client was」と合致します。

修正後のコード:

		defer c.Close()
		defer cli.Close()

この場合、LIFOなので cli.Close() が先に実行され、次に c.Close() が実行されます。 これにより、「TLS Clientが閉じられた後にPipeが閉じられる」という正しい順序になります。

つまり、TLSクライアント接続 cli が、その基盤となるパイプ接続 c を使用し終えてから、または cli 自身が閉じられる際に c を適切にクリーンアップできるように、cli.Close()c.Close() よりも先に実行されるように順序が変更されたのです。これにより、cli がまだ c に依存している間に c が閉じられるという競合状態が解消され、テストの不安定性が修正されました。

また、var config = *testConfig から config := *testConfig への変更は、Go言語における変数の短縮宣言(short variable declaration)への変更であり、機能的な意味合いは全くありません。これは単にGoの慣習に合わせたコードスタイルの変更です。

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

変更は src/pkg/crypto/tls/handshake_client_test.go ファイルの TestEmptyRecords 関数内で行われました。

--- a/src/pkg/crypto/tls/handshake_client_test.go
+++ b/src/pkg/crypto/tls/handshake_client_test.go
@@ -84,7 +84,7 @@ func TestEmptyRecords(t *testing.T) {
 	// the first application data from the server. This test ensures that
 	// the empty record doesn't cause (0, nil) to be returned from
 	// Conn.Read.
-	var config = *testConfig
+	config := *testConfig
 	config.CipherSuites = []uint16{TLS_RSA_WITH_AES_256_CBC_SHA}

 	c, s := net.Pipe()
@@ -92,8 +92,8 @@ func TestEmptyRecords(t *testing.T) {
 	go func() {
 		buf := make([]byte, 1024)
 		n, err := cli.Read(buf)
-		defer cli.Close()
 		defer c.Close()
+		defer cli.Close()

 		if err != nil {
 			t.Fatalf("error reading from tls.Client: %s", err)

具体的には、以下の2点が変更されました。

  1. var config = *testConfigconfig := *testConfig に変更されました。(行86)
  2. defer cli.Close()defer c.Close() の順序が入れ替えられました。(行94-95)

コアとなるコードの解説

このコミットの主要な変更は、defer ステートメントの順序の入れ替えです。

変更前:

		defer cli.Close() // (1) 最初に登録される
		defer c.Close()   // (2) 次に登録される

この場合、LIFOの原則により、関数終了時には (2) c.Close() が先に実行され、次に (1) cli.Close() が実行されます。 これは、基盤となるパイプ接続 c が、それを使用しているTLSクライアント接続 cli よりも先に閉じられることを意味します。cli がまだ c を介したI/O操作(例: cli.Read)を試みている間に c が閉じられると、競合状態やエラーが発生し、テストが不安定になる原因となっていました。

変更後:

		defer c.Close()   // (1) 最初に登録される
		defer cli.Close() // (2) 次に登録される

この場合、LIFOの原則により、関数終了時には (2) cli.Close() が先に実行され、次に (1) c.Close() が実行されます。 これにより、TLSクライアント接続 cli が先に閉じられ、その後に基盤となるパイプ接続 c が閉じられるという、依存関係を考慮した正しいクリーンアップ順序が保証されます。cli.Close() が実行されることで、cli は自身の内部リソースを適切に解放し、c への依存を解消します。その後、c.Close() が実行されて基盤のパイプが閉じられます。この順序により、テストの実行が安定し、GOMAXPROCS の値に依存しない信頼性の高い結果が得られるようになりました。

var config = *testConfig から config := *testConfig への変更は、Go言語の慣用的な書き方への修正であり、機能的な影響はありません。:= は変数の宣言と初期化を同時に行う短縮宣言で、型推論が行われます。

関連リンク

参考にした情報源リンク