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

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

このコミットは、Go言語の標準ライブラリ net/http パッケージ内のベンチマークテスト serve_test.go において、並行ベンチマークの実行方法を testing.BRunParallel メソッドを使用するように変更するものです。これにより、ベンチマークの記述が簡潔になり、Goのテストフレームワークが提供する並行実行の恩恵を享受できるようになります。

コミット

net/http パッケージのベンチマークテスト serve_test.go において、並行ベンチマークのロジックが手動でゴルーチンと sync.WaitGroup を使って実装されていた箇所を、testing パッケージが提供する RunParallel メソッドに置き換えました。これにより、ベンチマークコードが簡素化され、よりGoのテストフレームワークの慣習に沿った形になります。

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

https://github.com/golang/go/commit/96d52298189b556f9fabca9a940f60a18fbc49d9

元コミット内容

net/http: use RunParallel in benchmarks

LGTM=bradfitz
R=golang-codereviews, bradfitz
CC=golang-codereviews
https://golang.org/cl/68070043

変更の背景

この変更の背景には、Goのベンチマークテストにおける並行処理の記述をより効率的かつ標準的な方法に統一するという意図があります。以前のコードでは、runtime.GOMAXPROCSsync.WaitGroup を組み合わせて手動で複数のゴルーチンを起動し、ベンチマークのイテレーションを並行して実行していました。しかし、Go 1.1で導入された testing.B.RunParallel メソッドは、このような並行ベンチマークのシナリオをより簡単に、かつ最適に処理するための専用のAPIを提供します。

RunParallel を使用することで、開発者は手動でのゴルーチン管理や sync.WaitGroup の同期処理から解放され、ベンチマークのロジック自体に集中できるようになります。また、RunParallelGOMAXPROCS の値に基づいて最適な並行度を自動的に調整するため、様々な環境でのベンチマークの再現性と信頼性が向上します。このコミットは、net/http パッケージのベンチマークをGoのテストフレームワークの最新のベストプラクティスに準拠させることを目的としています。

前提知識の解説

Goのベンチマークテスト (testing パッケージ)

Go言語には、標準ライブラリとして testing パッケージが提供されており、ユニットテスト、例示テスト、そしてベンチマークテストを記述するための機能が含まれています。

  • ベンチマーク関数: ベンチマーク関数は func BenchmarkXxx(b *testing.B) というシグネチャを持ちます。*testing.B 型の引数 b は、ベンチマークの実行を制御するためのメソッドを提供します。
  • b.N: ベンチマーク関数内で、b.N はベンチマーク対象の操作を何回繰り返すかを示す数値です。ベンチマーク実行時には、b.N の値は動的に調整され、操作が安定した時間内に実行されるようにします。
  • b.ReportAllocs(): メモリ割り当ての統計をレポートに含めるように指示します。
  • b.StopTimer() / b.StartTimer(): ベンチマークの計測を一時停止/再開します。セットアップ処理など、ベンチマーク対象ではない処理の時間を計測から除外するために使用されます。
  • b.ResetTimer(): タイマーをリセットし、b.N の値もリセットします。通常、セットアップ処理の後に呼び出され、純粋なベンチマーク対象の処理時間のみを計測するために使われます。

testing.B.RunParallel

RunParallel はGo 1.1で導入された testing.B のメソッドで、並行してベンチマークを実行するために設計されています。

  • 並行実行: RunParallel は、GOMAXPROCS の値に基づいて複数のゴルーチンを起動し、これらのゴルーチン間で b.N のイテレーションを分散して実行します。これにより、CPUコアを効率的に利用し、並行処理の性能を測定できます。
  • b.SetParallelism(p int): RunParallel が起動するゴルーチンの数を明示的に設定するために使用できます。デフォルトでは GOMAXPROCS の値が使われます。
  • pb *testing.PB: RunParallel に渡される関数は *testing.PB 型の引数を受け取ります。この pb オブジェクトは Next() メソッドを提供し、各ゴルーチンが次のイテレーションを実行すべきかどうかを判断するために使用されます。for pb.Next() { ... } のループ内でベンチマーク対象の操作を記述します。
  • 利点:
    • 簡潔なコード: 手動でのゴルーチン管理や sync.WaitGroup の使用が不要になります。
    • 正確な並行ベンチマーク: RunParallel は、複数のCPUコアを効率的に利用し、並行アクセス下でのコードのパフォーマンスをより正確に測定します。
    • データ競合の発見: 並行実行により、シーケンシャルなテストでは見過ごされがちなデータ競合を発見しやすくなります。
    • 実世界のシミュレーション: 実際のアプリケーションが並行して動作する状況をより忠実にシミュレートできます。

runtime.GOMAXPROCS

runtime.GOMAXPROCS は、Goランタイムが同時に実行できるOSスレッドの最大数を設定します。これは、GoのスケジューラがゴルーチンをOSスレッドにマッピングする方法に影響を与えます。RunParallel はこの値を利用して、最適な並行度を決定します。

ゴルーチンと sync.WaitGroup

  • ゴルーチン (Goroutine): Goランタイムによって管理される軽量なスレッドです。数千、数万のゴルーチンを同時に起動してもオーバーヘッドが少ないのが特徴です。
  • sync.WaitGroup: 複数のゴルーチンの完了を待機するために使用される同期プリミティブです。Add() でカウンタを増やし、Done() で減らし、Wait() でカウンタがゼロになるまでブロックします。

技術的詳細

このコミットの技術的詳細の中心は、並行ベンチマークのイディオムを、手動でのゴルーチンと sync.WaitGroup の管理から、testing.B.RunParallel の利用へと移行した点にあります。

変更前の benchmarkClientServerParallel 関数では、以下の手順で並行ベンチマークを実行していました。

  1. numProcs := runtime.GOMAXPROCS(-1) * conc で、GOMAXPROCS の値と conc (並行度を示す引数) を掛け合わせて、起動するゴルーチンの総数を決定していました。
  2. var wg sync.WaitGroupwg.Add(numProcs)WaitGroup を初期化し、起動するゴルーチンの数だけカウンタを設定していました。
  3. for p := 0; p < numProcs; p++ ループ内で、go func() { ... }() を使って numProcs 個のゴルーチンを起動していました。
  4. 各ゴルーチン内では、for atomic.AddInt32(&n, -1) >= 0 というループを使って、b.N 回の操作をアトミックカウンタ n を共有しながら実行していました。これにより、すべてのゴルーチンが協力して b.N 回の操作を完了するようにしていました。
  5. 各ゴルーチンが完了すると wg.Done() を呼び出し、最後に wg.Wait() で全てのゴルーチンの完了を待機していました。

この手動での並行処理の実装は、正しく動作しますが、いくつかの課題があります。

  • 複雑性: ゴルーチンの起動、WaitGroup による同期、共有カウンタのアトミック操作など、並行処理のロジックがベンチマーク対象のコードとは別に記述され、コードが複雑になります。
  • 最適化の機会: GOMAXPROCS の値に基づいてゴルーチン数を決定していますが、RunParallel はGoランタイムのスケジューラとより密接に連携し、より効率的な並行実行を調整できます。
  • エラーハンドリング: 各ゴルーチン内でエラーが発生した場合の b.Logf の呼び出しや continue の使用は、ベンチマークの全体的な結果に影響を与える可能性があります。

変更後のコードでは、これらの課題が testing.B.RunParallel を使用することで解決されています。

  1. b.ResetTimer(): ベンチマーク対象の処理が始まる前にタイマーをリセットします。これは RunParallel を使う際の一般的なパターンです。
  2. b.SetParallelism(parallelism): RunParallel が起動するゴルーチンの数を、引数 parallelism に基づいて設定します。これにより、ベンチマークの並行度を外部から制御できます。
  3. b.RunParallel(func(pb *testing.PB) { ... }): このメソッドが呼び出されると、Goランタイムは指定された並行度(またはデフォルトの GOMAXPROCS に基づく数)のゴルーチンを起動します。
  4. for pb.Next() { ... }: 各ゴルーチンは、このループ内でベンチマーク対象の操作を実行します。pb.Next() は、まだ実行すべきイテレーションが残っている場合に true を返します。RunParallelb.N のイテレーションをこれらのゴルーチン間で自動的に分散し、全てのイテレーションが完了するまでループを継続させます。

この変更により、ベンチマークコードはより簡潔になり、Goのテストフレームワークの内部最適化を活用できるようになります。特に、RunParallelb.N のイテレーションを複数のゴルーチンに効率的に割り当てるため、手動でアトミックカウンタを管理する必要がなくなります。

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

src/pkg/net/http/serve_test.go ファイルの benchmarkClientServerParallel 関数が変更されています。

--- a/src/pkg/net/http/serve_test.go
+++ b/src/pkg/net/http/serve_test.go
@@ -26,7 +26,6 @@ import (
 	"runtime"
 	"strconv"
 	"strings"
-	"sync"
 	"sync/atomic"
 	"syscall"
 	"testing"
@@ -2280,42 +2279,33 @@ func BenchmarkClientServerParallel64(b *testing.B) {
 	benchmarkClientServerParallel(b, 64)
 }
 
-func benchmarkClientServerParallel(b *testing.B, conc int) {
+func benchmarkClientServerParallel(b *testing.B, parallelism int) {
 	b.ReportAllocs()
-	b.StopTimer()
 	ts := httptest.NewServer(HandlerFunc(func(rw ResponseWriter, r *Request) {
 		fmt.Fprintf(rw, "Hello world.\n")
 	}))
 	defer ts.Close()
-	b.StartTimer()
-
-	numProcs := runtime.GOMAXPROCS(-1) * conc
-	var wg sync.WaitGroup
-	wg.Add(numProcs)
-	n := int32(b.N)
-	for p := 0; p < numProcs; p++ {
-		go func() {
-			for atomic.AddInt32(&n, -1) >= 0 {
-				res, err := Get(ts.URL)
-				if err != nil {
-					b.Logf("Get: %v", err)
-					continue
-				}
-				all, err := ioutil.ReadAll(res.Body)
-				res.Body.Close()
-				if err != nil {
-					b.Logf("ReadAll: %v", err)
-					continue
-				}
-				body := string(all)
-				if body != "Hello world.\n" {
-					panic("Got body: " + body)
-				}
+	b.ResetTimer()
+	b.SetParallelism(parallelism)
+	b.RunParallel(func(pb *testing.PB) {
+		for pb.Next() {
+			res, err := Get(ts.URL)
+			if err != nil {
+				b.Logf("Get: %v", err)
+				continue
 			}
-			wg.Done()
-		}()
-	}
-	wg.Wait()
+			all, err := ioutil.ReadAll(res.Body)
+			res.Body.Close()
+			if err != nil {
+				b.Logf("ReadAll: %v", err)
+				continue
+			}
+			body := string(all)
+			if body != "Hello world.\n" {
+				panic("Got body: " + body)
+			}
+		}
+	})
 }
 
 // A benchmark for profiling the server without the HTTP client code.

コアとなるコードの解説

変更の核心は、benchmarkClientServerParallel 関数内の並行処理ロジックの書き換えです。

削除されたコード:

  • import "sync": sync.WaitGroup が不要になったため、sync パッケージのインポートが削除されました。
  • b.StopTimer()b.StartTimer(): b.ResetTimer() を使用する新しいパターンでは、これらの明示的なタイマー制御は不要になります。b.ResetTimer() は、セットアップ処理の後に呼び出され、その時点からベンチマークの計測を開始します。
  • numProcs := runtime.GOMAXPROCS(-1) * conc: 手動でゴルーチン数を計算するロジックが削除されました。RunParallel がこの調整を内部的に行います。
  • var wg sync.WaitGroupwg.Add(numProcs)wg.Done()wg.Wait(): sync.WaitGroup を使ったゴルーチンの同期メカニズムが完全に削除されました。RunParallel がゴルーチンのライフサイクルと同期を管理します。
  • n := int32(b.N)for atomic.AddInt32(&n, -1) >= 0: b.N のイテレーションを複数のゴルーチンで共有するためのアトミックカウンタとループが削除されました。RunParallelpb.Next() がこの役割を担います。
  • for p := 0; p < numProcs; p++ { go func() { ... }(): 手動でゴルーチンを起動するループが削除されました。

追加されたコード:

  • b.ResetTimer(): httptest.NewServer のセットアップが完了した後、ベンチマークタイマーをリセットします。これにより、サーバーの起動時間はベンチマークの計測時間に含まれません。
  • b.SetParallelism(parallelism): RunParallel が起動するゴルーチンの数を、関数の引数 parallelism に基づいて設定します。これにより、ベンチマークの並行度を柔軟に制御できます。
  • b.RunParallel(func(pb *testing.PB) { ... }): この行が、手動の並行処理ロジックの全てを置き換えるものです。無名関数が pb *testing.PB を引数として受け取り、この関数が複数のゴルーチンで並行して実行されます。
  • for pb.Next() { ... }: RunParallel によって起動された各ゴルーチンは、このループ内でベンチマーク対象の操作(この場合は Get(ts.URL) とレスポンスの読み取り)を実行します。pb.Next() は、b.N 回の操作が全て完了するまで true を返し続けます。

この変更により、ベンチマークコードは大幅に簡素化され、Goのテストフレームワークの提供する並行ベンチマークの機能が最大限に活用されています。これにより、コードの可読性と保守性が向上し、将来的なGoランタイムの最適化の恩恵も受けやすくなります。

関連リンク

  • Go CL: https://golang.org/cl/68070043

参考にした情報源リンク