エラーの内容
panic: sync: WaitGroup is reused before previous Wait has returned
Goサービスでバッチジョブをループ処理していたときに遭遇したエラーです。WaitGroupがループの外側に宣言されており、毎回のラウンドで使い回されていました。ある時点で、前のwg.Wait()が完全に終了する前にwg.Add()が呼ばれてしまいました。Goのランタイムはこれをすぐさまキャッチしてパニックを起こします。状態が静かに壊れるよりはマシですが、本番環境で追跡するのは依然として厄介です。
なぜこのエラーが起きるのか
Goには一つの絶対ルールがあります。カウンターがゼロに移行している最中に、正のデルタでwg.Add()を呼んではいけません。「ゼロへの移行中」とは、Wait()が呼び出し元のブロックを解除している最中を意味します。その間にAdd()を呼ぶとパニックが発生します。
確実に引き起こす2つのパターンを紹介します。
パターン1 — ループのイテレーション間でWaitGroupを使い回す:
var wg sync.WaitGroup
for round := 0; round < 3; round++ {
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait() // round 0でWaitが返る...
// 次のイテレーションがすぐにwg.Add(1)を呼ぶ。
// Wait()が内部でまだ完全に返っていなければ、これがレースになる → パニック
}
パターン2 — Wait()が開始した後にゴルーチン内でAdd()を呼ぶ:
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done()
// ここで追加の作業を生成するのは安全ではない — このゴルーチンが
// 最後の一つだった場合、Done()でカウンターが0になりWait()がブロック解除される。
// そうなると、子のAdd()がそのブロック解除とレースしてしまう。
wg.Add(1) // ← パニックになる可能性がある
go func() {
defer wg.Done()
}()
}()
wg.Wait()
修正1:バッチごとに新しいWaitGroupを作成する
ループの内側でvar wgを宣言します。毎回のラウンドで新鮮なWaitGroupを使うことで、残留状態がなくなりレースの起きる余地がありません。ループベースのほとんどのパターンでこの方法が問題を解決します。
for round := 0; round < 3; round++ {
var wg sync.WaitGroup // 毎回のラウンドで新たに作成
for i := 0; i < 5; i++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
time.Sleep(10 * time.Millisecond)
}(i)
}
wg.Wait()
}
修正2:すべてのゴルーチンを開始する前にAdd()を呼ぶ
合計数が事前にわかる場合は、カウンターを一度セットしてからゴルーチンを起動します。
var wg sync.WaitGroup
tasks := fetchTasks()
wg.Add(len(tasks)) // どのゴルーチンも実行される前にカウンターをセット
for _, task := range tasks {
go func(t Task) {
defer wg.Done()
process(t)
}(task)
}
wg.Wait()
カウンターはlen(tasks)から始まり、そこからデクリメントされるだけです。Wait()がアクティブな間にAdd()が呼ばれることはないため、レースは起きません。
修正3:ネストしたゴルーチンでは、親がDone()を呼ぶ前にAdd()を呼ぶ
子ゴルーチンを生成するゴルーチンは、自身がデクリメントする前に子を登録しなければなりません。これにより、カウンターが常に正の値を保ちます。
var wg sync.WaitGroup
wg.Add(1)
go func() {
// このゴルーチンがDone()を呼ぶ前に子を登録する
wg.Add(2)
go func() {
defer wg.Done()
doWork()
}()
go func() {
defer wg.Done()
doWork()
}()
wg.Done() // 親が終了するが、カウンターはまだ2(子ゴルーチンの分)
}()
wg.Wait()
ルールは単純です。Add()をまだ呼ぶ予定がある間は、カウンターを絶対にゼロにしてはなりません。ゼロは引き返せない地点です。
修正4:複雑なファンアウトにはerrgroupを使う
シンプルなバッチを超えるものはすぐに複雑になります。golang.org/x/sync/errgroupはAdd/Done/Waitの管理を肩代わりし、ゴルーチンからのエラーも収集してくれます。2つの問題を1つのパッケージで解決できます。
import "golang.org/x/sync/errgroup"
func runBatch(tasks []Task) error {
g := new(errgroup.Group)
for _, task := range tasks {
t := task
g.Go(func() error {
return process(t)
})
}
return g.Wait()
}
g.Go()の呼び出しは内部的に安全です。Add/Doneの手動管理は不要で、g.Wait()はいずれかのゴルーチンから返された最初の非nilエラーを返します。
修正の確認
必ずレースディテクターを有効にして実行してください。WaitGroupの誤用を本番環境に持ち込む前に検出でき、パニックメッセージ自体よりもはるかに明確なスタックトレースが得られます。
go run -race main.go
# テストの場合
go test -race ./...
ストレステストをしたい場合は、関数をタイトなループで叩き続けてみましょう。
for i := 0; i < 500; i++ {
go runYourBatchFunction()
}
time.Sleep(5 * time.Second)
パニックが起きず、レースディテクターの出力もなければ完了です。
クイックヒント
- **常に
-raceをつけて開発してください。**本番でのパニックメッセージは曖昧です。レースディテクターを使えば、どのAdd()が遅れて呼ばれたかを示すゴルーチンの完全なスタックが得られます。 - **WaitGroupを値渡ししてはいけません。**必ず
*sync.WaitGroupを使用してください。使用中のWaitGroupをコピーすることは未定義動作であり、同じクラスのパニックを引き起こします。 - **WaitGroupは使用箇所の近くで宣言してください。**関数をまたいで共有されるパッケージレベルのWaitGroupは、使い回し事故の温床です。
- カウンターは常に正を保ってください。
Add()をまだ呼ぶ予定があるなら、カウンターをゼロにしてはなりません。ゼロになってWait()が返れる状態になったら、次のバッチには新しいWaitGroupを使いましょう。

