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

testing/synctest
とは
testing/synctest
(以下 synctest
)は、その名の通り並行処理のテストを行うためのパッケージです。experimental な機能のため、使うためにはビルド時に環境変数で GOEXPERIMENT=synctest
を指定する必要があります。
synctest
にはsynctest.Run(func())
とsynctest.Wait()
の二つの関数が用意されています。リリースノートではそれぞれの関数について次のように説明されています。
- The
synctest.Run
function starts a group of goroutines in an isolated “bubble”. Within the bubble, time package functions operate on a fake clock.- The
synctest.Wait
function waits for all goroutines in the current bubble to block.
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.
もう1つの関数 synctest.Wait
は、「bubble」の中の現在の goroutine 以外のすべての goroutine がブロックされるまでブロックします。
Wait blocks until every goroutine within the current bubble, other than the current goroutine, is durably blocked.
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
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.Ticker
や time.Timer
を明示的に止めなくても、使われなくなったらガベージコレクターが回収してくれるようになりましたが、1.24以降の synctest
を使うのであればまだ明示的に停止する必要があるようです。
おわり
おわりです。リリースノートにも書かれていますが、testing/synctest
は experimental であり、将来のリリースでその API が変更される可能性があり、現在もプロポーザルの issue では機能に対する指摘などが出ています。プロダクションコードに使う場合は注意したほうがいいかもしれないと思いました。