エラーの内容
Goプログラムが実行時に以下のようなエラーで終了します:
goroutine 1 [running]:
sync: negative WaitGroup counter
goroutine 1 [running]:
sync.(*WaitGroup).Add(0xc000012090, 0xffffffffffffffff)
/usr/local/go/src/sync/waitgroup.go:74 +0x12d
main.main()
/home/user/project/main.go:18 +0x68
exit status 2
sync.WaitGroup内部のカウンターがゼロ未満に下がりました。Goはこれを致命的なプログラミングエラーとして扱い、即座にパニックを起こします。リカバリーもグレースフルシャットダウンも行われません。
根本原因
WaitGroupのカウンターは常にゼロ以上でなければなりません。このパニックのほぼすべてのケースは、以下の3つのミスに起因しています:
Add()よりも多くDone()を呼び出す — 最も多い原因で、誤って二重呼び出しが発生するケースが多いです。- goroutineを起動する前ではなく、goroutineの内部で
Add()を呼び出す — スケジューラがAdd()の実行前にDone()を実行してしまう可能性があります。 Wait()がまだ実行中にWaitGroupを再利用する — リセットに対して新たなAdd()が競合することは、Go仕様で明示的に禁止されています。
修正1:Done()の過剰呼び出し
これが最も典型的な原因です。wg.Done()を呼び出すたびにカウンターが1減少します。Add(1)に対して2回呼び出すとカウンターが-1になり、即座にパニックが発生します。
問題のあるコード:
package main
import (
"fmt"
"sync"
)
func main() {
var wg sync.WaitGroup
wg.Add(1)
go func() {
defer wg.Done() // 1回目のDone — OK
fmt.Println("working")
wg.Done() // 2回目のDone — カウンターが-1に → パニック
}()
wg.Wait()
}
修正方法: 1つのgoroutineには1つのdefer wg.Done()のみ使用します。先頭に置き、手動でDone()を呼び出さないようにします。
go func() {
defer wg.Done() // ここだけ
fmt.Println("working")
}()
修正2:goroutineの内部でAdd()を呼び出す
goroutineは即座に起動しません。goキーワードからgoroutineが実際に実行されるまでの間に、スケジューラが他のコード(wg.Wait()を含む)を実行する可能性があります。goroutineの内部にAdd(1)を置くと、カウンターがインクリメントされる前にDone()が実行される競合状態が生じます。
問題のあるコード:
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
go func(n int) {
wg.Add(1) // 誤り: goroutineの内部でAddを呼び出している
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait()
}
修正方法: wg.Add(1)をループを持つgoroutine内のgo文の直前に移動します。
func main() {
var wg sync.WaitGroup
for i := 0; i < 5; i++ {
wg.Add(1) // 正しい: goキーワードの前にAddを呼び出す
go func(n int) {
defer wg.Done()
fmt.Println(n)
}(i)
}
wg.Wait()
}
修正3:WaitGroupを値渡しする
WaitGroupを値渡しすると、関数は独自のプライベートコピーを受け取ります。その関数内のDone()はコピーのカウンターを減らし、元のカウンターは変化しません。タイミングによっては、コピーが負の値になるか、元のWaitGroupが永遠にゼロを待ちながらブロックされます。
問題のあるコード:
func worker(wg sync.WaitGroup) { // 値コピー — 誤り
defer wg.Done()
fmt.Println("working")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go worker(wg)
wg.Wait() // 永遠にブロックされるか、パニックになる
}
修正方法: 常にポインターを渡します。これはGoにおいてポインタールールが絶対に必要なケースの一つです。
func worker(wg *sync.WaitGroup) { // ポインター — 正しい
defer wg.Done()
fmt.Println("working")
}
func main() {
var wg sync.WaitGroup
wg.Add(1)
go worker(&wg)
wg.Wait()
}
修正4:WaitGroupの早すぎる再利用
カウンターがゼロになると、Wait()はブロック解除を開始します。まさにその瞬間に別のgoroutineからAdd()を呼び出すと、内部リセットと競合します。Goのドキュメントでは、これを明示的に禁止しています。その結果は未定義動作であり、カウンターが負になるパニックとして現れることがあります。
問題のあるパターン:
// goroutine A: wg.Wait()がブロック解除中(カウンターがちょうど0になった)
// goroutine B: wg.Add(1)がリセットと競合 — 未定義動作
修正方法: バッチごとに新しいWaitGroupを使用します。ループ内で宣言してもコストはほとんどなく、競合状態を完全に排除できます。
for batch := 0; batch < 3; batch++ {
var wg sync.WaitGroup // バッチごとに新しいWaitGroup
for i := 0; i < 5; i++ {
wg.Add(1)
go func(n int) {
defer wg.Done()
fmt.Println(batch, n)
}(i)
}
wg.Wait() // 次のイテレーション前に完全に完了
}
早期検出:レースディテクター
Goには組み込みのレースディテクターが搭載されています。開発中に実行してください。WaitGroupの誤用やデータ競合を、単なるパニックスタックトレースではなく、goroutineレベルの詳細な情報とともに検出します:
go run -race main.go
go test -race ./...
競合が検出されると、関係するファイル、行番号、goroutineが正確に表示されます。本番環境ではなく、その時点で修正してください。
検証
修正が機能していることを確認する3つのコマンド:
# 通常実行
go run main.go
# レースディテクターを使用
go run -race main.go
# テストスイート全体
go test -race ./...
パニックなし、-raceからの出力なし。それが合格のサインです。
予防チェックリスト
wg.Add(n)はgo文の前に呼び出す — goroutineの内部では絶対に呼び出さない。defer wg.Done()をすべてのgoroutineの先頭行に、必ず1回だけ記述する。- 関数のシグネチャは
*sync.WaitGroup(ポインター)を取り、sync.WaitGroup(値)は使用しない。 wg.Wait()がブロック解除を開始した後にwg.Add()を呼び出さない — 代わりに新しいWaitGroupを使用する。- CIに
go test -race ./...を追加し、プルリクエストごとに自動的に競合状態を検出できるようにする。

