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

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

このコミットは、Go言語のtestingパッケージにおけるdefer関数の実行順序に関する競合状態(defer race)を修正するものです。具体的には、t.Fatal()が呼び出された際に、テストが終了する前にdeferされたクリーンアップ関数が確実に実行されるように、テストランナー(tRunner)のロジックが変更されています。これにより、一時ファイルの削除やファイルシステムのアンマウントといった重要なクリーンアップ処理が、テストの失敗時にも保証されるようになります。

コミット

commit 4953b87296f53c5e0c7c62a775f1c088d4212902
Author: Russ Cox <rsc@golang.org>
Date:   Thu Jan 12 10:18:12 2012 -0800

    testing: fix defer race
    
    In a test that does
    
            func TestFoo(t *testing.T) {
                    defer cleanup()
                    t.Fatal("oops")
            }
    
    it can be important that cleanup run as the test fails.
    The old code did this in Fatal:
    
            t.signal <- t
            runtime.Goexit()
    
    The runtime.Goexit would run the deferred cleanup
    but the send on t.signal would cause the main test loop
    to move on and possibly even exit the program before
    the runtime.Goexit got a chance to run.
    
    This CL changes tRunner (the top stack frame of a test
    goroutine) to send on t.signal as part of a function
    deferred by the top stack frame.  This delays the send
    on t.signal until after runtime.Goexit has run functions
    deferred by the test itself.
    
    For the above TestFoo, this CL guarantees that cleanup
    will run before the test binary exits.
    
    This is particularly important when cleanup is doing
    externally visible work, like removing temporary files
    or unmounting file systems.
    
    R=golang-dev, r
    CC=golang-dev
    https://golang.org/cl/5532078

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

https://github.com/golang/go/commit/4953b87296f53c5e0c7c62a775f1c088d4212902

元コミット内容

testing: fix defer race

In a test that does

        func TestFoo(t *testing.T) {
                defer cleanup()
                t.Fatal("oops")
        }

it can be important that cleanup run as the test fails.
The old code did this in Fatal:

        t.signal <- t
        runtime.Goexit()

The runtime.Goexit would run the deferred cleanup
but the send on t.signal would cause the main test loop
to move on and possibly even exit the program before
the runtime.Goexit got a chance to run.

This CL changes tRunner (the top stack frame of a test
goroutine) to send on t.signal as part of a function
deferred by the top stack frame.  This delays the send
on t.signal until after runtime.Goexit has run functions
deferred by the test itself.

For the above TestFoo, this CL guarantees that cleanup
will run before the test binary exits.

This is particularly important when cleanup is doing
externally visible work, like removing temporary files
or unmounting file systems.

R=golang-dev, r
CC=golang-dev
https://golang.org/cl/5532078

変更の背景

Goのtestingパッケージにおいて、テスト関数内でdeferキーワードを使って遅延実行される関数(例えば、テスト後に一時ファイルを削除するcleanup関数など)を登録し、かつテストがt.Fatal()t.FailNow()によって途中で終了する場合に問題が発生していました。

従来のt.Fatal()の実装では、まずテストの終了を通知するシグナルをチャネル(t.signal)に送信し、その後にruntime.Goexit()を呼び出して現在のゴルーチンを終了させていました。runtime.Goexit()は、現在のゴルーチンが終了する際に、そのゴルーチン内でdeferされた関数をすべて実行するという特性があります。

しかし、t.signalへの送信がruntime.Goexit()の呼び出しよりも前に行われるため、メインのテストループがテストの終了を検知し、次のテストに進んだり、場合によってはプログラム全体が終了したりする可能性がありました。この「テストの終了通知」と「deferされた関数の実行」の間に競合状態が生じ、runtime.Goexit()deferされた関数を実行する機会を得る前に、テストバイナリが終了してしまうことがありました。

この問題は、特に一時ファイルの削除やファイルシステムのアンマウントなど、外部に影響を与えるクリーンアップ処理がテストの失敗時に確実に実行されないという深刻な結果を招く可能性がありました。このコミットは、この競合状態を解消し、deferされたクリーンアップ関数がテストの失敗時にも確実に実行されるようにするために導入されました。

前提知識の解説

Go言語のdeferキーワード

deferキーワードは、Go言語において関数の実行を遅延させるために使用されます。deferに続く関数呼び出しは、その関数がリターンする直前(パニックが発生した場合も含む)に実行されることが保証されます。これは、リソースの解放(ファイルのクローズ、ロックの解除など)やクリーンアップ処理を確実に行うために非常に便利です。deferされた関数はLIFO(Last-In, First-Out)の順序で実行されます。

runtime.Goexit()

runtime.Goexit()は、現在のゴルーチンを終了させるためのGoの組み込み関数です。この関数が呼び出されると、現在のゴルーチンは直ちに実行を停止し、そのゴルーチン内でdeferされたすべての関数が実行されます。その後、ゴルーチンは終了します。runtime.Goexit()は、panicとは異なり、呼び出し元のスタックをアンワインドせず、呼び出し元に制御を戻しません。

testingパッケージとt.Fatal() / t.FailNow()

Goの標準ライブラリであるtestingパッケージは、ユニットテストやベンチマークテストを記述するためのフレームワークを提供します。

  • *testing.Tは個々のテストのコンテキストを表します。
  • t.Fatal(args ...interface{})またはt.Fatalf(format string, args ...interface{})は、テストを失敗としてマークし、現在のテストゴルーチンを直ちに終了させます。この関数が呼び出されると、テスト関数内のそれ以降のコードは実行されません。内部的にはt.FailNow()を呼び出します。
  • t.FailNow()は、テストを失敗としてマークし、現在のテストゴルーチンをruntime.Goexit()を呼び出すことで終了させます。

tRunner

tRunnerは、testingパッケージ内部で各テスト関数を実行するために使用される関数です。各テスト関数は、tRunnerによって新しいゴルーチンで起動されます。tRunnerは、テストの開始時刻の記録、テスト関数の実行、テストの終了時刻の記録、そしてテスト結果をメインのテストループに通知する役割を担っています。

技術的詳細

このコミットが解決しようとしている問題は、t.Fatal()(またはt.FailNow())が呼び出された際のdeferされた関数の実行と、テスト終了のシグナル送信のタイミングに関するものです。

旧来の動作:

  1. テスト関数内でt.Fatal()が呼び出される。
  2. t.Fatal()内部で、まずc.signal <- c.selfc*testing.Tまたは*testing.Bの共通構造体)によって、テストが終了したことをメインのテストループに通知するシグナルが送信される。
  3. 次にruntime.Goexit()が呼び出され、現在のテストゴルーチンが終了し、そのゴルーチン内でdeferされた関数が実行される。

この順序では、シグナルが送信された後、メインのテストループがテストゴルーチンの終了を待たずに次の処理(例えば、次のテストの開始やプログラムの終了)に進んでしまう可能性がありました。その結果、runtime.Goexit()deferされたクリーンアップ関数を実行する前に、テストバイナリ自体が終了してしまうという「競合状態」が発生していました。

新しい動作:

このコミットでは、t.signalへのシグナル送信のタイミングが変更されました。

  • t.Fatal()からはc.signal <- c.selfの行が削除され、純粋にruntime.Goexit()を呼び出すだけになりました。
  • 代わりに、tRunner関数(各テストゴルーチンの最上位スタックフレーム)に、新しいdefer関数が追加されました。このdefer関数は、tRunnerゴルーチンが終了する際に、テストの実行時間を記録し、t.signal <- tによってテスト終了のシグナルを送信します。

この変更により、t.Fatal()が呼び出された場合でも、runtime.Goexit()がまずdeferされたすべての関数(テスト関数内でdeferされたクリーンアップ関数を含む)を実行します。そして、そのゴルーチンが完全に終了する直前に、tRunnerに新しく追加されたdefer関数が実行され、そこでt.signalへのシグナル送信が行われます。

これにより、t.signalへのシグナル送信は、テストゴルーチン内のすべてのdefer関数が実行された後にのみ行われることが保証されます。結果として、メインのテストループがテストの終了を検知する前に、重要なクリーンアップ処理が確実に完了するようになります。

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

src/pkg/testing/benchmark.go

--- a/src/pkg/testing/benchmark.go
+++ b/src/pkg/testing/benchmark.go
@@ -142,6 +142,13 @@ func (b *B) run() BenchmarkResult {
 func (b *B) launch() {
 	// Run the benchmark for a single iteration in case it's expensive.
 	n := 1
+
+	// Signal that we're done whether we return normally
+	// or by FailNow's runtime.Goexit.
+	defer func() {
+		b.signal <- b
+	}()
+
 	b.runN(n)
 	// Run the benchmark for at least the specified amount of time.
 	d := time.Duration(*benchTime * float64(time.Second))
@@ -162,7 +169,6 @@ func (b *B) launch() {
 		b.runN(n)
 	}
 	b.result = BenchmarkResult{b.N, b.duration, b.bytes}
-	b.signal <- b
 }

src/pkg/testing/testing.go

--- a/src/pkg/testing/testing.go
+++ b/src/pkg/testing/testing.go
@@ -136,9 +136,27 @@ func (c *common) Failed() bool { return c.failed }
 // FailNow marks the function as having failed and stops its execution.
 // Execution will continue at the next Test.
 func (c *common) FailNow() {
-	c.duration = time.Now().Sub(c.start)
 	c.Fail()
-	c.signal <- c.self
+
+	// Calling runtime.Goexit will exit the goroutine, which
+	// will run the deferred functions in this goroutine,
+	// which will eventually run the deferred lines in tRunner,
+	// which will signal to the test loop that this test is done.
+	//
+	// A previous version of this code said:
+	//
+	//		c.duration = ...
+	//		c.signal <- c.self
+	//		runtime.Goexit()
+	//
+	// This previous version duplicated code (those lines are in
+	// tRunner no matter what), but worse the goroutine teardown
+	// implicit in runtime.Goexit was not guaranteed to complete
+	// before the test exited.  If a test deferred an important cleanup
+	// function (like removing temporary files), there was no guarantee
+	// it would run on a test failure.  Because we send on c.signal during
+	// a top-of-stack deferred function now, we know that the send
+	// only happens after any other stacked defers have completed.
 	runtime.Goexit()
 }
 
@@ -195,9 +213,17 @@ type InternalTest struct {
 
 func tRunner(t *T, test *InternalTest) {
 	t.start = time.Now()
+
+	// When this goroutine is done, either because test.F(t)
+	// returned normally or because a test failure triggered 
+	// a call to runtime.Goexit, record the duration and send
+	// a signal saying that the test is done.
+	defer func() {
+		t.duration = time.Now().Sub(t.start)
+		t.signal <- t
+	}()
+
 	test.F(t)
-	t.duration = time.Now().Sub(t.start)
-	t.signal <- t
 }

コアとなるコードの解説

src/pkg/testing/testing.go の変更

  • FailNow()関数の変更:

    • 以前はc.duration = time.Now().Sub(c.start)c.signal <- c.selfruntime.Goexit()の前に実行されていました。
    • これらの行が削除され、FailNow()c.Fail()を呼び出した後、直接runtime.Goexit()を呼び出すだけになりました。
    • これにより、テストの失敗シグナルが送信される前に、runtime.Goexit()によってdeferされた関数が確実に実行されるようになります。コメントにもあるように、以前のバージョンではruntime.Goexitによるゴルーチンのティアダウンがテスト終了前に完了することが保証されていませんでした。
  • tRunner関数の変更:

    • tRunnerは、各テスト関数が実行されるゴルーチンの最上位の関数です。
    • 新しいdefer関数が追加されました。このdefer関数は、tRunnerゴルーチンが正常に終了する場合でも、runtime.Goexit()によって終了する場合でも、必ず実行されます。
    • このdefer関数内で、テストの実行時間(t.duration)が計算され、t.signal <- tによってテスト終了のシグナルが送信されます。
    • これにより、t.signalへのシグナル送信は、tRunnerゴルーチンが完全に終了する直前、つまりテスト関数内でdeferされたすべての関数が実行された後にのみ行われることが保証されます。

src/pkg/testing/benchmark.go の変更

  • launch()関数の変更:
    • *testing.B(ベンチマークテスト)のlaunch()関数も同様の競合状態を抱えていたため、tRunnerと同様の修正が適用されました。
    • 以前はb.signal <- bが関数の最後に直接呼び出されていました。
    • この行が削除され、代わりに新しいdefer関数が追加されました。このdefer関数内でb.signal <- bが実行されます。
    • これにより、ベンチマークテストが正常に終了する場合でも、FailNow()によって終了する場合でも、deferされたクリーンアップ処理が完了した後にのみシグナルが送信されるようになります。

これらの変更により、testingパッケージは、テスト関数内でdeferされたクリーンアップ処理が、テストが成功したか失敗したかにかかわらず、常に確実に実行されることを保証するようになりました。これは、テストの信頼性と堅牢性を高める上で非常に重要な改善です。

関連リンク

参考にした情報源リンク

  • Go言語の公式ドキュメント(deferruntime.Goexittestingパッケージに関する情報)
  • コミットメッセージとコードの差分