feature image

2025年2月13日 | ブログ記事

Go1.24 の testing/synctest で time.Ticker 使うなら (*time.Ticker).Stop しないと、異世界から抜け出せなくなる。

Go

こんばんは。いくら・はむです。昨日(2025年2月12日)、Goのv1.24がリリースされましたね。めでたい。この記事では1.24からの新しい機能の1つであるtesting/synctestを使っていて困った話をしようと思います。

その他の新しい機能については下のリリースノートからどうぞ。
https://tip.golang.org/doc/go1.24

Go 1.24 Release Notes - The Go Programming Language
Go 1.24 リリースノート

testing/synctestとは

testing/synctest (以下 synctest)は、その名の通り並行処理のテストを行うためのパッケージです。experimental な機能のため、使うためにはビルド時に環境変数で GOEXPERIMENT=synctest を指定する必要があります。

synctest にはsynctest.Run(func())synctest.Wait()の二つの関数が用意されています。リリースノートではそれぞれの関数について次のように説明されています。

https://tip.golang.org/doc/go1.24#testing-synctest

ざっくり和訳すると、synctest.Runは偽の時間が進む「bubble」の中で新しい goroutine を開始し、synctest.Wait は現在の「bubble」の全ての goroutine がブロックするまで待ちます。

これだけ読んでもよくわからないと思うので、実際にコードを書いてみましょう。

package main

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

func f() {
	time.Sleep(time.Second * 100)
	fmt.Println("Hello, World!")
}

func main() {
	synctest.Run(f)
}

とても簡単な例ですが、f は100秒待って Hello, World! を出力する関数です。しかし、これを実際に実行してみると一瞬で終わります。

Go Playground: https://go.dev/play/p/oMVqcEJKtja?v=gotip

これが synctest.Run の機能です。synctest.Runの「bubble」の中では、以下の説明のようにすべての goroutine がブロックされたとき、ブロックが解消されるまで時間が進み、「bubble」のすべての goroutine が終了するまで待ってから return します。今回は time.Sleep によってブロックされたので、100秒後まで一気に時間が進み、すぐに実行が終わります。

Time advances when every goroutine in the bubble is blocked. For example, a call to time.Sleep will block until all other goroutines are blocked and return after the bubble's clock has advanced. See Wait for the specific definition of blocked.

The new goroutine and any goroutines transitively started by it form an isolated "bubble". Run waits for all goroutines in the bubble to exit before returning.

https://github.com/golang/go/blob/76e4efdc77861954969ce192966e1595c268d8c1/src/testing/synctest/synctest.go#L37

もう1つの関数 synctest.Waitは、「bubble」の中の現在の goroutine 以外のすべての goroutine がブロックされるまでブロックします。

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

https://github.com/golang/go/blob/76e4efdc77861954969ce192966e1595c268d8c1/src/testing/synctest/synctest.go#L65

package main

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

var count = 0

func f() {
	count++
	time.Sleep(time.Second * 100)
	fmt.Println("Hello, World!")
}

func main() {
	synctest.Run(func() {
		go f()
		synctest.Wait()
		fmt.Println("count:", count)
	})
}

Go Playground: https://go.dev/play/p/ZHDCY77Thx-?v=gotip

これは f の方の goroutine で time.Sleepするのを待つので、count は 1 と出力されます。

今回の説明では普通のmain関数を使いましたが、 synctest はその名の通りテスト用の関数です。 プロポーザルの例ではキャッシュの有効期限が切れることを確認するテストに使っていました。今までであれば実際に sleep する必要がありテストに時間がかかっていましたが、synctest を使えばすぐにテストが終了するのでテストにかかる時間が短縮されるという嬉しさがあります。

synctest 難しいポイント

そんな便利な synctest パッケージですが、time.Ticker 周りでちょっとわかりにくい罠があります。以下のようなプログラムを考えます。

package main

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

func f() {
	count := 0
	ticker := time.NewTicker(time.Second)

	for {
		<-ticker.C
		count++
		fmt.Println(count)
		if count == 5 {
			return
		}
	}
}

func main() {
	// f()
	synctest.Run(f)
}

ticker を用いて 1 秒ごとにカウンタの値を増やし、カウンタの値が 5 になったら return して終了します。一見何の問題も無く実行が終了しそうです。単純に f を実行するだけであればなにも問題ありませんが、synctest.Run の中で実行すると問題が起こります。

Go Playground: https://go.dev/play/p/Z16maNj4ohw?v=gotip

これを実行すると、プログラムが終了しなくなります(Go Playgroundではタイムアウトになります)。関数 f は return していますから、『synctest.Runは、「bubble」のすべての goroutine が終了するまで待ってから return』するという性質を考えると正常に終了できそうに思えます。

問題の原因

この問題の原因はプロポーザルの issue のコメントに記述されていました。

https://github.com/golang/go/issues/67434#issuecomment-2565780150

proposal: testing/synctest: new package for testing concurrent code · Issue #67434 · golang/go
Current proposal status: #67434 (comment) This is a proposal for a new package to aid in testing concurrent code. // Package synctest provides support for testing concurrent code. package synctest…

The problem is that Run does not return so long as advancing the fake clock causes some event to happen. The time.Ticker provides a never-ending source of events.

つまり、「bubble」の中の偽の時計を進めるとtime.Tickerによってイベントが発生し続けるため、goroutine が終了してない判定になって synctest.Runが終了しないようです。

さっきのプログラムは、defer ticker.Stop()で明示的に ticker を停止することで正しく終了させることができます。

package main

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

func f() {
	count := 0
	ticker := time.NewTicker(time.Second)
+	defer ticker.Stop()

	for {
		<-ticker.C
		count++
		fmt.Println(count)
		if count == 5 {
			return
		}
	}
}

func main() {
	// f()
	synctest.Run(f)
}

Go Playground: https://go.dev/play/p/LYfE4KMK214?v=gotip

この issue コメントでは、time.Ticker の他にもループの中の time.Sleep もプログラムが終了しない例として挙げられていました。

1つ前のGo 1.23では time.Tickertime.Timer を明示的に止めなくても、使われなくなったらガベージコレクターが回収してくれるようになりましたが、1.24以降の synctest を使うのであればまだ明示的に停止する必要があるようです。

おわり

おわりです。リリースノートにも書かれていますが、testing/synctest は experimental であり、将来のリリースでその API が変更される可能性があり、現在もプロポーザルの issue では機能に対する指摘などが出ています。プロダクションコードに使う場合は注意したほうがいいかもしれないと思いました。

ikura-hamu icon
この記事を書いた人
ikura-hamu

SysAd班、ゲーム班 いろいろやりたい

この記事をシェア

このエントリーをはてなブックマークに追加
共有

関連する記事

2021年8月12日
CPCTFを支えたWebshell
mazrean icon mazrean
2021年5月19日
CPCTF2021を実現させたスコアサーバー
xxpoxx icon xxpoxx
2024年6月21日
ハッカソン参加記 4班"Slide Center"
Alt--er icon Alt--er
2024年3月15日
個人開発として2週間でWebサービスを作ってみた話 〜「LABEL」の紹介〜
Natsuki icon Natsuki
2023年10月20日
DIGI-CON HACKATHON 参加記事「Comic DoQ」
mehm8128 icon mehm8128
2023年6月23日
2023 春ハッカソン 26班 『traP Mission』
Ras icon Ras
記事一覧 タグ一覧 Google アナリティクスについて 特定商取引法に基づく表記