Go1.24 testing/synctest バブルが永続的にブロックされると何がどうなるのか。具体例



[先日のブログ記事](https://go.dev/blog/synctest)は、Go1.24から試験的に導入された[testing/synctest](https://pkg.go.dev/testing/synctest@go1.24.0)パッケージについて説明するものでしたが、[#blocking-and-the-bubble](https://go.dev/blog/synctest#blocking-and-the-bubble)に書かれている**durably blocked**という概念についてよくわからなかったので、具体例を通して理解してみました。自分用の雑メモでござる。

**注意** 本記事はバージョンgo1.24.0のtesting/synctestパッケージを対象にしています。


# TL;DR

- 「バブルが永続的にブロックされる」って何？
- 具体例で慣れる

# 前提

本記事を読む上で必要となるtesting/synctestパッケージについての知識をまとめます。

## [testing/synctest.Run](https://pkg.go.dev/testing/synctest@go1.24.0#Run)

```golang
func Run(f func())
```

- Run関数は第1引数fを新しいゴルーチンとして実行する
- f自身と、fから推移的に開始されるゴルーチンをまとめたものをバブルと呼ぶ
- Run関数はバブル中の全ゴルーチンが完了するまで制御を返さない
- バブル中では、2000-01-01T00:00:00Zを始まりとする偽の時間(*synthetic time*)が流れている
- **バブルがデッドロックする(<-わからん1)** とpanicする

## [testing/synctest.Wait](https://pkg.go.dev/testing/synctest@go1.24.0#Wait)

```golang
func Wait()
```

特徴

- バブル中に存在している、Wait関数を呼び出したゴルーチン以外の全ての**ゴルーチンが永続的にブロックされる(*durably blocked*)(<-わからん2)** まで、Wait関数は制御を返さない
- Wait関数を呼び出したゴルーチン以外にゴルーチンが存在しない場合、Wait関数は制御を返す
- 1つのバブル中で同時に複数のWait関数を呼ぶことはできない

# わからない用語についての説明

## バブル中の1つのゴルーチンが永続的にブロックされるとは

バブル中のゴルーチンが以下のいずれかによってブロックされること。

- nilチャンネルへのメッセージ送受信[^1]
- バブル中で作成されたチャンネルにて発生しているブロック
- 全てのcase文がブロックされているselect構文
- time.Sleep関数
- sync.Cond.Wait関数
- sync.WaitGroup.Wait関数

## バブルがデッドロックする

「バブルが永続的にブロックされた」とは「バブル中の全ゴルーチンが永続的にブロックされた」ことを指します。

もしバブルが永続的にブロックされた場合、バブルは次のように振る舞う。

- もし未処理のWait関数がある場合、制御を返す
- そうでない場合、偽の時間が次へ進む(時間が進むことを待っている関数があれば、その関数は制御を返す)
- そうでない場合、バブルはデッドロックとなり、Run関数はpanicする(<-これが**バブルのデッドロック**)

# 具体例

## [先日のブログ記事](https://go.dev/blog/synctest)にある平行プログラムに対するテスト

```golang
func TestAfterFunc(t *testing.T) {
	synctest.Run(func() { // ゴルーチン1
		ctx, cancel := context.WithCancel(context.Background())

		funcCalled := false
		context.AfterFunc(ctx, func() { // ゴルーチン2
			funcCalled = true
		})

		synctest.Wait() // (1)
		if funcCalled { // (2)
			t.Fatalf("AfterFunc function called before context is canceled")
		}

		cancel() // (3)

		synctest.Wait() // (4)
		if !funcCalled {
			t.Fatalf("AfterFunc function not called after context is canceled")
		}
	})
}
```

実行過程の解説

(1)が実行された時点において、ゴルーチン1の他に実行されているゴルーチンは存在しない(ゴルーチン2はまだ実行されていない)。この場合、synctest.Waitは制御を返す[^2]。

(2)ゴルーチン2はまだ実行されていないためfuncCalledはfalseとなる。

(3) cancelする。するとゴルーチン2が実行される。
(4) ゴルーチン2が実行中、バブルは永続的にブロックされている状態ではないため、synctest.Waitは制御を返さない。ゴルーチン1が実行完了すると、ゴルーチン1の他に実行されているゴルーチンは存在しなくなるため、synctest.Waitは制御を返す。

余談だが(1)のsynctest.Waitは不要では？[^3]

## 偽の時間が進んで、Wait関数が制御を返す例

**問題**

次のmain.goの出力はAとBのどちらが正解でしょう？

```golang:main.go
package main

import (
	"fmt"
	"testing/synctest"
	"time"
)

func main() {
	synctest.Run(func() { // ゴルーチン1
		fmt.Printf("synthetic time=%s\n", time.Now().Format(time.RFC3339Nano)) // (1)
		go func() { // ゴルーチン2
			time.Sleep(1 * time.Second)                                            // (2)
			fmt.Printf("synthetic time=%s\n", time.Now().Format(time.RFC3339Nano)) // (3)
		}()

		synctest.Wait() // (10)
		fmt.Println("synctest.Wait is called")
	})
}
```

```bash:A
% GOEXPERIMENT=synctest go run ./cmd/how2synctest002/main.go 
synthetic time=2000-01-01T09:00:00+09:00
synthetic time=2000-01-01T09:00:01+09:00
synctest.Wait is called
```

```bash:B
% GOEXPERIMENT=synctest go run ./cmd/how2synctest002/main.go 
synthetic time=2000-01-01T09:00:00+09:00
synctest.Wait is called
synthetic time=2000-01-01T09:00:01+09:00
```

**解答**

(1)偽の現在時刻の初期値(UTC 2000-01-01 00:00:00)を表示する。
(2)ゴルーチン2はtime.Sleepによって永続的にブロックされる。
(10)ゴルーチン2が(2)へ到達するまでは、Wait関数は制御を返さない。しかしながら、(2)へ到達すると、ゴルーチン1以外の全てのゴルーチンが永続的にブロックされたため、Wait関数は制御を返す(この振る舞いが理解し難い。「全てがブロックされるまで待つ」というのが直感的ではないからだろうか)。
(3)ゴルーチン1が終わった後、全てのゴルーチンが永続的にブロックされたため、偽の時間が次へ進んで、(2)のtime.Sleepが制御を返す。

ということで正解はB。

## チャンネルに対する送受信が制御を返さない具体例

**問題**

main.goは`a`,`b`,`c`,`d`をどの順序で出力するでしょう？

```golang:main.go
package main

import (
	"fmt"
	"testing/synctest"
)

func main() {
	synctest.Run(func() { // ゴルーチン1
		ch := make(chan int)
		go func() { // ゴルーチン2
			fmt.Println("a")
			ch <- 1 // (1)
			fmt.Println("b")
		}()

		synctest.Wait() // (2)
		fmt.Println("c")

		<-ch            // (3)
		synctest.Wait() // (4)
		fmt.Println("d")
	})
}
```

**解答**

(1)ゴルーチン2は永続的にブロックされる。
(2)ゴルーチン2が(1)まで到達するとsynctest.Waitは制御を返す。
(3)が実行されるとゴルーチン2の(1)が制御を返すため、ゴルーチン2の永続的なブロックが解除される。
(4)ゴルーチン2が完了するまでは、synctest.Waitは制御を返さない。ゴルーチン2が完了するとsynctest.Waitは制御を返す。

ということで

```
a
c
b
d
```

## チャンネルによるブロックとtime.Sleepによるブロックが混合してる

**問題**

main.goは`a`,`b`,`c`,`d`,`e`をどの順序で出力するでしょう？

```golang:main.go
package main

import (
	"fmt"
	"testing/synctest"
	"time"
)

func main() {
	synctest.Run(func() { // ゴルーチン1
		ch := make(chan int)

		go func() { // ゴルーチン2
			ch <- 1 // (2)
			fmt.Println("a")
		}()

		go func() { // ゴルーチン3
			time.Sleep(1 * time.Second) // (3)
			fmt.Println("b")
		}()

		synctest.Wait() // (1)
		fmt.Println("c")

		synctest.Wait() // (4)
		fmt.Println("d")

		<-ch            // (5)
		synctest.Wait() // (6)
		fmt.Println("e")
	})
}
```

(1)ゴルーチン2の(2),ゴルーチン3の(3)へ到達したら、synctest.Wait関数は制御を返す。
(4)ゴルーチン2の(2),ゴルーチン3の(3)でブロックされたままであるため、synctest.Wait関数は制御を返す。
(5)この行が実行されると、ゴルーチン2の(2)が制御を返す。
(6)ゴルーチン2が完了したら、synctest.Wait関数は制御を返す。
(3)ゴルーチン1が完了したら、time.Sleepは制御を返す。

ということで

```
c
d
a
e
b
```

# 感想

ちょっと慣れが必要...というかわかりにくくね(^^;

[^1]: nilチャンネルへのメッセージ送受信が、すぐに制御を返す、とか、panicではなく、ブロックされるという振る舞いだったことを初めて知りました。少し気になったので、この振る舞いに至った理由を調べてみると、[こちら](https://groups.google.com/g/golang-nuts/c/QltQ0nd9HvE/m/4TR-Bw1xbX8J)に書いてあったりしました。
[^2]: 実はWait関数のドキュメントには、Wait関数を呼び出したゴルーチン以外のゴルーチンが存在しない場合の振る舞いについては書かれていない。
[^3]: ChatGPTさんによると、明示的に「ここではまだ呼ばれていないはず」を確認する意図で入れてるのかもしれないね、とのことでした。