フューチャー技術ブログ

Go1.25 リリース連載 testing/synctest

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 を含むテストコードの実行時間を圧縮することが可能です
  • 利点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) {
synctest.Run(func() {
hoge := ...
if !hoge {
t.Fatalf("hoge not true")
}
})
}

Go 1.25 では、synctest.Run から synctest.Test(t, func(t *testing.T){ ... }) に移行しています。

func TestHoge(t *testing.T) {
synctest.Test(t, func (t *testing.T) {
hoge := ...
if !hoge {
t.Fatalf("hoge not true")
}
})
}

移行に伴い、以下のような変更が入っています。

  • バブル内専用の *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点を確かめていきます。

  1. durably blocked 時にフェイククロックが進むこと
  2. time.Sleep() が goroutine を durably block すること
  3. synctest.Wait() が、全 goroutine が durably blocked になる or 終了するまで待機すること

具体例1: time.Sleep() と durably blocked の関係

synctest のテストコード (TestTime) に、解説用のコメントを追加し、以下を確認します。

  1. durably blocked 時にフェイククロックが進むこと
  2. time.Sleep() が goroutine を durably block すること
func TestTime(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
start := time.Now()

// (1)別 goroutine を起動
go func() {
time.Sleep(1 * time.Second) // (3) sleep により、durably blocked に
t.Log(time.Since(start)) // always logs "1s"
}()

// (2) durably blocked になるのを待つ
synctest.Wait()

// (4) 上記の goroutine が sleep に入ったことで、バブルが durably blocked になったので、 Wait が解かれる
time.Sleep(2 * time.Second)
t.Log(time.Since(start)) // always logs "2s"
})
}

上記テストの 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
=== RUN TestTime
main_test.go:21: 1s
main_test.go:25: 2s
--- PASS: TestTime (0.00s)
PASS
ok github.com/hoge/fuga 0.277s

実行時間は 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) {
synctest.Test(t, func(t *testing.T) {
// (1): Done チャネルつきの ctx と、 ctx を cancel するための関数が返される
ctx, cancel := context.WithCancel(context.Background())

afterFuncCalled := false
// (4): context が cancel された直後、別 goroutine が起動し、 `funcCalled = true` が実行される
context.AfterFunc(ctx, func() {
afterFuncCalled = true
// (6): 関数の実行が終了すると、この関数が動いている goroutine が終了する
})

// (2) この時点では、他に起動している goroutine は存在しないため、 `durably blocked` とみなされ wait がすぐ解放される
synctest.Wait()
if afterFuncCalled {
t.Fatalf("before context is canceled: AfterFunc called")
}

// (3): context が cancel される
cancel()

// (5): `AfterFunc` により実行されている関数が完了されるのを待つ
synctest.Wait()
// (7): `AfterFunc` で起動した goroutine の終了によって、このバブルは durably blocked になったので、wait を解放
if !afterFuncCalled {
t.Fatalf("before context is canceled: AfterFunc not called")
}
})
}

(5) の synctest.Wait() は、 AfterFunc で指定した関数が実行されている goroutine が終了した (6) のを確認した後、 (7) で解放されます。これは、Wait()が解放される条件 のうち、「すべての goroutine が終了すること」のケースに該当します。

このように、 synctest.Wait() を用いることで、 全 goroutine が durably blocked になる or 終了するまで明示的に待つことができます。

cancelAfterFunc の goroutine 関連の挙動について補足

context.AfterFunc の第二引数に代入した関数は、まず以下のように「ctx が cancel されたら実行される関数」としてセットされます。

AfterFunc の抜粋
func AfterFunc(ctx Context, f func()) (stop func() bool) {
a := &afterFuncCtx{
f: f,
}
a.cancelCtx.propagateCancel(ctx, a)
...
}

次に、cancel が実行されたら、go a.f() のところで新たな goroutine が起動され、セットされた AfterFunc の関数が実行されます。

afterFuncCtx.cancel() の抜粋
func (a *afterFuncCtx) cancel(removeFromParent bool, err, cause error) {
a.cancelCtx.cancel(false, err, cause)
if removeFromParent {
removeChild(a.Context, a)
}
a.once.Do(func() {
go a.f() // ⭐️ AfterFunc で指定した関数が、別 goroutine 内で実行される
})
}

このように、AfterFunc の第二引数に設定された関数は、メインの goroutine とは別の goroutine で実行されます。

synctest.Wait() をコメントアウトした場合の挙動より分かること

逆に synctest.Wait() をコメントアウトしてテストを実行するとどうなるでしょうか。
テスト実行時、 -race をオプションに指定し、競合状態を検知してみます。

func TestContextAfterFunc(t *testing.T) {
synctest.Test(t, func(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())

...

cancel()

// synctest.Wait() ⚠️ コメントアウト
if !afterFuncCalled {
t.Fatalf("AfterFunc function not called after context is canceled")
}
})
}
% go1.25rc1 test -v . --count=1 -run TestContextAfterFunc -race
=== RUN TestContextAfterFunc
main_test.go:117: AfterFunc function not called after context is canceled
==================

Goroutine 8 (running) created at:
context.(*afterFuncCtx).cancel.func1()
/Users/hoge/sdk/go1.25rc1/src/context/context.go:358 +0x40
sync.(*Once).doSlow()
/Users/hoge/sdk/go1.25rc1/src/sync/once.go:78 +0x94
sync.(*Once).Do()

Goroutine 7 (running) created at:
testing/synctest.Test
()
~~
==================
testing.go:1614: race detected during execution of test
--- FAIL: TestContextAfterFunc (0.00s)
FAIL
FAIL github.com/hoge/fuga 0.294s
FAIL

race detected during execution of test と怒られてしまいました。 この場合、競合状態が発生してしまうことが分かります。
これは、 AfterFunc での funcCalled への書き込みが終了するのを待たずに if !funcCalled の読み込みが発生してしまっているためです。

synctest.Wait() が 全 goroutine の blocking or 終了を待機していたことにより、競合を回避できていたことが、このケースよりわかります。

補足: synctest を使わない場合はどうなるか?

synctest を使えない場合に、以下2点をチェックしたい場合があります。

  1. キャンセル前に AfterFunc のコールバックが実行されないこと
  2. キャンセル後に AfterFunc のコールバックが実行されること

この場合は、以下のように書かざるを得ません。

func TestAfterFuncWithoutSync(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())

calledCh := make(chan struct{}) // closed when AfterFunc is called
context.AfterFunc(ctx, func() {
close(calledCh)
})

funcCalled := func() bool {
select {
case <-calledCh:
return true
case <-time.After(10 * time.Millisecond):
return false
}
}

if funcCalled() {
t.Fatalf("AfterFunc function called before context is canceled")
}

cancel()

if !funcCalled() {
t.Fatalf("AfterFunc function not called after context is canceled")
}
}

この場合、「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 のアップデートについてです。