時間(とその他の非同期処理)をテストする
Testing Time (and other asynchronicities) by Damien Neil
Go 1.24 では、実験的パッケージとして testing/synctest パッケージを導入しました。
このパッケージを使うと、並行かつ非同期なコードのテストの記述を大幅に簡略化できます。
Go 1.25 では、testing/synctest パッケージは実験段階を卒業し、正式に一般提供されるようになりました。
以下は、2025年にベルリンで開催された GopherCon Europe での
testing/synctest パッケージに関する私の講演をブログ記事化したものです。
非同期関数とは何か
同期関数はとてもシンプルです。呼び出すと、何かを行い、そして戻ってきます。
非同期関数はこれとは異なります。呼び出すと、まず戻ってきて、その後で何かを行います。
具体的な例として、多少作為的ではありますが、次の Cleanup 関数は同期的です。
呼び出すと、キャッシュディレクトリを削除して、戻ってきます。
func (c *Cache) Cleanup() {
os.RemoveAll(c.cacheDir)
}
CleanupInBackground は非同期関数です。呼び出すと戻ってきて、キャッシュディレクトリは……遅かれ早かれ削除されます。
func (c *Cache) CleanupInBackground() {
go os.RemoveAll(c.cacheDir)
}
非同期関数の中には、未来のある時点で何かを行うものもあります。たとえば context パッケージの
WithDeadline 関数は、将来キャンセルされることになるコンテキストを返します。
package context
// WithDeadline は d より後にならないデッドラインを持つ、
// 派生コンテキストを返す。
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
並行なコードのテストと言うとき、私はこのような非同期処理のテストのことを指しています。 それは実時間を使うものも、使わないものも含みます。
テスト
テストとは、システムが期待通りに振る舞うことを検証するものです。テストの種類を表す用語は、単体テストや 結合テストなど数多くありますが、ここでの議論のためには、あらゆる種類のテストは次の3つのステップに帰着します。
- 何らかの初期条件を用意する。
- テスト対象のシステムに何かをさせる。
- 結果を検証する。
同期関数のテストは単純です。
- 関数を呼び出す
- 関数が何かを行い、戻ってくる
- 結果を検証する
一方、非同期関数のテストは厄介です。
- 関数を呼び出す
- 戻ってくる
- それが何をするにせよ、完了するのを待つ
- 結果を検証する
正しい時間だけ待たなければ、まだ発生していない、あるいは部分的にしか発生していない処理の結果を 検証してしまうことになりかねません。これはうまくいった試しがありません。
非同期関数のテストが特に厄介になるのは、何かが起きて いない ことをアサートしたい場合です。 まだ起きていないことは確認できますが、この先も起きないと確実に言えるのはどうすればよいのでしょうか。
具体例
もう少し具体的にするために、実際の例に取り組んでみましょう。ここでもう一度、context パッケージの
WithDeadline 関数を考えます。
package context
// WithDeadline は d より後にならないデッドラインを持つ、
// 派生コンテキストを返す。
func WithDeadline(parent Context, d time.Time) (Context, CancelFunc)
WithDeadline に対して書くべき明らかなテストが2つあります。
- コンテキストは、デッドラインより 前 ではキャンセルされ ない 。
- コンテキストは、デッドラインより 後 では キャンセルされる 。
テストを書いてみましょう。
コードの量をなるべく圧倒的なものにしないために、2番目のケースだけをテストすることにします。 デッドラインが過ぎたあと、コンテキストがキャンセルされる、というものです。
func TestWithDeadlineAfterDeadline(t *testing.T) {
deadline := time.Now().Add(1 * time.Second)
ctx, _ := context.WithDeadline(t.Context(), deadline)
time.Sleep(time.Until(deadline))
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
}
このテストは単純です。
context.WithDeadlineを使って、1秒後にデッドラインを迎えるコンテキストを作成する。- デッドラインまで待つ。
- コンテキストがキャンセルされていることを検証する。
残念ながら、このテストには明らかな問題があります。デッドラインが切れるちょうどその瞬間まで スリープしているのです。検証する時点では、コンテキストがまだキャンセルされていない可能性が高いでしょう。 よくても、このテストは非常に不安定(flaky)になります。
修正してみましょう。
time.Sleep(time.Until(deadline) + 100*time.Millisecond)
デッドラインの100ミリ秒後までスリープすればよいのです。100ミリ秒はコンピューターにとっては 永遠にも等しい時間です。これで問題ないはずです。
残念ながら、それでもまだ2つの問題が残っています。
1つ目は、このテストの実行に1.1秒かかることです。これは遅すぎます。これは単純なテストであり、 せいぜいミリ秒単位で実行が終わるべきです。
2つ目は、このテストが不安定なことです。100ミリ秒はコンピューターにとって永遠のような時間ですが、 負荷の高い継続的インテグレーション(CI)システムでは、それよりずっと長い停止が発生するのも珍しくありません。 このテストは開発者のワークステーションではおそらく安定して通るでしょうが、CIシステムでは 時折失敗すると予想されます。
遅いか不安定かの二択
実時間を使うテストは、常に遅いか不安定かのどちらかです。たいていは両方です。テストが必要以上に 長く待てば遅くなり、十分に長く待たなければ不安定になります。テストをより遅く、より不安定でなく することも、より速く、より不安定にすることもできますが、速くて信頼できるものにすることはできません。
net/http パッケージには、この手法を使ったテストが数多くあります。そのすべてが遅い、あるいは
不安定、あるいはその両方であり、それが今日私たちをここに至らしめた道のりの始まりでした。
同期関数への書き換え
非同期関数をテストする一番簡単な方法は、そもそもテストしないことです。同期関数はテストが簡単です。 非同期関数を同期関数に変換できるなら、テストはより簡単になります。
たとえば、先ほどのキャッシュのクリーンアップ関数を考えると、同期的な Cleanup は非同期の
CleanupInBackground より明らかに優れています。同期関数の方がテストしやすく、必要であれば
呼び出し側で新しいゴルーチンを起動してバックグラウンドで実行することも簡単にできます。
一般的な原則として、並行処理をできるだけ呼び出しスタックの上位に押し上げるほど良いということです。
// CleanupInBackgroundはテストが難しい。
cache.CleanupInBackground()
// Cleanupはテストが簡単で、
// 必要ならバックグラウンドで実行するのも簡単。
go cache.Cleanup()
残念ながら、このような変換が常に可能とは限りません。たとえば context.WithDeadline は
本質的に非同期なAPIです。
テスト可能にするための計装
より良いアプローチは、コードをよりテストしやすくすることです。
WithDeadline のテストでこれがどう見えるか、その例を示します。
func TestWithDeadlineAfterDeadline(t *testing.T) {
clock := fakeClock()
timeout := 1 * time.Second
deadline := clock.Now().Add(timeout)
ctx, _ := context.WithDeadlineClock(
t.Context(), deadline, clock)
clock.Advance(timeout)
context.WaitUntilIdle(ctx)
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
}
実時間を使う代わりに、フェイクの時間実装を使います。フェイクの時間を使えば、何もせず待ち続けることが なくなるため、不必要にテストが遅くなることを避けられます。また、現在時刻はテストが調整したときにしか 変化しないため、テストの不安定さも避けやすくなります。
世の中にはさまざまなフェイク時間のパッケージがありますし、自分で書くこともできます。
フェイクの時間を使うには、フェイクの時計(clock)を受け取れるようにAPIを変更する必要があります。
ここでは、追加の clock 引数を取る context.WithDeadlineClock 関数を用意しました。
ctx, _ := context.WithDeadlineClock(
t.Context(), deadline, clock)
フェイクの時計を進めると、問題が生じます。時間を進めることは非同期な操作です。スリープしている ゴルーチンが目を覚ましたり、タイマーがそのチャネルに送信したり、タイマー関数が実行されたりするかも しれません。システムの期待する振る舞いをテストする前に、それらの処理が完了するのを待つ必要があります。
ここでは、コンテキストに関連するバックグラウンドの処理がすべて完了するのを待つ
context.WaitUntilIdle 関数を用意しました。
clock.Advance(timeout)
context.WaitUntilIdle(ctx)
これは単純な例ですが、テスト可能な並行コードを書くための2つの基本原則を示しています。
- 時間を使うなら、フェイクの時間を使う。
- 静止状態(quiescence)を待つ何らかの手段を用意する。これは「すべてのバックグラウンド活動が止まり、 システムが安定している」ことをかっこよく言い換えた表現です。
もちろん、興味深い問いは、それをどうやって実現するかです。この例では細部を端折っていますが、 それはこのアプローチには大きな欠点がいくつかあるからです。
難しいのです。フェイクの時計を使うこと自体は難しくありませんが、バックグラウンドの並行処理が いつ終わり、いつシステムの状態を調べても安全なのかを見極めることは難しいのです。
コードのイディオム性が下がります。標準の time パッケージの関数が使えなくなります。バックグラウンドで
起きているすべてのことを、非常に注意深く追跡する必要があります。
計装が必要になるのは自分のコードだけではなく、使用している他のパッケージすべてです。サードパーティの 並行コードを呼び出している場合、おそらくお手上げでしょう。
最悪なことに、既存のコードベースにこのアプローチを後付けするのは、ほぼ不可能に近いことがあります。
私はこのアプローチをGoのHTTP実装に適用しようとしました。一部ではある程度うまくいきましたが、 HTTP/2サーバーには単純に打ち負かされてしまいました。特に、大規模な書き換えなしに静止状態を 検知するための計装を追加することは実現不可能である、少なくとも私の腕では歯が立たないとわかりました。
ランタイムのひどいハック
コードをテスト可能にできないとしたら、どうすればよいでしょうか。
コードを計装する代わりに、計装されていないシステムの振る舞いを観測する方法があったらどうでしょうか。
Goのプログラムはゴルーチンの集合から成り立っています。それらのゴルーチンには状態があります。 すべてのゴルーチンが実行を止めるまで待ちさえすればよいのです。
残念ながら、Goのランタイムには、それらのゴルーチンが何をしているかを知る手段は用意されていません。 ……本当にそうでしょうか。
runtime パッケージには、実行中のすべてのゴルーチンについてスタックトレースとその状態を教えてくれる
関数があります。これは人間が読むためのテキストですが、その出力をパースすることはできます。
これを使って静止状態を検知できないでしょうか。
もちろん、これはひどいアイデアです。これらのスタックトレースの形式が将来にわたって安定しているという 保証はありません。これはやってはいけないことです。
私はやりました。そしてうまくいきました。実際、驚くほどうまくいったのです。
フェイクの時計の単純な実装、テストの一部であるゴルーチンを追跡するためのわずかな計装、そして
runtime.Stack を恐ろしい形で悪用することで、ついに http パッケージに対する速く信頼できる
テストを書く方法を手に入れました。
これらのテストの内部実装はひどいものでしたが、そこには有用な概念があることを実証してくれました。
より良い方法
Goには組み込みの並行処理機構がありますが、その並行処理を使うプログラムをテストするのは大変です。
私たちは残念な選択を迫られていました。シンプルでイディオマティックなコードを書けば、速く信頼できる テストを書くことは不可能になり、テスト可能なコードを書けば、それは複雑でイディオムから外れたものに なってしまうのです。
そこで私たちは、これをより良くするために何ができるかを自問しました。
先に見た通り、テスト可能な並行コードを書くために必要な2つの基本機能は、フェイクの時間と、 静止状態を待つ手段です。
静止状態を待つためのより良い方法が必要です。バックグラウンドのゴルーチンが仕事を終えたかどうかを、 ランタイムに問い合わせられるようにするべきです。また、無関係なテスト同士が干渉しないように、 この問い合わせの範囲を単一のテストに限定できるようにもしたいところです。
フェイクの時間を使うプログラムをテストするための、より良いサポートも必要です。
フェイクの時間の実装を作ること自体は難しくありませんが、そのような実装を使うコードはイディオマティック ではありません。
イディオマティックなコードは time.Timer を使いますが、フェイクの Timer を作ることはできません。
テストがタイマーの発火タイミングを制御できるような、フェイクの Timer を作る手段をテストに
提供すべきかどうか、私たちは自問しました。
時間のテスト用実装を作るには、time パッケージの全く新しいバージョンを定義し、それを時間を扱う
すべての関数に渡す必要があります。私たちは、ネットワーク接続を表す共通インターフェースである
net.Conn と同じように、時間についても共通のインターフェースを定義すべきかどうかを検討しました。
しかし、そこで気づいたのは、ネットワーク接続と違って、フェイクの時間の実装はただ1通りしかありえない ということです。フェイクのネットワークには、レイテンシやエラーを導入したいという要求があるかも しれません。それに対して時間がすることはただ1つ、前に進むことだけです。テストは時間が進む速度を 制御する必要がありますが、10秒後に発火するよう設定されたタイマーは、常に(フェイクかもしれない) 10秒後に発火するべきです。
さらに、Goのエコシステム全体をひっくり返すようなことはしたくありません。今日のほとんどのプログラムは
time パッケージの関数を使っています。それらのプログラムを、動き続けるだけでなく、イディオマティック
なままにしておきたいのです。
この結論から、Go Playgroundがフェイクの時計を使っているのとよく似たやり方で、テストが time
パッケージにフェイクの時計を使うよう伝える手段こそが必要なのだ、とわかりました。Playgroundと違うのは、
その変更の範囲を単一のテストに限定する必要があるという点です。(Go Playgroundがフェイクの時計を
使っていることは一見わかりにくいかもしれません。フェイクの遅延をフロントエンドで実際の遅延に変換して
いるためですが、実際には使われています。)
synctest の実験
そこでGo 1.24では、並行プログラムのテストを簡略化するための新しい実験的パッケージとして
testing/synctest を導入しました。Go 1.24のリリース後の
数か月間、私たちは初期の採用者からフィードバックを集めました。(試してくださった皆さん、ありがとう
ございました!)問題点や不足していた部分に対処するために、数多くの変更を加えました。そして今、
Go 1.25で、私たちは testing/synctest パッケージを標準ライブラリの一部としてリリースしました。
これを使うと、私たちが「バブル(bubble)」と呼んでいるものの中で関数を実行できます。バブルの内部では、
time パッケージはフェイクの時計を使い、synctest パッケージはバブルが静止するのを待つための関数を
提供します。
synctest パッケージ
synctest パッケージが含む関数はわずか2つです。
package synctest
// Test は f を新しいバブル内で実行する。
// バブル内のゴルーチンはフェイクの時計を使う。
func Test(t *testing.T, f func(*testing.T))
// Wait はバブル内のバックグラウンド処理が完了するのを待つ。
func Wait()
Test は関数を新しいバブル内で実行します。
Wait は、バブル内のすべてのゴルーチンが、
バブル内の他のゴルーチンを待ってブロックされる状態になるまでブロックします。この状態を私たちは
「永続的にブロックされている(durably blocked)」と呼んでいます。
synctest を使ったテスト
実際に動作する synctest の例を見てみましょう。
func TestWithDeadlineAfterDeadline(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
deadline := time.Now().Add(1 * time.Second)
ctx, _ := context.WithDeadline(t.Context(), deadline)
time.Sleep(time.Until(deadline))
synctest.Wait()
if err := ctx.Err(); err != context.DeadlineExceeded {
t.Fatalf("context not canceled after deadline")
}
})
}
少し見覚えがあるかもしれません。これは、先ほど見た context.WithDeadline の素朴なテストです。
変更点は、テストをバブル内で実行するために synctest.Test 呼び出しでラップしたことと、
synctest.Wait 呼び出しを追加したことだけです。
このテストは速く、信頼できます。ほぼ瞬時に実行が終わります。テスト対象のシステムの期待する振る舞いを
正確にテストしています。しかも context パッケージを一切変更する必要がありません。
synctest パッケージを使えば、シンプルでイディオマティックなコードを書き、それを信頼性高く
テストできます。
もちろんこれはとても単純な例ですが、これは実際のプロダクションコードに対する実際のテストです。
context パッケージが書かれた当時に synctest が存在していたら、そのテストを書くのはずっと
楽だったことでしょう。
時間
バブル内の時間は、Go Playgroundのフェイクの時間とほぼ同じように振る舞います。時間はUTCの 2000年1月1日午前0時から始まります。何らかの理由で特定の時点でテストを実行する必要があれば、 単にその時点までスリープすればよいのです。
func TestAtSpecificTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
// 2000-01-01 00:00:00 +0000 UTC
t.Log(time.Now().In(time.UTC))
// これに25年もかからない。
time.Sleep(time.Until(
time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)))
// 2025-01-01 00:00:00 +0000 UTC
t.Log(time.Now().In(time.UTC))
})
}
時間は、バブル内のすべてのゴルーチンがブロックされたときにだけ進みます。バブルは無限に高速な コンピューターをシミュレートしていると考えられます。どれだけの量の計算も、時間がかからないのです。
次のテストは、実時間がどれだけ経過していても、テスト開始からフェイクの時間が0秒しか経過していないと、 常に出力します。
func TestExpensiveWork(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
for range 1e7 {
// 重い処理を行う
}
t.Log(time.Since(start)) // 0s
})
}
次のテストでは、time.Sleep の呼び出しは、実時間で10秒待つのではなく、即座に戻ります。
このテストは、テスト開始からちょうどフェイクの10秒が経過したと、常に出力します。
func TestSleep(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()
time.Sleep(10 * time.Second)
t.Log(time.Since(start)) // 10s
})
}
静止状態を待つ
synctest.Wait 関数を使うと、バックグラウンドの処理が
完了するのを待てます。
func TestWait(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true
}()
// 上のゴルーチンが終了するのを待つ。
synctest.Wait()
t.Log(done) // true
})
}
上のテストで Wait の呼び出しがなければ、競合状態(race condition)が発生してしまいます。
1つのゴルーチンが done 変数を変更する一方で、別のゴルーチンが同期なしにそれを読み取るからです。
Wait の呼び出しがその同期を提供しています。
データ競合検出器を有効にする -race テストフラグをご存じの方も多いでしょう。データ競合検出器は
Wait が提供する同期を認識しているため、このテストに対して文句を言うことはありません。もし
Wait の呼び出しを忘れていれば、データ競合検出器は正しく文句を言うはずです。
synctest.Wait 関数は同期を提供しますが、時間の経過は同期を提供しません。
次の例では、1つのゴルーチンが done 変数に書き込む一方、別のゴルーチンは1ナノ秒スリープしてから
それを読み取ります。synctest のバブルの外で実時間の時計を使って実行した場合、このコードに
データ競合が含まれていることは明らかでしょう。synctest のバブルの内部では、フェイクの時計に
よって time.Sleep が戻る前にそのゴルーチンが完了することは保証されますが、それでもデータ競合
検出器は、このコードをバブルの外で実行した場合と同様に、データ競合を報告します。
func TestTimeDataRace(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
done := false
go func() {
done = true // 書き込み
}()
time.Sleep(1 * time.Nanosecond)
t.Log(done) // 読み取り(同期なし)
})
}
Wait 呼び出しを追加すると、明示的な同期が提供され、データ競合が修正されます。
time.Sleep(1 * time.Nanosecond)
synctest.Wait() // 同期
t.Log(done) // 読み取り
io.Copy の例
synctest.Wait が提供する同期を活用すれば、明示的な同期をあまり書かずに、よりシンプルな
テストを書けます。
たとえば、io.Copy に対する次のテストを考えてみましょう。
func TestIOCopy(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
srcReader, srcWriter := io.Pipe()
defer srcWriter.Close()
var dst bytes.Buffer
go io.Copy(&dst, srcReader)
data := "1234"
srcWriter.Write([]byte("1234"))
synctest.Wait()
if got, want := dst.String(), data; got != want {
t.Errorf("Copy wrote %q, want %q", got, want)
}
})
}
io.Copy 関数は io.Reader から io.Writer へデータをコピーします。コピーが完了するまで
ブロックするため、io.Copy を並行関数だとはすぐには思わないかもしれません。しかし、io.Copy
のリーダーにデータを供給することは非同期な操作です。
CopyはリーダーのReadメソッドを呼び出すReadは何らかのデータを返す- そしてそのデータは、後になってからライターに書き込まれる
このテストでは、io.Copy がバッファを埋めるのを待たずに、新しいデータをライターに書き込むことを
検証しています。
テストを1つずつ見ていきましょう。まず、io.Copy が読み込む元となる io.Pipe を作成します。
srcReader, srcWriter := io.Pipe()
defer srcWriter.Close()
新しいゴルーチンで io.Copy を呼び出し、パイプの読み込み側から bytes.Buffer へコピーします。
var dst bytes.Buffer
go io.Copy(&dst, srcReader)
パイプのもう一方の端に書き込み、io.Copy がそのデータを処理するのを待ちます。
data := "1234"
srcWriter.Write([]byte("1234"))
synctest.Wait()
最後に、コピー先のバッファに期待するデータが含まれていることを検証します。
if got, want := dst.String(), data; got != want {
t.Errorf("Copy wrote %q, want %q", got, want)
}
コピー先のバッファにミューテックスなどの同期を追加する必要はありません。synctest.Wait に
よって、それが並行にアクセスされないことが保証されているからです。
このテストはいくつかの重要な点を示しています。
io.Copy のように、戻ったあとに追加のバックグラウンド処理を行わない同期関数であっても、
非同期的な振る舞いを見せることがあります。
synctest.Wait を使えば、そうした振る舞いをテストできます。
また、このテストは時間を扱っていないことにも注意してください。非同期なシステムの多くは時間に 関わりますが、すべてがそうというわけではありません。
バブルの終了
synctest.Test 関数は、戻る前にバブル内のすべてのゴルーチンが終了するのを待ちます。ルートの
ゴルーチン(Test によって開始されたゴルーチン)が戻ったあとは、時間は進まなくなります。
次の例では、Test はバックグラウンドのゴルーチンが実行され終了するのを待ってから戻ります。
func TestWaitForGoroutine(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
go func() {
// synctest.Testが戻る前にこれは実行される。
}()
})
}
この例では、未来のある時点に向けて time.AfterFunc をスケジュールします。バブルのルートゴルーチンは、
その時点に達する前に戻ってしまうため、この AfterFunc は決して実行されません。
func TestDoNotWaitForTimer(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
time.AfterFunc(1 * time.Nanosecond, func() {
// これは決して実行されない。
})
})
}
次の例では、スリープするゴルーチンを起動します。ルートゴルーチンは戻り、時間は進まなくなります。
このときバブルはデッドロックしています。Test はバブル内のすべてのゴルーチンが終了するのを
待っていますが、スリープ中のゴルーチンは時間が進むのを待っているからです。
func TestDeadlock(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
go func() {
// このスリープは決して戻らず、テストはデッドロックする。
time.Sleep(1 * time.Nanosecond)
}()
})
}
デッドロック
synctest パッケージは、バブル内のすべてのゴルーチンが、バブル内の他のゴルーチンに対して
永続的にブロックされることでバブルがデッドロックすると、パニックを起こします。
--- FAIL: Test (0.00s)
--- FAIL: TestDeadlock (0.00s)
panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]
goroutine 7 [running]:
(stacks elided for clarity)
goroutine 10 [sleep (durable), synctest bubble 1]:
time.Sleep(0x1)
/Users/dneil/src/go/src/runtime/time.go:361 +0x130
_.TestDeadlock.func1.1()
/tmp/s/main_test.go:13 +0x20
created by _.TestDeadlock.func1 in goroutine 9
/tmp/s/main_test.go:11 +0x24
FAIL _ 0.173s
FAIL
ランタイムは、デッドロックしたバブル内のすべてのゴルーチンについて、スタックトレースを出力します。
バブル内のゴルーチンの状態を出力するとき、ランタイムはそのゴルーチンが永続的にブロックされているか どうかを示します。このテストでは、スリープしているゴルーチンが永続的にブロックされていることが わかります。
永続的なブロック
「永続的にブロックされる(durably blocking)」ことは、synctest における中心的な概念です。
あるゴルーチンが永続的にブロックされているのは、単にブロックされているだけでなく、同じバブル内の 別のゴルーチンによってしかブロックを解除されえない場合です。
バブル内のすべてのゴルーチンが永続的にブロックされているとき、次のことが起こります。
synctest.Waitが戻る。- 実行中の
synctest.Wait呼び出しがなければ、フェイクの時間はゴルーチンを目覚めさせる 次の時点まで、瞬時に進む。 - 時間を進めても目覚めさせられるゴルーチンがなければ、バブルはデッドロックし、テストは失敗する。
単にブロックされているだけのゴルーチンと、永続的にブロックされているゴルーチンとを区別することが 重要です。ゴルーチンが、そのバブルの外部で発生する何らかのイベントを一時的に待ってブロックされている だけなのに、デッドロックだと宣言してしまうのは避けたいのです。
ゴルーチンが永続的でない形でブロックされるいくつかの方法を見てみましょう。
I/O(ファイル、パイプ、ネットワーク接続など)
もっとも重要な制約は、ネットワークI/Oを含め、I/Oは永続的なブロックとはみなされないということです。 ネットワーク接続から読み込むゴルーチンはブロックされることがありますが、その接続にデータが到着 すればブロックは解除されます。
これは何らかのネットワークサービスへの接続について明らかに成り立ちますが、リーダーとライターが 両方とも同じバブルにあるループバック接続についても同様に成り立ちます。
ネットワークソケットに(ループバックソケットであっても)データを書き込むと、そのデータは配送のために カーネルに渡されます。書き込みのシステムコールが戻ってから、カーネルが接続の相手側にデータが利用 可能であると通知するまでには、一定の時間があります。Goのランタイムは、すでにカーネルのバッファに あるデータを待ってブロックしているゴルーチンと、決して到着しないデータを待ってブロックしている ゴルーチンとを、区別できません。
つまり、synctest を使ったネットワークプログラムのテストでは、通常は実際のネットワーク接続を
使えません。代わりに、インメモリのフェイクを使うべきです。
ここではフェイクのネットワークを作成する手順については触れませんが、synctest パッケージの
ドキュメントには、フェイクのネットワーク越しに通信するHTTPクライアントとサーバーをテストする、
完全な実例が載っています。
システムコール、cgo呼び出し、Goではないもの全般
システムコールとcgo呼び出しは、永続的なブロックとはみなされません。Goのコードを実行している ゴルーチンの状態についてしか、推論できないからです。
ミューテックス
意外に思われるかもしれませんが、ミューテックスは永続的なブロックとはみなされません。これは実用性 から生まれた判断です。ミューテックスはグローバルな状態を保護するためによく使われるため、バブル内の ゴルーチンが、バブルの外で保持されているミューテックスを獲得する必要がある場面がよくあります。 ミューテックスは性能に非常に敏感であるため、そこに追加の計装を加えると、テスト以外のプログラムを 遅くしてしまう恐れがあります。
synctest を使ってミューテックスを使うプログラムをテストすることはできますが、ゴルーチンが
ミューテックスの獲得でブロックされている間、フェイクの時計は進みません。私たちがこれまでに
遭遇した事例では問題になったことはありませんが、知っておくべき点です。
time.Sleep
では、何が永続的なブロックにあたるのでしょうか。
time.Sleep が永続的であることは明らかです。時間は、バブル内のすべてのゴルーチンが永続的に
ブロックされているときにしか進まないからです。
同じバブルで作成されたチャネルへの送受信
同じバブル内で作成されたチャネルに対するチャネル操作は、永続的です。
私たちは、バブル化されたチャネル(バブル内で作成されたもの)と、バブル化されていないチャネル (いかなるバブルの外でも作成されたもの)とを区別しています。これはつまり、たとえばグローバルに キャッシュされたリソースへのアクセスを制御するために、同期用のグローバルなチャネルを使う関数は、 バブルの内部から安全に呼び出せるということです。
バブル化されたチャネルに対して、そのバブルの外部から操作しようとするとエラーになります。
同じバブルに属する sync.WaitGroup
私たちは sync.WaitGroup もバブルに関連付けています。
WaitGroup にはコンストラクタがないため、Go または Add の最初の呼び出し時に、暗黙的に
バブルとの関連付けを行います。
チャネルと同様に、同じバブルに属する WaitGroup を待つことは永続的なブロックですが、バブルの
外部から待つ場合はそうではありません。別のバブルに属する WaitGroup に対して Go や Add を
呼び出すとエラーになります。
sync.Cond.Wait
sync.Cond を待つことは常に永続的なブロックです。別のバブルにある Cond を待っているゴルーチンを
起こそうとするとエラーになります。
select{}
最後に、空の select は永続的なブロックです。(caseを持つ select は、その中のすべての操作が
永続的なブロックであれば、永続的なブロックとなります。)
以上が、永続的なブロックとなる操作の完全な一覧です。それほど長くはありませんが、現実世界のほぼ すべてのプログラムを扱うには十分です。
ルールとしては、あるゴルーチンがブロックされていて、それを解除できるのは自分のバブル内の別の ゴルーチンだけであると保証できる場合に、そのゴルーチンは永続的にブロックされているとみなされます。
バブル内のゴルーチンを、そのバブルの外部から起こそうとする可能性がある場合には、パニックを 起こします。たとえば、バブル化されたチャネルに対してそのバブルの外部から操作を行うのはエラーです。
1.24から1.25への変更点
私たちはGo 1.24で synctest パッケージの実験版をリリースしました。初期の採用者がこのパッケージの
実験的なステータスを確実に認識できるように、パッケージを見えるようにするには GOEXPERIMENT
フラグを設定する必要がありました。
それらの初期採用者から得られたフィードバックは非常に貴重なものでした。このパッケージが有用で あることを実証してくれただけでなく、APIの改善が必要な部分を明らかにしてもくれました。
以下は、実験版とGo 1.25でリリースされたバージョンとの間で行われた変更の一部です。
Run を Test に置き換え
当初のバージョンのAPIでは、Run 関数でバブルを作成していました。
// Run は f を新しいバブル内で実行する。
func Run(f func())
バブルにスコープされた *testing.T を作成する手段が必要であることが明らかになりました。たとえば
t.Cleanup は、バブルが終了したあとではなく、クリーンアップ関数が登録されたのと同じバブル内で
実行されるべきです。私たちは Run を Test に改名し、新しいバブルの生存期間にスコープされた
T を作成するようにしました。
バブルのルートゴルーチンが戻ると時間は止まる
当初は、バブル内に未来のイベントを待つゴルーチンが存在する限り、バブル内の時間を進め続けていました。
これは、time.Ticker から永遠に読み込み続けるゴルーチンのように、長生きして決して戻らない
ゴルーチンがあると、非常にわかりにくいことが判明しました。現在は、バブルのルートゴルーチンが
戻った時点で、時間を進めるのをやめるようにしています。バブルが時間の進行を待ってブロックされている
場合、これはデッドロックとなり、解析可能なパニックを引き起こします。
「永続的」ではなかったケースを除去
私たちは「永続的にブロックされる」という定義を整理しました。当初の実装には、永続的にブロックされて いるはずのゴルーチンが、バブルの外部からブロック解除されうるケースがありました。たとえば、チャネルは バブル内で作成されたかどうかは記録していましたが、どのバブルで作成されたかまでは記録していなかった ため、あるバブルが別のバブルのチャネルをブロック解除してしまうことがありました。現在の実装には、 永続的にブロックされているゴルーチンがそのバブルの外部からブロック解除されうるケースは、私たちが 知る限り存在しません。
より良いスタックトレース
スタックトレースに出力される情報にも改良を加えました。バブルがデッドロックしたとき、デフォルトでは、 そのバブル内のゴルーチンのスタックだけを出力するようになりました。また、スタックトレースは、 バブル内のどのゴルーチンが永続的にブロックされているかも、明確に示すようになりました。
同時刻に発生するイベントのランダム化
同時刻に発生するイベントのランダム化についても改良を加えました。当初、同じ瞬間に発火するよう スケジュールされたタイマーは、常に作成された順序で発火していました。この順序は現在ランダム化 されています。
今後の課題
現時点では、私たちは synctest パッケージにかなり満足しています。
避けられないバグ修正は別として、今のところ今後大きな変更を加える予定はありません。もちろん、 より広く採用されるにつれて、何かやるべきことが見つかる可能性は常にあります。
考えられる作業の1つは、永続的にブロックされたゴルーチンの検出を改善することです。バブル内で 獲得したミューテックスは同じバブル内で解放しなければならないという制約のもとで、ミューテックス 操作を永続的なブロックとみなせるようにできれば理想的でしょう。
synctest を使ってネットワークのコードをテストするには、フェイクのネットワークが必要です。
net.Pipe 関数はフェイクの net.Conn を作成できますが、フェイクの net.Listener や
net.PacketConn を作成する標準ライブラリの関数は、現時点ではありません。さらに、net.Pipe
が返す net.Conn は同期的であり、書き込みはすべて、読み込みがそのデータを消費するまでブロック
します。これは実際のネットワークの振る舞いを代表するものではありません。おそらく、一般的な
ネットワークインターフェースの、良質なフェイク実装を標準ライブラリに追加すべきでしょう。
結論
以上が synctest パッケージです。
並行なコードのテストを簡単にすると言うことはできません。なぜなら並行処理が簡単になることは
決してないからです。このパッケージができるのは、イディオマティックなGoと標準の time パッケージを
使って、可能な限りシンプルな並行コードを書けるようにし、そのうえで速く信頼できるテストを書ける
ようにすることです。
役に立てば幸いです。
By Damien Neil