🌏

testing/synctestはGoランタイムの世界を改変していた

に公開

はじめに

先日、Go Conference 2025に行ってきました。

様々なセッションを聞く中で、特に興味を持ったトピックがtesting/synctestパッケージです。
これを取り上げているセッションが2つもありました。

https://speakerdeck.com/daikieng/synctest
https://docs.google.com/presentation/d/1nCysE_J4WpRUwvDQSAzZbtFFCgCWAZL1tayBVfh79iQ/edit?slide=id.p1#slide=id.p1

名前は聞いていたが、詳しくはよく知らなかったパッケージでした。

話を聞けば聞くほど、「なんかすごいけど、なんでこれ(synctest)ですごくなるん?」で頭がいっぱいになってしまいました。

セッションでも話されていたため、なんとなくはわかった(気分になっていた)が、もっとこの技術に納得感を持ちたかったために、個人的に腰を据えてコードリーディングをしてみることにしました。

その結果筆者が感じたのは、

testing/synctestパッケージが新しく生えただけですごくなったのではない、そもそもGoランタイムがこのパッケージに合わせて書き換えられているからすごくなっていたんだ...

ということです。

Goランタイムをなんとなく知っているとより面白いですが、ここではその説明は割愛します。

筆者も「なんとなく」なのでうまく説明できるほどではないので、
Goでの並行処理を徹底解剖!
などがわかりやすくておすすめです。

非同期テストのつらみ

非同期テスト処理のテストが難しいぞという話がありました。

  • 関数が処理中にgoroutineを呼び出しており、結果を確認すべきタイミングが不安定

    • goroutineの実行タイミングはスケジューラのお気分です(?)
  • 「経過時間」に依存する処理をtime.Sleepで待ってもflakyになる

    • sleep処理になると、実行しているgoroutineはスケジューラによりブロッキングとみなされ、マシンスレッドから外されます。timeがすぎてブロッキングが解除されたとしても、実行可能なキューに入るだけで、それをgoroutineがいつ戻してくれるかはこれまたスケジューラの気分です。
    • この辺の話は一度記事にしたことがある(ちょっと違うけど)ので興味あればぜひこちらから。

gorutineの完了を確実に待つためにsleep時間を長く置いてみたりするとシンプルに実行時間が長くなっていくだけですし、そもそもそれで確実にflakyではなくなるかというと、述べたようなランタイムの仕組みを考えればNOです。

そこで、synctestという解決策の1つがあるようです。

このパッケージが提供するAPIは二つのみで、

  • synctest.Test()
    • 実行を「バブル」に隔離することで、テスト実行時間の短縮を実現する
  • synctest.Wait()
    • 「バブル」内のgoroutineがすべてブロックするまで待機する

です。

synctest.Test()を見てみる

まずはこちらのAPIから見ていきます。

func Test(t *testing.T, f func(*testing.T)) {...}

早速ですが、コードから処理を追いつつ、挙動を理解していきます。

バブル(goroutine)内でテストを実行する

まず押さえておくべきそうな独自概念が、

Test executes f in a new bubble.

ということです。

以下の文章から、この「バブル」については「テスト実行時の仮想的な隔離環境」と捉えておくと良さそうです。

The Test function runs a function in an isolated "bubble".

つまり第二引数にとるfは隔離環境で実行されるわけですね。

そしてバブル内でテストの実行(f)がgoroutine内で行われるようになっているようです。また、そのf内で起動されるgoroutineもまた同バブルに属します。
synctest.Test()を実行した時、テスト関数を実行するgoroutine(root goroutineと呼称します)の中にバブルが紐付けられ、そのバブルの中でgoroutineが起動してf関数が実行されるという流れをみてみましょう。

synctest.Test()の内部コード(抜粋)
src/runtime/synctest.go
func synctestRun(f func()) {
    // 現在のgoroutineを取得
    gp := getg()
    if gp.bubble != nil {
        panic("synctest.Run called from within a synctest bubble")
    }
    // 新しいバブル空間を作成し、現在のgoroutine(gp)をそのrootにする
    bubble := &synctestBubble{
        id:      bubbleGen.Add(1),
        total:   1,
        running: 1,
        root:    gp,
    }
    // 現在のgoroutineとバブルを紐付ける
    gp.bubble = bubble
    defer func() { gp.bubble = nil }()

    // テスト関数fを新しいgoroutine(bubble.main = 以降、main goroutine)で実行している部分
    systemstack(func() {
        fv := *(**funcval)(unsafe.Pointer(&f))
        // newproc1は実行可能ステータスのgoroutineを作成し、その中でfvは実行される。また、そのgoroutineも同バブルに属する
        bubble.main = newproc1(fv, gp, sys.GetCallerPC(), false, waitReasonZero)
        pp := getg().m.p.ptr()
        runqput(pp, bubble.main, true)
        wakep()
    })

    // ...(仮想時間管理やタイマー処理は省略)...
}

bubble内で新たなgoroutine(bubble.main)を起動しています。これをここでは main goroutineと呼ぶことにします。

そのmain goroutineの中でfを実行しているのは以下の関数です。

src/runtime/proc.go
func newproc1(fn *funcval, callergp *g, callerpc uintptr, parked bool, waitreason waitReason) *g {
    // 省略

    // 実行可能ステータスの新goroutine(newg)を取得/作成
    newg := gfget(pp)
    if newg == nil {
        newg = malg(stackMin)
        casgstatus(newg, _Gidle, _Gdead)
        allgadd(newg)
    }
    // ...(スタック設定など省略)...

    // 親goroutineのバブルを継承
    // callergpはbubbleのrootであるgoroutine
    newg.bubble = callergp.bubble

    // 実行する関数(fn)をセット
    gostartcallfn(&newg.sched, fn)

    // 実行可能状態にする
    casgstatus(newg, _Gdead, _Grunnable)

    // 返されたgoroutineはスケジューラにより実行される
    // 実行 = f関数の実行
    return newg
}

ちなみに、goroutineの構造体にあるbubbleフィールドは、testing/synctestのものです。

テストに使われるパッケージがgoroutine自体に埋め込まれている感じなんですね。

src/runtime/runtime2.go
type g struct {
    // 省略
    bubble  *synctestBubble
}

そして、このbubbleフィールドのsynctestBubble構造体は以下のようになっています。

synctestBubble構造体
src/runtime/synctest.go
(一部省略)
// A synctestBubble is a set of goroutines started by synctest.Run.
type synctestBubble struct {
	timers  timers
	now     int64  // current fake time
	root    *g     // caller of synctest.Run. <- root goroutine (TestXxxx()) 
	waiter  *g     // caller of synctest.Wait 
	main    *g     // goroutine started by synctest.Run <- main goroutine
	waiting bool   // true if a goroutine is calling synctest.Wait
	total   int // total goroutines
	running int // non-blocked goroutines
	active  int // other sources of activity
}

そして、このバブル内にテスト実行を隔離させて何が嬉しくなったかというと、バブル内の時刻をなんかいい感じに一瞬で時間を進めてくれるということでしたね。

それによってテスト実行時が短縮される...。(謎)

確かめるべく、まずは動かしてみましょう。

テストコードを簡単に動かして実感する

公式の例を持ってきました。

func TestTime(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        start := time.Now()
        go func() {
            time.Sleep(1 * time.Second)
            t.Log(time.Since(start)) // always logs "1s"
        }()
        time.Sleep(2 * time.Second) // the goroutine above will run before this Sleep returns
        t.Log(time.Since(start))    // always logs "2s"
    })
}

まあ、シンプルに考えたら2秒以上はかかるはずです。実行してみましょう。

ok  	playground	0.360s

嘘のような本当でした。(はや過ぎ)
 
試しに、synctest.Test()を使わずに実行してみましょう。

synctestを剥がしたテストコード
func TestTime(t *testing.T) {
	start := time.Now()
	go func() {
		time.Sleep(1 * time.Second)
		t.Log(time.Since(start))
	}()
	time.Sleep(2 * time.Second) // the goroutine above will run before this Sleep returns
	t.Log(time.Since(start))
}

結果は以下です。逆に安心する結果となりました。

ok  	playground	2.243s

.....しかし、synctest.Test()に実行関数を入れ込むだけで、なんでこんなうまいことやってくれるんだろうとかなり不思議に思えますね。

気になるのでさらにここから深ぼってみましょう。

なぜtime.Sleepの時間が一瞬で過ぎるのか

documentには以下のように書いています。

Within a bubble, the time package uses a fake clock. Each bubble has its own clock. The initial time is midnight UTC 2000-01-01.

Time in a bubble only advances when every goroutine in the bubble is durably blocked. See below for the exact definition of "durably blocked".

なるほど、bubble内のすべてのgoroutineがdurably blockedな状態である場合のみ、仮想クロックとして時間が進むようですね。

これが一体どういうことなのかをコードベースで見てみます。

time.Sleep()を見る

まずはtime.Sleep()本人の中を見てみます。

src/runtime/time.go
go/src/runtime/time.go
func timeSleep(ns int64) {
    if ns <= 0 {
        return // 0以下なら即復帰
    }

    gp := getg() // 現在のgoroutineを取得

    // goroutineにタイマーがなければ新規作成
    t := gp.timer
    if t == nil {
        t = new(timer)
        t.init(goroutineReady, gp)
        // バブル内なら仮想タイマーとして扱うためのフラグを立てる
        if gp.bubble != nil {
            t.isFake = true
        }
        gp.timer = t
    }

    // 現在時刻を取得(バブル内なら仮想時刻、外なら実時刻)
    var now int64
    if bubble := gp.bubble; bubble != nil {
        now = bubble.now
    } else {
        now = nanotime()
    }

    // いつまでsleepするかの時刻を計算してgoroutineにセット
    when := now + ns
    gp.sleepWhen = when

    if t.isFake {
        // sleepWhenにタイマーが発火するように設定(つまりsleepWhenまでの間sleepする)
        resetForSleep(gp, nil)
        // 現在のgoroutineをコンテキストスイッチ(waitingにparkする)
        gopark(nil, nil, waitReasonSleep, traceBlockSleep, 1)
    } else {
        // 通常のタイマー処理
        gopark(resetForSleep, nil, waitReasonSleep, traceBlockSleep, 1)
    }
}

コードから見るに、time.Sleepが呼ばれているgoroutineがbubble内かどうかをチェックしているようです。
そしてbubble内であれば、bubbleにおける仮想クロック(`bubble.now)でタイマーを発火するようにしていそうです。

if bubble := gp.bubble; bubble != nil {
    // bubble内の仮想クロックの現在時刻にする
    now = bubble.now
} else {
    now = nanotime()
}

// いつまでsleepするかの時刻を計算してgoroutineにセット
when := now + ns
gp.sleepWhen = when

一旦ここまでをまとめると、

  • synctest.Test()によって実行をbubble(厳密にはmain goroutine)に隔離する
  • time.Sleepを呼んでいたgoroutineがbubbleに属していた場合、仮想クロックでタイマーの発火(動き出し)を設定してからgopark(一旦待機として実行から外す)する

synctest.Test()の続きを見る

ここでさらに、もう一度synctest.Test()の内部(syntestRun())の続きを見てみましょう。

いくつか前のセクションで、bubble内でmain goroutineを起動➡️その中でf関数を実行する、ところまでコードを見ていましたので、その後の処理を以下から追っていきます。

synctest.Test()のbubble生成後の流れ(抜粋)
src/runtime/synctest.go
func synctestRun(f func()) {
    // 省略 - バブル作成、初期化処理
    // bubble内でmain goroutineを起動して、f関数を実行するところまで完了
    
    // メインループ: 仮想時間管理
    for {        
        // 1. 現在の仮想時刻でタイマーをチェックし、発火できる場合はbubble内goroutineをrunnableにする(→その後スケジューラがrunningにして実行再開)
        systemstack(func() {
            gp.bubble.timers.check(bubble.now, bubble)
        })
        
        // 2. バブル内の全goroutineがdurably blockedされるまで待機
        // synctestidle_c関数で、
        //   - bubble内goroutineが全てdurably blockedしている場合 ➡️ root goroutine(= `TestXxxx()`といったテスト関数を実行しているgoroutine)が一旦待機後すぐ復帰する
        //   - bubble内にrunningなgoroutineがある場合 ➡️ root goroutineがブロッキング(待機)
        gopark(synctestidle_c, nil, waitReasonSynctestRun, traceBlockSynctest, 0)

        // root goroutineが復帰したら以降の処理へ進む
        // この時、bubble内goroutineは全てdurably blockedしている
        
        // 3. 次にタイマーが発火すべき時刻を取得
        next := bubble.timers.wakeTime()

        // 4. 仮想時刻を次のタイマー発火時刻まで一気に進める
        bubble.now = next
    }

    // 省略(終了処理)
}

goparkの1つ目の引数に入っている関数synctestidle_cは、バブル内の全goroutineがdurably blocked(後述)しているかどうかを判定しています。

synctestidle_c()のコード
src/runtime/synctest.go
func synctestidle_c(gp *g, _ unsafe.Pointer) bool {
	canIdle := true
        // ative == 1はroot goroutine自体のカウント分
	if gp.bubble.running == 0 && gp.bubble.active == 1 {
		// bubble内の全goroutineがブロックまたは終了している
		canIdle = false
	} else {
		gp.bubble.active--
	}
	
	return canIdle
}

このgoparkの第1引数に入れられるsynctestidle_c()が返す値が偽であれば gopark()の処理内でroot goroutineは待機後すぐ復帰する / 真であればブロッキングとなります。

ここまでで、bubble内のgoroutineがまだ動いている場合にはroot goroutineはブロッキングし続けることになることがわかりました。
いつまでもブロッキングしていてはテストが完了しないので、いつか復帰(runnable)するはずです。

そのタイミングがいつかというと、bubble内goroutineがdurably blokedになった時にroot goroutineが復帰します。

復帰するトリガーとなっているのは、例えばtime.Sleep()です。

time.Sleep()は間接的に以下のchangegstatusを呼び出しています。

bubble.changegstatus()
src/runtime/synctest.go
// changegstatus is called when the non-lock status of a g changes.
// It is never called with a Gscanstatus.
func (bubble *synctestBubble) changegstatus(gp *g, oldval, newval uint32) {
    // 省略
	wake := bubble.maybeWakeLocked()
	unlock(&bubble.mu)
	if wake != nil {
		goready(wake, 0)
	}
}

wakeはgoroutineになります。それをgoready()でrunnableにしています。
どのgoroutineが返ってきているのかというと、bubble.maybeWakeLocked()を見るとわかります。

bubble.maybeWakeLocked()
src/runtime/synctest.go
func (bubble *synctestBubble) maybeWakeLocked() *g {
    if bubble.running > 0 || bubble.active > 0 {
        return nil  // まだ動いているgoroutineがある → wake(復帰)しない
    }
    // この時点でbubble.running == 0 && bubble.active == 0
    // → 全goroutineがdurably blocked!
        
    // 省略

    return bubble.root  // root goroutineを返す
}

bubble内の全goroutineがすべてdurably blockedになった時に、復帰させるgoroutineとして root goroutineを返していました。

これでroot goroutineは復帰し、止まっていたfor loopの続きから始まると思われます。
つまり、TestXxxx()のようなテスト関数を担当していたgoroutineが動き出すということです。

そして、順番にコードを追っていけば、root goroutineが復帰した後、以下のように仮想時刻を進めています。

// 3. 次にタイマーが発火すべき時刻を取得
next := bubble.timers.wakeTime()

// 4. 仮想時刻を次のタイマー発火時刻まで一気に進める
bubble.now = next

nextに入る値は、time.Sleep()内でセットしていたタイマー発火の時刻(sleepWhen)と同値です。(起きるべき時刻だからwakeTime()なんですかね)

そして、バブル内の仮想時間が一気に進んだところで、また for loopの冒頭に戻り、 gp.bubble.timers.check(bubble.now, bubble)に差し掛かります。

その時点で、タイマーが発火していい時刻になっているはずです。
t.check()内でタイマーが発火し(t.unlockAndRun())➡️goroutineReady()が呼ばれる➡️time.Sleep()呼び出してdurably blockedしていたbubble内のgoroutineがrunnableになります(気になる人はここも追ってみてください)。

runnableになったgoroutineはスケジューラによってそのうちrunningに戻されます。

こういった流れでtime.Sleep()で指定時間の間はdurably blockedするはずのgoroutineが一瞬で動き出していたんですね。

syntest.Test()/time.Sleep()/Goランイムの仕組みを合わせて見る必要があったので、少し複雑に思えるかもしれません。

以下に流れを概念的に図解してみました。

time.Sleep側もそういう対応がされていたのと、さらにgoroutineの構造も変わっていたのは個人的に面白かったポイントでした。つまりGoのランタイムごと変わってたんですね。

synctestパッケージがぴょこっと生えただけではなかったようです。

気になって前のバージョンのコードを見ると、前々からtime.Sleepがbubble対応されていたわけではなさそうでした。

  • 1.23では、gp.bubble != nilの分岐を綺麗に取り除いた感じでした。
    👉 1.23 src/runtime/time.go

  • goroutineの構造体も一応見ておくと、案の定1.23時点ではbubbleフィールドがなかったことがわかりました。
    👉 1.23 src/runtime/runtime2.go

一旦ここで、よく出てくる概念でありあまり説明できていなかった、durably blockedとは何かを理解しましょう。

durably blockedを理解する

A goroutine in a bubble is "durably blocked" when it is blocked and can only be unblocked by another goroutine in the same bubble. A goroutine which can be unblocked by an event from outside its bubble is not durably blocked.

つまり、バブル内の現在のgoroutineがブロックされていて、それが「同バブル内の他のgoroutineによってのみブロック解除される」場合に、durably blockedの状態となるそう。

durably blockという概念はおそらくこのsynctest独自のものと思います。単なるブロッキングではないという定義を持たせたいのだと思っています。

また、durably blockedという判定になるのはどんな時かというのが以下のように挙げられています。

  • a blocking send or receive on a channel created within the bubble
  • a blocking select statement where every case is a channel created within
  • the bubble
  • sync.Cond.Wait
  • sync.WaitGroup.Wait, when sync.WaitGroup.Add was called within the bubble
  • time.Sleep

そして、以下のようなブロッキングは、durably blockではないと明示されています。

  • locking a sync.Mutex or sync.RWMutex
  • blocking on I/O, such as reading from a network socket
  • system calls

これらの場合、「ゴルーチンはバブルの外で発生するイベントによってブロック解除できる」ため、durably blockedには分類されないみたいです。

ここまでの話を踏まえると、

バブル内に閉じたブロッキング(durably blocked)はsynctestを活用できるが、バブル外の要因で起きるブロッキングに対しては有効活用が難しい

というふうにいえそうです。

sycrtest.Test()で高速化できる/できない場合のイメージは以下です。(あくまで筆者の)

ソースコードからdurably blockなケースを拾ってみる

手っ取り早くdurably blockedっていつなるん?をコードから確認してみましょう。

そこで、筆者はスタックトレースでgoroutineの状態を表示する箇所を見つけました。
少し正攻法ではない気もしますがこちらを見ることにしましょう(カンタンに見れるので)。

見てみると、goroutineがbubble内であり、gp.waitreason.isIdleInSynctest()が真であった場合に(durable)と標準出力するようですね。

src/runtime/traceback.go
func goroutineheader(gp *g){
    //省略
    if bubble := gp.bubble; bubble != nil &&
        gp.waitreason.isIdleInSynctest() &&
    !stringslite.HasSuffix(status, "(durable)") {
        // If this isn't a status where the name includes a (durable)
        // suffix to distinguish it from the non-durable form, add it here.
        print(" (durable)")
	}
}

( durable)の表すところですが、上の関数の分岐は、go 1.24には見られない & bubbleを見ている時点で、synctestの指すdurably blockedを表していることは間違い無いでしょう。

https://github.com/golang/go/blob/release-branch.go1.24/src/runtime/traceback.go#L1196

そして、重要なのはisIdleSynctest()です。waitReasonが以下のmapに含まれていれば真になりますね。

src/runtime/runtime2.go
func (w waitReason) isIdleInSynctest() bool {
	return isIdleInSynctest[w]
}

// isIdleInSynctest indicates that a goroutine is considered idle by synctest.Wait.
var isIdleInSynctest = [len(waitReasonStrings)]bool{
	waitReasonChanReceiveNilChan:    true,
	waitReasonChanSendNilChan:       true,
	waitReasonSelectNoCases:         true,
	waitReasonSleep:                 true,
	waitReasonSyncCondWait:          true,
	waitReasonSynctestWaitGroupWait: true,
	waitReasonCoroutine:             true,
	waitReasonSynctestRun:           true,
	waitReasonSynctestWait:          true,
	waitReasonSynctestChanReceive:   true,
	waitReasonSynctestChanSend:      true,
	waitReasonSynctestSelect:        true,
}

mapのキーを見てみると、先に見たdocumentの引用にあったdurably lockedになるケースと大方マッチしてそうですね。

documentの内容とコードが一致しているのが分かれば、いつdurably blockedになるかについては十分納得できるかと思います。

synctest.Wait()を見てみる

durably blockedのセクションが一個挟まりましたが、2つ目のAPIである Waitについてみていきましょう。

func Wait()

Wait blocks until every goroutine within the current bubble, other than the current goroutine, is durably blocked.

bubble内でsynctest.Waitを呼び出しているgoroutine以外の全てのgoroutineがdurably blockedになるまで待機するとありました。

一旦簡単なテストコードを動かしてみる

個人的に捉えづらかったので、まず実例としてどんなテストコードになるのかみてみましょう。

documentにあった例をさらに簡素化しました。
先に見たsynctest.Test()関数内で、5秒後にキャンセルされるコンテキストが5秒後にキャンセルされていることを確かめるというテストです。

func TestContextWithTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// 5秒後にキャンセルされるContextを作成
		const timeout = 5 * time.Second
		ctx, cancel := context.WithTimeout(t.Context(), timeout)
		defer cancel()

		// 5s待つ
		time.Sleep(timeout)

		// ここではタイムアウトしているはず....???
		if err := ctx.Err(); err != context.DeadlineExceeded {
			t.Fatalf("after timeout: ctx.Err() = %v, want DeadlineExceeded\n", err)
		}
	})
}

ありゃ。こちら落ちました。

--- FAIL: TestContextWithTimeout (0.00s)
    synctest_wait_example_test.go:25: after timeout: ctx.Err() = <nil>, want DeadlineExceeded
FAIL
FAIL    playground      0.193s
FAIL

なんで落ちたんでしょう。

context.WithTimeoutの内部では、cancel処理が行われるgoroutineが別に起動します。

goroutineの実行タイミングはスケジューラ次第なので、5s経ったとしても、contextをキャンセルするgoroutineがいつ実行されるかのタイミングが不安定になります。

つまり、今回これが落ちたのは、5sのsleepをmain goroutineが終えたところで、以下に差し掛かりましたが、

if err := ctx.Err(); err != context.DeadlineExceeded {
    t.Fatalf("after timeout: ctx.Err() = %v, want DeadlineExceeded\n", err)
}

チェックが走った段階では、contextをキャンセルするgoroutineがまだ実行されていなかったということでしょう。

起きていた状況を図解すると以下のような感じになるかと思います。

時にgoroutineの実行が期待通りのタイミングになることもあります。flakyなテストの典型です。

では、この場合どうやってテストを書いたらよかったのかというと、ここでsynctest.Wait()の出番です。

func TestContextWithTimeout(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		// 5秒後にキャンセルされるContextを作成
		const timeout = 5 * time.Second
		ctx, cancel := context.WithTimeout(t.Context(), timeout)
		defer cancel()

		// 5s待つ
		time.Sleep(timeout)

		// bubble内のgoroutineが全てdurably blockedになるまで待機
		synctest.Wait()

		// ここではタイムアウトしているはず
		if err := ctx.Err(); err != context.DeadlineExceeded {
			t.Fatalf("after timeout: ctx.Err() = %v, want DeadlineExceeded\n", err)
		}
	})
}

これでテストは成功しました。
1000回の実行でも落ちることはありませんでした。

go test -run TestContextWithTimeout synctest_wait_example_test.go -count=1000
ok      command-line-arguments  0.220s

しかし、実はこのテストはsynctest.Waitじゃなくてもいい感じに書けたりします。

<-ctx.Done()で待機する書き方
func TestContextWithTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        // 5秒後にキャンセルされるContextを作成
        const timeout = 5 * time.Second
        ctx, cancel := context.WithTimeout(t.Context(), timeout)
        defer cancel()

        // Done()チャネルでタイムアウトを確実に待機
        <-ctx.Done()

        // この時点でcancel処理が確実に完了している
        if err := ctx.Err(); err != context.DeadlineExceeded {
            t.Fatalf("after timeout: ctx.Err() = %v, want DeadlineExceeded\n", err)
        }
    })
}

これを踏まえても、非同期テストではsynctest.Wait()を使った方がいいと推せます。

これは、「bubble内全ての他のgoroutineがdurably blockedするまで待ってくれる」という性質があるからです。

コードを追いながらこれについて理解していきましょう。

bubbleにおけるWait()の呼び出し元goroutineをdurably blockedにする

synctestWait()のコード(抜粋)
func synctestWait() {
    gp := getg()
    
    // 1. bubble内からの呼び出しかチェック
    if gp.bubble == nil {
        panic("goroutine is not in a bubble")
    }
        
    // 2. 同時呼び出しを防ぐ(同じbubble内で複数のgoroutineがWait()を呼ぶのを禁止)
    if gp.bubble.waiting {
        unlock(&gp.bubble.mu)
        panic("wait already in progress")
    }
    // waitingフラグを立てる
    gp.bubble.waiting = true
        
    // 3. 現在のgoroutineを待機状態にする
    // synctestwait_c が呼ばれ、bubble内の他の全goroutineがdurably blockedになるまで待機
    gopark(synctestwait_c, nil, waitReasonSynctestWait, traceBlockSynctest, 0)
    
    // 4. 復帰後、waiting状態を取り消し
    gp.bubble.waiter = nil
    gp.bubble.waiting = false
    
    // 省略
}

gopark()の第一引数に入れている関数は以下です。

func synctestwait_c(gp *g, _ unsafe.Pointer) bool {    
    // 自分自身をwaiterとして登録
    gp.bubble.waiter = gp
    
    return true // 待機を継続
}

色々ありますが、本質的な処理部分で把握しておきたいのは以下の2点のみかと思います。

  • gopark(synctestwait_c, nil, waitReasonSynctestWait, traceBlockSynctest, 0)で必ずdurably blocked状態(待機)になる。

goparkの挙動はここまでで確認済みです。今回だとsynctestwait_cgopark内における第一引数の関数unlockfであり、

  • unlockfがfalseを返すとgoroutineが復帰(parkをキャンセル)
  • unlockfがtrueを返すとpark(待機)が継続

なのでした。

synctestwait_cは常にtrueを返すっぽいので、synctest.Wait()が呼ばれると呼び出し元のgoroutineが確実にdurably blockedになるようですね。

また、Wait()を呼び出したgoroutineを以降ではwaiter goroutineと呼称します。

waiter goroutineは、bubble内の他goroutineがdurably blockedになった時に復帰する

waiter goroutineが復帰するタイミングがいつなのかが気になるところです。

それは例えば、time.Sleep()になります。
間接的に呼び出されているbubble.changegstatus、そしてその中で呼ばれているbubble.maybeWakeLocked()を見ましょう。

bubble.changegstatus() , bubble.maybeWakeLocked()

実は、以下の二つはすでにsynctest.Test()のセクションで確認済みです。
ただ、一部省略していた箇所が今回重要な部分になっています。

src/runtime/synctest.go
func (bubble *synctestBubble) changegstatus(gp *g, oldval, newval uint32) {
    // 省略
    wake := bubble.maybeWakeLocked()
    if wake != nil {
        goready(wake, 0)  // ← wakeとして格納されているgoroutineを起床
    }
}
src/runtime/synctest.go
func (bubble *synctestBubble) maybeWakeLocked() *g {
    if bubble.running > 0 || bubble.active > 0 {
        return nil  // まだ動いているgoroutineがある → wake(復帰)しない
    }
    // この時点でbubble.running == 0 && bubble.active == 0
    // → 全goroutineがdurably blocked!

    if gp := bubble.waiter; gp != nil {
        return gp  // ← Wait()呼び出し元を返す
    }

    return bubble.root  // Wait()が呼ばれていなければrootを返す
}

bubble.maybeWakeLocked()では、bubble内のすべてのgoroutineがdurably blockedしている、かつ、bubbleにwaiter goroutineが紐づけられている場合は waiter goroutineが返されます。

つまり、bubble内のどれかのgoroutineがwaiter goroutineだった場合、この時点で復帰されるのはwaiter goroutineになるわけですね。

復帰した後は、synctest.Wait()内のgopark以降から再開します。

// 4. 復帰後、waiting状態を取り消し
gp.bubble.waiter = nil
gp.bubble.waiting = false

そして、(元)waiter goroutineはsynctest.Wait()の呼び出し以降から再開するわけですね。

先にお見せしていた例の図を修正する形で、ここまでの流れを図解してみました。

contextをcancelするgoroutineが、実際にそのキャンセル処理を終えた時、bubble内には、waiter goroutineのみが存在している状況になります。つまり、bubble内のすべてのgoroutineがdurably blockedな状態になったと言えますね。

そして、取り上げた例だとキャンセル処理の後に確実にmain goroutineが復帰して、「contextがキャンセルされているかのチェック」を行うという流れになります。

この挙動の何が嬉しいのか

このsynctest.Wait()を呼び出す最大のメリットは、bubble内のすべてのgoroutineを監視し、waiter goroutineの待機を制御できる点だと筆者は考えます。

synctest.Wait()を用いずとも、goroutineの実行制御として以下のような対応も可能ということは先述しました。

<-ctx.Done()で待機する書き方
func TestContextWithTimeout(t *testing.T) {
    synctest.Test(t, func(t *testing.T) {
        // 5秒後にキャンセルされるContextを作成
        const timeout = 5 * time.Second
        ctx, cancel := context.WithTimeout(t.Context(), timeout)
        defer cancel()

        // Done()チャネルでタイムアウトを確実に待機
        <-ctx.Done()

        // この時点でcancel処理が確実に完了している
        if err := ctx.Err(); err != context.DeadlineExceeded {
            t.Fatalf("after timeout: ctx.Err() = %v, want DeadlineExceeded\n", err)
        }
    })
}

しかしこの書き方だと、contextをcancelするgoroutineに関してしか制御できていません。

複数goroutineがテスト関数内に登場した場合、すべてのgoroutineの完了を待機するためにwg.Wait()を大量に書くのはテストコード的にどうでしょうか。その部分はとてもノイズに感じられます。

また、「複数goroutineが内部で起動して並行処理を行うような関数」をテストしていた場合、関数内の処理が、起動される数個のgoroutineの実行完了も含めてどのタイミングで終わるかなどはどう制御すればいいのでしょうか。

そこで、synctest.Wait()を使うと、このAPIを1行差し込むだけで、他goroutineの実行完了を確実に待つことができます。

おわりに

今回は、testing/synctestの魔法のような効果を解き明かしたい!なモチベーションでコードリーディングをしました。

このgolang 1.25のsynctestに関するリリースがtesting/synctestというパッケージだけに閉じた話ではなく、goroutineの構造体や、time.Sleepの中身もそれに伴って書き換えれていたことがこの記事を通して理解できたかと思います。

読みつつ得た理解&解釈をそのまま記事にしていったので、雑なコードリーディングログみたいにもなっていて自己満な内容になっている説もあるんですが、温かい目で見ていただければと思います。

正直、runtimeパッケージのコードは雰囲気で読んでいる部分も多いですが、仕組みを知ると、驚きなどがあり面白いですね。(そして、「面白い」で終わる)

自身も、今回を通してなんとなく理解したつもりにはなったのですが、じゃあ、実務のコードでどこにこれを使えそうか?というのがまだいくつも考えつきません。

そもそもtesting/synctestが出始めのものだと思うので、プラクティスも確立されていないというのもありそうです。

ただ、「便利そう」なのは理解できました。とりあえず把握しておいて、手札においておく程度でいい気が
します。「ここであれが使えそう」と思い出せたら最高ですね。

コードを読み違えている/解釈し違えている可能性もありますので、なんかあればご指摘お待ちしております。

参考

https://pkg.go.dev/testing/synctest@go1.25.1
https://go.dev/blog/synctest
https://pkg.go.dev/context#WithTimeout
https://go.dev/doc/effective_go#goroutines

Discussion