Goでゴルーチン管理中の「panic: sync: negative WaitGroup counter」を修正する

intermediate🔷 Go2026-04-28| Go 1.13以降、Linux / macOS / Windows、sync.WaitGroupとゴルーチンを使用するプログラム全般

Error Message

panic: sync: negative WaitGroup counter
#go#goroutine#sync#waitgroup#panic

エラーの内容

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 ./...を追加し、プルリクエストごとに自動的に競合状態を検出できるようにする。

Related Error Notes