🦔

Goのtesting/synctestパッケージについて

に公開

つぎのドキュメントを元に、testing/synctestについて確認したいと思います。
https://pkg.go.dev/testing/synctest

Package synctest provides support for testing concurrent code.

まずsynctestの用途ですが、このパッケージは並行処理のテストをサポートするパッケージであると説明されています。

それでは、synctestに用意されている関数を確認していきます。

synctest.Test

func TestTime(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		start := time.Now() // always midnight UTC 2000-01-01
		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"
	})
}

ドキュメントに記載されているサンプルでは、synctest.Testが使用されています。
そして、synctest.Testは、つぎのように記載されています。

The Test function runs a function in an isolated "bubble". Any goroutines started within the bubble are also part of the bubble.

ここでは、Test関数がバブルと呼ばれる独立した環境を実行する。また、バブル内で起動したすべてのgoroutineもまたバブルの一部であると説明されています。

上記のテストを実行すると、コメントにあるように、"1s"と"2s"がログに出力されます。

つぎに、上記のサンプルコードの一部をコメントアウトして実行すると、エラーになります。

func TestTime(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		start := time.Now() // always midnight UTC 2000-01-01
		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"
	})
}

その際、つぎのようなメッセージが表示されました。

panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]

メインのバブルgoroutineは終了しているが、ブロックされたgoroutineが残っているとなっています。

ドキュメントでは、バブル内のすべてのgoroutineがdurably blockedの場合にのみ、バブル内の時間は進むとされています。

Time in a bubble only advances when every goroutine in the bubble is durably blocked.

このエラーになった理由は、つぎのようになったためと解釈しました。

  1. メインのgoroutineが中断せずに終了
  2. 子のgoroutineはtime.Sleepでdurably blockedになる
  3. バブル内のすべてのgoroutineがdurably blockedになるという条件が満たされない
  4. バブル内の時間が進まない
    => ブロックされた子のgoroutineは残ってしまっている

durably blocked とは

duraby blockedはドキュメントでは、つぎのように説明されています。

https://pkg.go.dev/testing/synctest#hdr-Blocking

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"です。
バブルの外部からのイベントによってブロックが解除可能なgoroutineはdurably blockedにはなりません。

synctest.Wait

つぎは synctest.Wait について確認します。

https://pkg.go.dev/testing/synctest#hdr-Blocking

func TestWait(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		done := false
		go func() {
			done = true
		}()
		// Wait will block until the goroutine above has finished.
		synctest.Wait()
		t.Log(done) // always logs "true"
	})
}

The Wait function blocks until all other goroutines in the bubble are durably blocked.

Wait関数は、バブル内のすべての他のgoroutineがdurably blockedになるまでブロックします。

上記のTestWait関数の内部でdone = trueに設定しているgoroutine(以下、子goroutine)はdurably blockedにはなっていません。

このことについては、つぎのように理解しました。

  1. synctest.Waitが呼ばれる
  2. 子goroutineが起動する(実行中になる)
  3. synctest.Waitは、実行中の他のすべてのgoroutineがdurably blockedになるまで待機する
  4. 子goroutineが終了する(実行中ではなくなった)
  5. 他のgoroutineがなくなったので、synctest.Waitはreturnする

synctest.Waitを使ったときに、goroutineが残ったままになった場合、エラーになることを確認したいと思います。
サンプルコードを変更して、子goroutineの内部でtime.Sleepを呼ぶようにしました。

func TestWait(t *testing.T) {
	synctest.Test(t, func(t *testing.T) {
		done := false
		go func() {
			time.Sleep(1 * time.Second)
			done = true
		}()
		synctest.Wait()
		t.Log(done) // false
	})
}

この場合、t.Log(done)の結果はfalseとなり、テストはつぎのエラーで失敗します。

panic: deadlock: main bubble goroutine has exited but blocked goroutines remain [recovered, repanicked]

このエラーが発生した理由を、つぎのように解釈しました。

  1. doneをfalseで初期化
  2. 子goroutineを起動
  3. synctest.Waitは子goroutineがdurably blockedになるのを待つ
  4. 子goroutineはtime.Sleepでdurably blockedになる
  5. synctest.Waitは子goroutineがdurably blockedになったのでreturnする
  6. doneはまだtrueになっていない
  7. t.Logの実行時はdoneがまだfalseのまま
    => 子goroutineが残ったままとなる

Discussion