基本のパターン
sync.WaitGroup は動いているゴルーチンを数えます。Add で数を増やし、Done で減らし、Wait はゼロになるまでブロックします。
3つのダウンロードは並行に動くので、プログラムは30ミリ秒ではなく約10ミリ秒で終わります。各ゴルーチンは sizes の自分のインデックスにだけ書き込み、main は Wait の後でしか読まないので、結果は入力の順に表示されます。
WaitGroup のゼロ値はすぐに使えます。コンストラクタはありません。
3つのルール
Add はゴルーチンの中ではなく go の前に呼ぶ。 ゴルーチン自身が Add を呼ぶと、どのゴルーチンも始まる前に main が Wait に到達し、数がゼロに見えて、処理が始まってもいないのに戻ることがあります。数が事前にわかっているなら、ループの前に一度 wg.Add(len(files)) としても同じです。
Done はゴルーチンの最初の行で defer を付けて呼ぶ。 エラーで早めに戻ったりpanicしたりしたゴルーチンでも、カウンタは減ります。Done が欠けると Wait は永遠にブロックします。それが残った唯一のゴルーチンなら、ランタイムはトレースに sync.WaitGroup.Wait を含む fatal error: all goroutines are asleep を報告します。
最初に使った後のWaitGroupは決してコピーしない。 関数には *sync.WaitGroup を渡すか、上のように変数をクロージャで捕捉します。
WaitGroupを関数に渡す
ゴルーチンの本体が名前付きの関数なら、ポインタを渡します。
値の引数として wg sync.WaitGroup を受け取ると、各ワーカーは自分のコピーに対して Done を呼び、main は Wait で永遠にブロックします。go vet は何かを実行する前にそれを検出します。
./main.go:8:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy
よりすっきりした設計は、並行処理を worker からまったく切り離すことです。worker は普通の関数にし、Add/Done の管理は呼び出し側のクロージャで行います。そうすれば worker はテストしやすく、同期的にも呼べます。
負のカウンタ
Done は Add(-1) です。数がゼロを下回ると、プログラムはpanicします。
出力は recovered: sync: negative WaitGroup counter です。よくある原因は、defer wg.Done() を持つゴルーチンが、ある経路で明示的に wg.Done() も呼んでいることです。
エラーを集める
WaitGroup は数えるだけです。エラーについては、各ゴルーチンに専用の枠を与え、Wait の後で調べます。
errors.Join(Go 1.20)は nil の値を飛ばし、すべてが nil なら nil を返すので、余計な管理なしに「ゴルーチンごとに1つのエラー」をまとめられます。
1つのゴルーチンが失敗したらすぐに残りの処理を止めたいなら、代わりに golang.org/x/sync/errgroup を使います。これはWaitGroupに、最初のエラーと、失敗時にキャンセルされるコンテキストを加えたもので、g.SetLimit(n) で並行数も制限できます。標準ライブラリの外にあるので、このページのエディタでは実行できません。
g, ctx := errgroup.WithContext(ctx)
for _, h := range hosts {
g.Go(func() error { return checkCtx(ctx, h) })
}
if err := g.Wait(); err != nil {
return err // the first error; ctx was cancelled for the others
}
ワーカープール
チャネルからジョブを読む固定数のゴルーチンを使えば、ジョブがいくつあっても並行数が制限されます。WaitGroupは、すべてのワーカーが終わったこと、つまり結果のチャネルを閉じてよいタイミングを教えてくれます。
3つの部品の順序が重要です。
- ワーカーが動いている間、
mainは結果を受信していなければなりません。mainが読む前に直接wg.Wait()を呼ぶと、ワーカーはresultsへの送信でブロックし、Doneに到達せず、すべてがデッドロックします。Waitを専用のゴルーチンで実行しているのはこのためです。 close(results)はWaitの後でしか起きないので、どのワーカーも閉じたチャネルに送信することはありません。- ジョブを供給する側もゴルーチンで動くので、供給と収集が重なり合います。
どのワーカーがどのジョブを扱ったかは実行のたびに変わるので、プログラムは表示する前にジョブでソートしています。表示されるものはすべて決定的です。
WaitGroup、チャネル、errgroupの使い分け
| 必要なこと | 使うもの |
|---|---|
| N個のゴルーチンを待ち、結果をインデックス付きの枠に入れる | sync.WaitGroup |
| 1つのゴルーチンを待つ | done チャネルか結果のチャネルそのもの |
| 終わった順に結果を流す | wg.Wait() の後に閉じるチャネル |
| 最初のエラーですべてを止める | errgroup.WithContext |
| タイムアウトや呼び出し側のキャンセルですべてを止める | context.Context とWaitGroupまたはerrgroup |
Go 1.25では、Add(1) とdeferした Done を代わりにやってくれる wg.Go(func() { ... }) が追加されます。このページのエディタを含め、Go 1.24以前向けのコードでは上に示した明示的な形を使います。
よくある間違い
- ゴルーチンの中で
wg.Add(1)する。 それが実行される前にWaitが戻ることがあります。 - 早期リターンで
Doneを忘れる。 常にdefer wg.Done()とします。 - WaitGroupを値で渡す。 ポインタを使います。
go vetがコピーを指摘します。 - チャネルを空にしなければならないのと同じゴルーチンで待つ。
wg.Wait()とcloseを別のゴルーチンに移します。 - 前の
Waitが戻る前にWaitGroupを再利用する。 新しいAddの周期は、Waitが終わってから始めます。
よくある質問
Goのsync.WaitGroupはどういう仕組みですか?
WaitGroupはカウンタです。wg.Add(n) で増やし、wg.Done() で1つ減らし、wg.Wait() はゼロになるまでブロックします。各ゴルーチンを開始する前に Add を呼び、その中で defer wg.Done() とし、すべての終了が必要な場所で Wait を呼びます。
WaitGroupは値で渡すべきですか、ポインタで渡すべきですか?
ポインタ(*sync.WaitGroup)で渡すか、ゴルーチンにクロージャで捕捉させます。コピーは独自のカウンタを持つので、コピーに対する Done は元に届かず、Wait は永遠にブロックします。go vet はこの間違いを「passes lock by value」として報告します。
「sync: negative WaitGroup counter」の原因は何ですか?
Add の呼び出しより Done の呼び出しが多いことです。たいていは、ゴルーチンが Done を2回呼んでいる(defer で1回と明示的に1回)か、ある経路で Add(1) が飛ばされています。カウンタがもう正しいことを示せないので、プログラムはpanicします。
WaitGroupで開始したゴルーチンからエラーを受け取るには?
WaitGroupは結果もエラーも運びません。各ゴルーチンにエラーのスライスの専用の枠を与え、Wait の後でまとめる(たとえば errors.Join で)か、golang.org/x/sync/errgroup を使います。errgroupの Wait は最初のエラーを返し、コンテキストを通して他をキャンセルできます。