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

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

このコミットは、Go言語の標準ライブラリ log/syslog パッケージのテストファイル src/pkg/log/syslog/syslog_test.go に加えられた変更です。具体的には、並行処理に関連するテストにおけるデッドロックの問題を解消することを目的としています。

コミット

log/syslog パッケージのテストコードにおけるデッドロックを修正するコミットです。デッドロックの原因は、サーバーハンドラが done チャネルからの読み込みでブロックし、done チャネルを読み込むゴルーチンが count チャネルからの読み込みでブロックし、さらに count チャネルを読み込むはずのメインゴルーチンがサーバーハンドラの終了を待機するという、循環的な依存関係にありました。この修正は、チャネルのバッファリングと sync.WaitGroup の使用方法を調整することで、このデッドロックを解消しています。

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

https://github.com/golang/go/commit/0806c97209505dc48dcca3bb4bbe05695d1a3dd3

元コミット内容

commit 0806c97209505dc48dcca3bb4bbe05695d1a3dd3
Author: Dmitriy Vyukov <dvyukov@google.com>
Date:   Fri May 24 18:35:48 2013 +0400

    log/syslog: fix deadlock in test
    The problem was that server handlers block on done<-,
    the goroutine that reads from done blocks on count<-,
    and the main goroutine that is supposed to read from count
    waits for server handlers to exit.
    Fixes #5547.
    
    R=golang-dev, dave, bradfitz
    CC=golang-dev
    https://golang.org/cl/9722043

変更の背景

このコミットは、Go言語の log/syslog パッケージのテスト TestConcurrentReconnect におけるデッドロックの問題を解決するために行われました。デッドロックは、並行処理のテストにおいて、複数のゴルーチンが互いに相手の処理完了を待つことで発生する典型的な問題です。

具体的には、以下のような循環的な依存関係がデッドロックを引き起こしていました。

  1. サーバーハンドラ: done チャネルからのデータ受信を待機してブロックします。
  2. done チャネルを読み込むゴルーチン: count チャネルからのデータ受信を待機してブロックします。
  3. メインゴルーチン: サーバーハンドラが終了するのを sync.WaitGroup を使って待機します。

この状況では、サーバーハンドラが終了しない限りメインゴルーチンは先に進めず、サーバーハンドラは done チャネルからのデータがないと終了せず、done チャネルにデータを送るゴルーチンは count チャネルからのデータがないと進めず、count チャネルにデータを送るゴルーチンはメインゴルーチンが先に進まないとデータを送れない、という膠着状態に陥っていました。

このデッドロックは、Issue #5547 として報告されており、このコミットはその問題を修正することを目的としています。

前提知識の解説

このコミットの変更内容を理解するためには、以下のGo言語の並行処理に関する基本的な概念と、log/syslog パッケージのテストの文脈を理解しておく必要があります。

Go言語の並行処理

  • Goroutine (ゴルーチン): Go言語における軽量なスレッドのようなものです。非常に少ないメモリで多数のゴルーチンを同時に実行できます。go キーワードを使って関数呼び出しの前に置くことで、新しいゴルーチンとして実行されます。
  • Channel (チャネル): ゴルーチン間で値を送受信するための通信メカニズムです。チャネルは、ゴルーチン間の同期と通信を安全に行うための主要な手段です。
    • バッファなしチャネル: make(chan Type) で作成され、送信側は受信側が値を受け取るまでブロックし、受信側は送信側が値を送るまでブロックします。同期的な通信に適しています。
    • バッファありチャネル: make(chan Type, capacity) で作成され、指定された容量まで値をバッファできます。バッファが満杯になるまで送信側はブロックせず、バッファが空になるまで受信側はブロックしません。非同期的な通信や、一時的な負荷の吸収に適しています。
  • sync.WaitGroup: 複数のゴルーチンの完了を待機するために使用される同期プリミティブです。
    • Add(delta int): 待機するゴルーチンの数を delta だけ増やします。
    • Done(): 完了したゴルーチンの数を1つ減らします。通常は defer ステートメントと共に使用され、ゴルーチンが終了する際に確実に呼び出されるようにします。
    • Wait(): Add で追加されたすべてのゴルーチンが Done を呼び出すまでブロックします。

log/syslog パッケージ

Go言語の標準ライブラリの一部で、Unix系システムで広く使われているシステムログプロトコルであるsyslogへのログ送信機能を提供します。このパッケージは、ネットワーク経由でsyslogサーバーにログを送信する機能も持っており、テストではその挙動をシミュレートするためにサーバーを立ててテストしています。

テストにおけるデッドロック

並行処理を含むテストでは、ゴルーチン間の相互作用が複雑になり、デッドロックが発生しやすくなります。デッドロックは、テストがハングアップし、タイムアウトする原因となります。これを解決するためには、ゴルーチン間の同期と通信のロジックを慎重に分析し、適切なチャネルのバッファリングや WaitGroup の使用方法を適用する必要があります。

技術的詳細

このコミットの技術的な詳細は、主に TestConcurrentWriteTestConcurrentReconnect という2つのテスト関数における sync.WaitGroupDone() メソッドの呼び出しタイミングと、チャネルのバッファリングの変更に集約されます。

TestConcurrentWrite の変更

元のコードでは、wg.Done()w.Info("test") の呼び出し後に配置されていました。

// 変更前
err := w.Info("test")
if err != nil {
    t.Errorf("Info() failed: %v", err)
    return
}
wg.Done() // ここで呼び出し

この場合、もし w.Info("test") がエラーを返して return してしまうと、wg.Done() が呼び出されず、wg.Wait() が永遠にブロックする(デッドロックする)可能性がありました。

修正後は、wg.Done()defer ステートメントを使ってゴルーチンの開始時に登録されるようになりました。

// 変更後
defer wg.Done() // ゴルーチン終了時に必ず呼び出される
err := w.Info("test")
if err != nil {
    t.Errorf("Info() failed: %v", err)
    return
}

これにより、w.Info("test") がエラーを返してゴルーチンが早期に終了した場合でも、wg.Done() が確実に呼び出され、WaitGroup のカウントが正しく減算されるようになります。これは、テストの信頼性を高め、潜在的なデッドロックを防ぐための一般的なプラクティスです。

TestConcurrentReconnect の変更

このテストは、syslogクライアントがサーバーへの再接続を試みるシナリオをテストしています。デッドロックの主要な原因は、done チャネルがバッファなしチャネルとして作成されていたことにありました。

元のコード: done := make(chan string) (バッファなしチャネル)

修正後: done := make(chan string, N*M) (バッファありチャネル)

ここで、N は並行して実行されるクライアントの数(10)、M は各クライアントが送信するメッセージの数(100)です。したがって、N*M は合計で送信されるメッセージの最大数(10 * 100 = 1000)を示します。

なぜバッファなしチャネルがデッドロックを引き起こしたのか?

コミットメッセージが説明しているように、サーバーハンドラは done<- でブロックします。バッファなしチャネルの場合、送信側(この場合はクライアントゴルーチン)が done チャネルに値を送信しようとすると、受信側(サーバーハンドラ)がその値を受け取るまでブロックします。

テストのシナリオでは、多数のクライアントゴルーチンが同時にメッセージを送信し、その完了を done チャネルを通じてサーバーハンドラに通知しようとします。しかし、サーバーハンドラが done チャネルから値を読み取る速度が遅いか、あるいは他の要因でブロックしている場合、クライアントゴルーチンは done チャネルへの送信でブロックしてしまいます。

さらに、メインゴルーチンは sync.WaitGroup を使ってすべてのクライアントゴルーチンが終了するのを待っています。クライアントゴルーチンが done チャネルへの送信でブロックすると、wg.Done() が呼び出されず、結果としてメインゴルーチンも wg.Wait() でブロックし、全体としてデッドロックが発生していました。

バッファありチャネルによる解決

done := make(chan string, N*M) とすることで、done チャネルは最大 N*M 個のメッセージをバッファできるようになります。これにより、クライアントゴルーチンは、サーバーハンドラがすぐに値を受け取らなくても、done チャネルに値を送信してブロックせずに先に進むことができます。チャネルのバッファが満杯にならない限り、送信操作は非ブロッキングになります。

これにより、クライアントゴルーチンは wg.Done() を呼び出すことができ、メインゴルーチンは wg.Wait() を正常に完了できるようになります。サーバーハンドラは、バッファされた done チャネルから非同期的に値を受け取ることができ、デッドロックが解消されます。

その他の変更

  • const N = 10const M = 100 の導入: テストのパラメータを定数として定義することで、コードの可読性と保守性が向上しています。
  • if ct > 500 から if ct > N*M/2 への変更: 期待されるイベント数の閾値を定数 NM に基づいて動的に計算するように変更されました。これにより、テストのロジックがより柔軟になり、パラメータ変更時の調整が容易になります。
  • wg.Add(1) から wg.Add(N) への変更、および wg.Done() の位置の調整: TestConcurrentReconnect においても、TestConcurrentWrite と同様に wg.Done() の呼び出しタイミングが調整されました。各クライアントゴルーチンが Dial を行い、メッセージを送信する一連の処理が完了した時点で wg.Done() が呼び出されるように変更されています。これにより、WaitGroup のカウントが正しく管理され、デッドロックが防止されます。

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

--- a/src/pkg/log/syslog/syslog_test.go
+++ b/src/pkg/log/syslog/syslog_test.go
@@ -281,12 +281,12 @@ func TestConcurrentWrite(t *testing.T) {
 	for i := 0; i < 10; i++ {
 		wg.Add(1)
 		go func() {
+			defer wg.Done()
 			err := w.Info("test")
 			if err != nil {
 				t.Errorf("Info() failed: %v", err)
 				return
 			}
-			wg.Done()
 		}()
 	}
 	wg.Wait()
@@ -296,8 +296,10 @@ func TestConcurrentReconnect(t *testing.T) {
 	crashy = true
 	defer func() { crashy = false }()
 
+	const N = 10
+	const M = 100
 	net := "unix"
-	done := make(chan string)
+	done := make(chan string, N*M)
 	addr, sock, srvWG := startServer(net, "", done)
 	defer os.Remove(addr)
 
@@ -310,7 +312,7 @@ func TestConcurrentReconnect(t *testing.T) {
 			// we are looking for 500 out of 1000 events
 			// here because lots of log messages are lost
 			// in buffers (kernel and/or bufio)
-			if ct > 500 {
+			if ct > N*M/2 {
 				break
 			}
 		}
@@ -318,21 +320,21 @@ func TestConcurrentReconnect(t *testing.T) {
 	}()
 
 	var wg sync.WaitGroup
-	for i := 0; i < 10; i++ {
-		wg.Add(1)
+	for i := 0; i < N; i++ {
+		wg.Add(1) // この行は変更なしだが、次の行のdefer wg.Done()と対になる
 		go func() {
+			defer wg.Done()
 			w, err := Dial(net, addr, LOG_USER|LOG_ERR, "tag")
 			if err != nil {
 				t.Fatalf("syslog.Dial() failed: %v", err)
 			}
-			for i := 0; i < 100; i++ {
+			for i := 0; i < M; i++ {
 				err := w.Info("test")
 				if err != nil {
 					t.Errorf("Info() failed: %v", err)
 					return
 				}
 			}
-			wg.Done()
 		}()
 	}
 	wg.Wait()

コアとなるコードの解説

TestConcurrentWrite の変更点

-			wg.Done()
+			defer wg.Done()
  • 変更前: wg.Done()w.Info("test") の呼び出しが成功した後に実行されていました。
  • 変更後: defer wg.Done() とすることで、ゴルーチンが終了する際に(エラーが発生して早期リターンした場合でも)必ず wg.Done() が呼び出されるようになりました。これにより、WaitGroup のカウントが正しく減算され、テストがデッドロックする可能性が排除されます。

TestConcurrentReconnect の変更点

-	done := make(chan string)
+	const N = 10
+	const M = 100
+	done := make(chan string, N*M)
  • 変更前: done チャネルはバッファなしチャネルとして作成されていました。
  • 変更後: const N = 10const M = 100 という定数を導入し、done チャネルを N*M の容量を持つバッファありチャネルに変更しました。これにより、クライアントゴルーチンが done チャネルにメッセージを送信する際に、サーバーハンドラがすぐに受信しなくてもブロックせずに済むようになり、デッドロックが解消されます。
-			if ct > 500 {
+			if ct > N*M/2 {
  • 変更前: 期待されるイベント数の閾値がハードコードされた 500 でした。
  • 変更後: 導入された定数 NM を用いて N*M/2 とすることで、閾値が動的に計算されるようになりました。これにより、テストのパラメータ変更に対する柔軟性が向上します。
-	for i := 0; i < 10; i++ {
-		wg.Add(1)
+	for i := 0; i < N; i++ {
+		wg.Add(1) // この行は変更なし
 		go func() {
+			defer wg.Done()
 			w, err := Dial(net, addr, LOG_USER|LOG_ERR, "tag")
 			if err != nil {
 				t.Fatalf("syslog.Dial() failed: %g", err)
 			}
-			for i := 0; i < 100; i++ {
+			for i := 0; i < M; i++ {
 				err := w.Info("test")
 				if err != nil {
 					t.Errorf("Info() failed: %v", err)
 					return
 				}
 			}
-			wg.Done()
 		}()
 	}
  • 変更前: 外側のループが 10 回、内側のループが 100 回実行されていました。wg.Done() は内側のループが完了した後に呼び出されていました。
  • 変更後: 外側のループの回数を N に、内側のループの回数を M に変更しました。最も重要な変更は、wg.Done() の位置です。TestConcurrentWrite と同様に、defer wg.Done() を外側のゴルーチンの開始時に配置することで、各クライアントゴルーチンが Dial からメッセージ送信までの一連の処理を終えた時点で、エラーの有無にかかわらず確実に wg.Done() が呼び出されるようになりました。これにより、WaitGroup のカウントが正しく管理され、メインゴルーチンが wg.Wait() でブロックする問題が解消されます。

これらの変更により、並行処理テストにおけるデッドロックが解消され、テストの信頼性と安定性が向上しました。

関連リンク

参考にした情報源リンク

  • Go言語公式ドキュメント: https://go.dev/
  • Go言語の並行処理に関する一般的な情報源 (例: Effective Go, Go Concurrency Patterns)
  • syslogプロトコルに関する一般的な情報