Menu

Go言語のWaitGroup:Add、Done、Waitとワーカープール

sync.WaitGroupがゴルーチンの集まりの終了を待つ仕組みを解説します。Add、Done、Waitのルール、ポインタで渡さなければならない理由、結果とエラーの集め方、そしてそれを使って作るワーカープールまで。

このページのコードはエディタで実行できます - 編集してすぐに結果を確認できます。

基本のパターン

sync.WaitGroup は動いているゴルーチンを数えます。Add で数を増やし、Done で減らし、Wait はゼロになるまでブロックします。

3つのダウンロードは並行に動くので、プログラムは30ミリ秒ではなく約10ミリ秒で終わります。各ゴルーチンは sizes の自分のインデックスにだけ書き込み、mainWait の後でしか読まないので、結果は入力の順に表示されます。

WaitGroup のゼロ値はすぐに使えます。コンストラクタはありません。

3つのルール

Add はゴルーチンの中ではなく go の前に呼ぶ。 ゴルーチン自身が Add を呼ぶと、どのゴルーチンも始まる前に mainWait に到達し、数がゼロに見えて、処理が始まってもいないのに戻ることがあります。数が事前にわかっているなら、ループの前に一度 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 を呼び、mainWait で永遠にブロックします。go vet は何かを実行する前にそれを検出します。

./main.go:8:24: worker passes lock by value: sync.WaitGroup contains sync.noCopy

よりすっきりした設計は、並行処理を worker からまったく切り離すことです。worker は普通の関数にし、Add/Done の管理は呼び出し側のクロージャで行います。そうすれば worker はテストしやすく、同期的にも呼べます。

負のカウンタ

DoneAdd(-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 は最初のエラーを返し、コンテキストを通して他をキャンセルできます。

Coddy programming languages illustration

Coddyでコードを学ぼう

始める