
Go 1.25 リリース連載 の5本目です。
はじめに
こんにちは、CSIG FutureVuls の市川です。
testing/synctest パッケージについて紹介します。Go の testing/synctest は、Go1.24 で experimental として追加され、 Go1.25 で正式なパッケージとなりました。
過去のリリース連載で棚井さんが詳しく解説した記事を書いてくださっています。synctest の基本的な説明についてはぜひご参照ください。
本記事は以下の流れで説明し、具体例を用いて durably blocked と synctest.Wait()
についての理解を深めます。
- synctest の概要とメリット
- experimental からの変更点
- durably blocked の定義
synctest.Wait()
の挙動
synctest の概要とメリット
synctest は、非同期なテストをサポートするためのパッケージで、 Test()
と Wait()
のみで構成されています。
synctest.Test()
は、「バブル」(隔離環境) 内でテスト関数を実行します。
バブル内では、以下の利点が得られます。
- 利点1
time.Sleep
を含むテストの実行時間を圧縮できる- バブル内では time パッケージはフェイククロックを用います。バブル内の全 goroutine が durably blocked になった段階でバブル内の時間が前進し、
time.Sleep
は実時間を待たずに完了します。この性質を利用することで、time.Sleep
を含むテストコードの実行時間を圧縮することが可能です
- バブル内では time パッケージはフェイククロックを用います。バブル内の全 goroutine が durably blocked になった段階でバブル内の時間が前進し、
- 利点2
synctest.Wait()
によって Flaky test を回避できる- バブル内で
synctest.Wait()
を呼ぶことにより、バブル内の全 goroutine が durably blocked になるのを明示的に待つことができます。これにより非同期処理をテストする際も Flaky Test (不安定なテスト) になるのを回避できます
- バブル内で
1.24 の experimental からの変更点
実験フラグが不要に
Go 1.24 の段階では GOEXPERIMENT=synctest
をつけないと synctest を使用できませんでしたが、 Go 1.25 では正式リリースに伴いこのフラグが不要になっています。
Run から Test へ移行
Go 1.24 では synctest.Run(func(){ ... })
で バブル(隔離環境)を開始する仕様でした。synctest に関するブログ記事: Testing concurrent code with testing/synctest などでも Run
が使われていました。
func TestHoge(t *testing.T) { |
Go 1.25 では、synctest.Run
から synctest.Test(t, func(t *testing.T){ ... })
に移行しています。
func TestHoge(t *testing.T) { |
移行に伴い、以下のような変更が入っています。
- バブル内専用の
*testing.T
が渡されるようになり、t.Cleanup
やログ等がうまく統合されたT.Cleanup
がバブル内で実行されるようになったt.Context()
がバブルに紐づいた Done を返すようになった
- バブル内での
T.Run
,T.Parallel
,T.Deadline
の呼び出しが禁止された (バブル内部の時間・並列制御と衝突するため)
移行の経緯について、詳しくは testing/synctest: replace Run with Test #73567 をご覧ください。
なお、互換のため Go1.25 でも GOEXPERIMENT=synctest
を設定した場合に限り、 Run
を残す方針が取られています。Go1.26 で削除予定と、ドキュメント内 で明言されています。
durably blocked と synctest.Wait() の深掘り
以下では、synctest のドキュメント に度々登場する durably blocked
と、 synctest.Wait()
の挙動について深掘りします。
durably blocked とは何か
durably blocked とは、「対象の goroutine がブロックされており、その goroutine を再開できるのが、同じ bubble 内の別の goroutine か フェイククロックの前進のみ」という状態を指しています。
外部(バブルの外側)からの I/O・シグナル・mutex アンロックなどで解除される可能性がある goroutine は durably blocked とはみなされません。
durably blocked とみなされる操作・みなされない操作の具体例は以下のとおりです。
- goroutine を durably block する操作一覧
- バブル内で作られたチャネルによるブロッキング
- バブル内で作られたチャネルのみから成る
select
によるブロッキング sync.Cond.Wait
sync.WaitGroup.Wait
(最初の Add がバブル内で呼ばれている場合)time.Sleep
- goroutine を durably block しない操作
- sync.Mutex または sync.RWMutex によるロック (∵ バブル外で解除される可能性があるため)
- I/O ブロッキング (例えば、実際に API を叩いた場合の待機時間など)
- システムコール
synctest.Wait() が解放される条件
synctest.Wait()
は、「現在の goroutine を除くバブル内の全ての goroutine が durably blocked」になったタイミングで解放されます。
注意したいのは、 メインの goroutine 以外のすべての goroutine が終了した場合も Wait()
が解放される 点です(下記の具体例2も、こちらの場合に当てはまっています)。zenn の記事:「【Go】非同期処理のテストコードの書き方とsynctest入門」 で詳しく解説されていたので、気になる方は合わせて参照ください。
続いて、以下の3点を確かめていきます。
- durably blocked 時にフェイククロックが進むこと
time.Sleep()
が goroutine を durably block することsynctest.Wait()
が、全 goroutine が durably blocked になる or 終了するまで待機すること
具体例1: time.Sleep() と durably blocked の関係
synctest のテストコード (TestTime) に、解説用のコメントを追加し、以下を確認します。
- durably blocked 時にフェイククロックが進むこと
time.Sleep()
が goroutine を durably block すること
func TestTime(t *testing.T) { |
上記テストの syncTest.wait()
は、全 goroutine が durable blocked 状態になるのを待ちます (2)。
goroutine を durably block する操作一覧 に書いた通り、time.Sleep
は goroutine を durably block する操作の1つです。そのため、 (3) の time.Sleep
が呼ばれると同時に、バブル内のすべて goroutine が durable blocked になります。
この時点で syncTest.Wait()
による待ち状態が解放され、 time.Sleep(2)
が実行されます。(4)
(4) に移った後は、 フェイククロックが進み、バブル内の時間が一気に前進します。
このテストを実行すると以下のような結果が得られます。
% go1.25rc1 test -v . --count=1 -run TestTime |
実行時間は 0.277s でした。バブル内では、実時間ではなくフェイククロックが使用されるため、 time.Sleep()
が圧縮されてテストがすぐ終了していることがわかります。
具体例2: synctest.Wait() が、全 goroutine が durably blocked になる or 終了するのを待機する
synctest のドキュメント で解説されているテストコード: TestContextAfterFunc
を用いて、「3: synctest.Wait()
」 が、「全 goroutine が durably blocked になる or 終了するまで待機する」ことを確認します。
synctest.Wait() の挙動を確認する
オリジナルの TestContextAfterFunc
に対して、コメントを追加してみます。
func TestContextAfterFunc(t *testing.T) { |
(5) の synctest.Wait()
は、 AfterFunc
で指定した関数が実行されている goroutine が終了した (6) のを確認した後、 (7) で解放されます。これは、Wait()が解放される条件 のうち、「すべての goroutine が終了すること」のケースに該当します。
このように、 synctest.Wait()
を用いることで、 全 goroutine が durably blocked になる or 終了するまで明示的に待つことができます。
cancel
と AfterFunc
の goroutine 関連の挙動について補足
context.AfterFunc
の第二引数に代入した関数は、まず以下のように「ctx が cancel されたら実行される関数」としてセットされます。
func AfterFunc(ctx Context, f func()) (stop func() bool) { |
次に、cancel
が実行されたら、go a.f()
のところで新たな goroutine が起動され、セットされた AfterFunc
の関数が実行されます。
func (a *afterFuncCtx) cancel(removeFromParent bool, err, cause error) { |
このように、AfterFunc
の第二引数に設定された関数は、メインの goroutine とは別の goroutine で実行されます。
synctest.Wait() をコメントアウトした場合の挙動より分かること
逆に synctest.Wait()
をコメントアウトしてテストを実行するとどうなるでしょうか。
テスト実行時、 -race
をオプションに指定し、競合状態を検知してみます。
func TestContextAfterFunc(t *testing.T) { |
% go1.25rc1 test -v . --count=1 -run TestContextAfterFunc -race |
race detected during execution of test
と怒られてしまいました。 この場合、競合状態が発生してしまうことが分かります。
これは、 AfterFunc
での funcCalled
への書き込みが終了するのを待たずに if !funcCalled
の読み込みが発生してしまっているためです。
synctest.Wait()
が 全 goroutine の blocking or 終了を待機していたことにより、競合を回避できていたことが、このケースよりわかります。
補足: synctest を使わない場合はどうなるか?
synctest
を使えない場合に、以下2点をチェックしたい場合があります。
- キャンセル前に
AfterFunc
のコールバックが実行されないこと - キャンセル後に
AfterFunc
のコールバックが実行されること
この場合は、以下のように書かざるを得ません。
func TestAfterFuncWithoutSync(t *testing.T) { |
この場合、「10ms 待つ」という無駄な時間が発生します。
また、このテストは 「10ms 以内に AfterFunc のコールバック goroutine が起動して calledCh を close する」ことを前提にしてしまっており、 実行環境の CPU やメモリの性能や Go スケジューラのタイミングに強く依存します (= flaky)。
synctest.Test
+ synctest.Wait
を使うことで、 「実行時間が伸びること」と「flaky であること」という 2 つの課題を同時に解決することができる、ということが分かります。
まとめ
experimental が外れ正式なパッケージとなった「testing/synctest」を取り上げました。
その中でも、個人的に気になっていた durably blocked
の定義と synctest.Wait()
の挙動を深掘って解説しました。
time.Sleep()
を含むテストの実行時間を短縮できるのも嬉しいですが、個人的には synctest.Wait()
でチャネルや他 goroutine との同期を取りやすくなったのが嬉しいなと思います。非同期関連のテストのリファクタが捗りそうです。
次回はjson/v2
のアップデートについてです。