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

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

このコミットは、Go言語のランタイムにおけるデータ競合検出器(Race Detector)のための包括的なユニットテスト群を追加するものです。これにより、Goのデータ競合検出機能が正しく動作し、様々なシナリオでデータ競合を正確に報告できることを検証します。

コミット

  • コミットハッシュ: 908e1b5ea1fa09227b52c9818b6abb6c21ae3261
  • 作者: Dmitriy Vyukov dvyukov@google.com
  • コミット日時: 2012年11月27日 15:04:48 +0400

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

https://github.com/golang/go/commit/908e1b5ea1fa09227b52c9818b6abb6c21ae3261

元コミット内容

runtime/race: add unit tests for race detector

R=golang-dev, remyoudompheng, rsc
CC=golang-dev
https://golang.org/cl/6525052

変更の背景

Go言語は並行処理を言語レベルでサポートしており、ゴルーチン(goroutine)とチャネル(channel)を通じて容易に並行プログラムを記述できます。しかし、並行処理はデータ競合(data race)という潜在的なバグを引き起こす可能性があります。データ競合は、複数のゴルーチンが同時に同じメモリ位置にアクセスし、少なくとも1つのアクセスが書き込みであり、かつそれらのアクセスが同期メカニズムによって保護されていない場合に発生します。このような競合はプログラムの予測不能な動作やクラッシュにつながるため、その検出は並行プログラムの信頼性を確保する上で非常に重要です。

Goには、コンパイル時または実行時にデータ競合を検出するための組み込みの競合検出器が用意されています。この競合検出器は、Googleが開発したThreadSanitizer (TSan) という動的解析ツールをベースにしています。競合検出器の正確性と信頼性を保証するためには、その機能を網羅的にテストすることが不可欠です。このコミットは、まさにその目的のために、様々な種類のデータ競合シナリオを網羅するユニットテストを追加することで、競合検出器の堅牢性を検証することを目的としています。

前提知識の解説

データ競合 (Data Race)

データ競合は、並行プログラミングにおける最も一般的なバグの一つです。以下の3つの条件がすべて満たされたときに発生します。

  1. 複数のゴルーチン(またはスレッド)が同じメモリ位置にアクセスする。
  2. 少なくとも1つのアクセスが書き込み操作である。
  3. それらのアクセスが同期メカニズム(ミューテックス、チャネルなど)によって保護されていない。

データ競合が発生すると、プログラムの実行結果がアクセス順序に依存するようになり、非決定的な動作、メモリ破損、クラッシュなどの深刻な問題を引き起こす可能性があります。

Goの競合検出器 (Race Detector)

Go言語には、go run -racego build -racego test -race のように -race フラグを付けてコマンドを実行することで有効化できる組み込みの競合検出器があります。この検出器は、プログラムの実行中にメモリアクセスを監視し、データ競合のパターンを検出すると警告を出力します。

Goの競合検出器は、Googleが開発した動的解析ツールであるThreadSanitizer (TSan) をベースにしています。TSanは、インストルメンテーション(コードに監視コードを挿入すること)を通じてメモリアクセスを追跡し、競合を検出する仕組みです。Goのツールチェインに統合されているため、Go開発者は特別な設定なしに容易に競合検出を利用できます。

ThreadSanitizer (TSan)

ThreadSanitizerは、C++、Goなどの言語でデータ競合やその他の並行処理のバグを検出するために設計された動的解析ツールです。プログラムの実行時にメモリアクセスを監視し、競合が発生した可能性のある場所を特定します。TSanは、各メモリ位置に対して「バージョン」を記録し、異なるスレッドからのアクセスが同期なしに行われた場合に競合を報告します。Goの競合検出器は、このTSanの技術をGoランタイムに組み込むことで実現されています。

技術的詳細

このコミットで追加されたテストは、Goの競合検出器が様々な種類のデータ競合を正確に検出できることを検証するために設計されています。テストは src/pkg/runtime/race/race_test.go と、その下の testdata ディレクトリに多数の個別のテストファイルとして配置されています。

race_test.go は、テストの実行をオーケストレーションし、各テストの出力を解析して競合が期待通りに検出されたか(または検出されなかったか)を検証する主要なテストハーネスです。

テストの実行と検証ロジック (race_test.go)

  1. TestRace 関数: この関数がテストのエントリポイントです。runTests() を呼び出して、testdata ディレクトリ内のすべてのテストを実行します。
  2. runTests() 関数:
    • filepath.Glob("./testdata/*_test.go") を使用して、testdata ディレクトリ内のすべてのテストファイル(例: atomic_test.go, chan_test.go など)を検索します。
    • go test -race -v コマンドを実行し、これらのテストファイルを引数として渡します。-race フラグにより競合検出器が有効になります。-v フラグは詳細な出力を生成し、各テストの実行結果とTSanからの競合レポートが含まれます。
    • cmd.Env = append(os.Environ(), \GORACE="suppress_equal_stacks=0 suppress_equal_addresses=0"`)という行が重要です。これはGORACE` 環境変数を設定しており、TSanのヒューリスティック(同じ競合レポートを抑制する機能)を無効にしています。これにより、テストが意図的に同じアドレスで多数の競合を発生させる場合でも、すべての競合が報告されるようになります。
    • コマンドの標準出力と標準エラー出力を結合して取得します。
  3. processLog() 関数:
    • runTests() から返された出力(go test の結果)を1行ずつ読み込み、各テストのログを解析します。
    • テスト名が "Race" で始まる場合はデータ競合が期待され、"NoRace" で始まる場合はデータ競合がないことが期待されます。
    • TSanのログに "DATA RACE" という文字列が含まれているかどうかをチェックし、実際に競合が検出されたかどうかを判断します。
    • 期待される結果と実際の検出結果を比較し、テストの合否を判定します。
    • passedTests, totalTests, falsePos (誤検知), falseNeg (見逃し) などの統計を更新します。

testdata ディレクトリ内のテストファイル

testdata ディレクトリには、様々なGoの機能や並行処理パターンにおけるデータ競合をシミュレートする多数のテストファイルが含まれています。これらのファイルは、特定の種類の競合を意図的に発生させるように記述されており、競合検出器がそれらを正しく報告するかどうかを検証します。

例:

  • atomic_test.go: sync/atomic パッケージの操作における競合(または競合がないこと)をテストします。
  • chan_test.go: チャネル操作(送受信、クローズ、len/cap)における競合をテストします。
  • map_test.go: マップの読み書き、範囲操作、削除などにおける競合をテストします。
  • mutex_test.go, rwmutex_test.go: ミューテックスやRWMutexの誤用による競合をテストします。
  • mop_test.go: 様々なメモリ操作(Method-On-Pointer、構造体フィールド、インターフェース変換など)における競合をテストします。このファイルは特に大きく、多様なシナリオをカバーしています。
  • cgo_test.go, cgo_test_main.go: Cgoを介したGoとCのコード間の同期における競合をテストします。
  • finalizer_test.go: ファイナライザとデータ競合の相互作用をテストします。
  • io_test.go: ファイルI/OやHTTPリクエストなどのI/O操作における競合をテストします。
  • slice_test.go: スライスの操作における競合をテストします。
  • sync_test.go, waitgroup_test.go: sync パッケージの他のプリミティブ(WaitGroupなど)における競合をテストします。

これらのテストは、意図的にデータ競合を発生させる TestRace... という名前の関数と、データ競合が発生しないことを確認する TestNoRace... という名前の関数で構成されています。また、TestRaceFailing... のように、競合検出器が特定のケースで競合を検出できない(または検出しない)ことを示すテストも含まれており、これは競合検出器の限界や設計上のトレードオフを示唆しています。

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

このコミットでは、主に以下のファイルが新規追加されています。

  • src/pkg/runtime/race/race_test.go: 競合検出器のテストハーネス。testdata ディレクトリ内のテストを実行し、その出力を解析して競合検出の正確性を検証します。
  • src/pkg/runtime/race/testdata/atomic_test.go: sync/atomic パッケージの操作に関する競合テスト。
  • src/pkg/runtime/race/testdata/cgo_test.go: Cgo関連の競合テスト。
  • src/pkg/runtime/race/testdata/cgo_test_main.go: cgo_test.go で使用されるCgoのメインプログラム。
  • src/pkg/runtime/race/testdata/chan_test.go: チャネル操作に関する競合テスト。
  • src/pkg/runtime/race/testdata/comp_test.go: 複合型(構造体、配列)に関する競合テスト。
  • src/pkg/runtime/race/testdata/finalizer_test.go: ファイナライザに関する競合テスト。
  • src/pkg/runtime/race/testdata/io_test.go: I/O操作に関する競合テスト。
  • src/pkg/runtime/race/testdata/map_test.go: マップ操作に関する競合テスト。
  • src/pkg/runtime/race/testdata/mop_test.go: 様々なメモリ操作(Method-On-Pointer)に関する広範な競合テスト。
  • src/pkg/runtime/race/testdata/mutex_test.go: ミューテックスに関する競合テスト。
  • src/pkg/runtime/race/testdata/regression_test.go: 過去に報告された競合バグの回帰テスト。
  • src/pkg/runtime/race/testdata/rwmutex_test.go: RWMutexに関する競合テスト。
  • src/pkg/runtime/race/testdata/select_test.go: select ステートメントに関する競合テスト。
  • src/pkg/runtime/race/testdata/slice_test.go: スライス操作に関する競合テスト。
  • src/pkg/runtime/race/testdata/sync_test.go: sync パッケージの一般的な同期プリミティブに関する競合テスト。
  • src/pkg/runtime/race/testdata/waitgroup_test.go: sync.WaitGroup に関する競合テスト。

合計17のファイルが追加され、そのほとんどが競合検出器のテストケースです。

コアとなるコードの解説

このコミットの核となるのは、src/pkg/runtime/race/race_test.go ファイルです。このファイルは、Goのテストフレームワークと連携して、競合検出器の動作を自動的に検証する仕組みを提供します。

// src/pkg/runtime/race/race_test.go

// TestRace は、race detector のテストを実行し、その出力を解析して検証します。
func TestRace(t *testing.T) {
	testOutput, err := runTests() // testdata 内のテストを実行し、出力を取得
	if err != nil {
		t.Fatalf("Failed to run tests: %v", err)
	}
	reader := bufio.NewReader(bytes.NewBuffer(testOutput))

	funcName := ""
	var tsanLog []string
	for {
		s, err := nextLine(reader) // 1行ずつ読み込み
		if err != nil {
			fmt.Printf("%s\n", processLog(funcName, tsanLog)) // 最後のテストのログを処理
			break
		}
		if strings.HasPrefix(s, testPrefix) { // 新しいテストの開始を検出
			fmt.Printf("%s\n", processLog(funcName, tsanLog)) // 前のテストのログを処理
			tsanLog = make([]string, 0, 100)
			funcName = s[len(testPrefix):]
		} else {
			tsanLog = append(tsanLog, s) // 現在のテストのログを収集
		}
	}

	// 最終的な統計を出力
	fmt.Printf("\nPassed %d of %d tests (%.02f%%, %d+, %d-)\n",
		passedTests, totalTests, 100*float64(passedTests)/float64(totalTests), falsePos, falseNeg)
	fmt.Printf("%d expected failures (%d has not fail)\n", failingPos+failingNeg, failingNeg)
	if failed {
		t.Fail() // 失敗したテストがあれば、Goテストとして失敗とマーク
	}
}

// processLog は、ThreadSanitizer のログを解析し、競合レポートが含まれているか、
// そしてそれがテストケース名と一致するかを検証します。
func processLog(testName string, tsanLog []string) string {
	// "Race" または "NoRace" で始まるテスト名のみを処理
	if !strings.HasPrefix(testName, "Race") && !strings.HasPrefix(testName, "NoRace") {
		return ""
	}
	gotRace := false
	for _, s := range tsanLog {
		if strings.Contains(s, "DATA RACE") { // "DATA RACE" 文字列で競合検出を判断
			gotRace = true
			break
		}
	}

	failing := strings.Contains(testName, "Failing") // 意図的に失敗するテストか
	expRace := !strings.HasPrefix(testName, "No")    // 競合が期待されるテストか

	// テスト名を見やすいように整形
	for len(testName) < visibleLen {
		testName += " "
	}

	// 期待される結果と実際の検出結果を比較
	if expRace == gotRace {
		passedTests++
		totalTests++
		if failing { // 意図的に失敗するテストで、期待通りに競合が検出された場合
			failed = true
			failingNeg++ // 失敗が期待されたが、競合が検出されなかったケース
		}
		return fmt.Sprintf("%s .", testName) // 成功
	}
	pos := ""
	if expRace { // 競合が期待されたが検出されなかった (False Negative)
		falseNeg++
	} else { // 競合が期待されなかったが検出された (False Positive)
		falsePos++
		pos = "+"
	}
	if failing { // 意図的に失敗するテストで、期待通りに競合が検出されなかった場合
		failingPos++
	} else {
		failed = true // 意図しない失敗
	}
	totalTests++
	return fmt.Sprintf("%s %s%s", testName, "FAILED", pos) // 失敗
}

// runTests は、パッケージとその依存関係がインストルメンテーション(-race)を有効にしてビルドされ、
// ThreadSanitizer からのデータ競合レポートを含む 'go test' の出力を返します。
func runTests() ([]byte, error) {
	tests, err := filepath.Glob("./testdata/*_test.go") // testdata 内のテストファイルを取得
	if err != nil {
		return nil, err
	}
	args := []string{"test", "-race", "-v"}
	args = append(args, tests...) // テストファイルを引数に追加
	cmd := exec.Command("go", args...)

	// 競合レポートの抑制ヒューリスティックを無効にするための環境変数設定
	// これにより、テストが同じアドレスで多数の競合を発生させる場合でも、すべての競合が報告されます。
	cmd.Env = append(os.Environ(), `GORACE="suppress_equal_stacks=0 suppress_equal_addresses=0"`)
	ret, _ := cmd.CombinedOutput() // コマンドを実行し、出力を取得
	return ret, nil
}

このコードは、Goの競合検出器のテスト戦略を明確に示しています。

  1. go test -race を使用して、競合検出器を有効にした状態でテストを実行します。
  2. GORACE 環境変数を設定することで、TSanのデフォルトの挙動(例えば、同じスタックトレースやアドレスからの重複する競合レポートを抑制する)をオーバーライドし、テストの網羅性を高めています。
  3. テストの出力から "DATA RACE" という文字列を検索することで、競合が検出されたかどうかをプログラム的に判断します。
  4. テストケースの命名規則(TestRace...TestNoRace...)を利用して、期待される競合の有無と実際の検出結果を比較し、テストの合否を判定します。これにより、競合検出器が誤検知(False Positive)や見逃し(False Negative)をしていないかを検証します。

関連リンク

参考にした情報源リンク