0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-02-27

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

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

TL;DR

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

前提

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

testing/synctest.Run

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

testing/synctest.Wait

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する(<-これがバブルのデッドロック)

具体例

先日のブログ記事にある平行プログラムに対するテスト

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のどちらが正解でしょう?

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")
	})
}
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
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をどの順序で出力するでしょう?

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をどの順序で出力するでしょう?

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ではなく、ブロックされるという振る舞いだったことを初めて知りました。少し気になったので、この振る舞いに至った理由を調べてみると、こちらに書いてあったりしました。

  2. 実はWait関数のドキュメントには、Wait関数を呼び出したゴルーチン以外のゴルーチンが存在しない場合の振る舞いについては書かれていない。

  3. ChatGPTさんによると、明示的に「ここではまだ呼ばれていないはず」を確認する意図で入れてるのかもしれないね、とのことでした。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?