📚 背景知識(読んでから問題へ)
前回学んだselectは「複数のchannelのうち、どれか1つ」を待つための道具でした。しかし実務では「複数のgoroutineを起動し、そのすべてが終わるのを待ってから次に進みたい」という場面が非常に多く発生します。これをchannelだけで書こうとすると、goroutineの数だけ受信を繰り返す必要があり、数が可変だと途端に面倒になります。そこで使うのがsync.WaitGroupです。
var wg sync.WaitGroup
wg.Add(1) // 待つべきgoroutineの数をカウンタに加算
go func() {
defer wg.Done() // 完了したらカウンタを1減らす
// ...何か処理...
}()
wg.Wait() // カウンタが0になるまでブロックする
sync.WaitGroupは内部にカウンタを1つ持つだけのシンプルな構造体です。使い方のルールは3つだけです。
Add(n)でカウンタにnを加算する。goroutineを起動する前に呼ぶのが鉄則(goroutine内で呼ぶと、Wait()が先に実行されてカウンタがまだ0のまま素通りしてしまうrace conditionになりうる)Done()でカウンタを1減らす。goroutineの中でdefer wg.Done()と書くのが定石(途中でpanicしても確実にDoneが呼ばれる)Wait()はカウンタが0になるまで呼び出し元をブロックする
重要な制約として、sync.WaitGroupはコピーしてはいけません。値渡しするとコピー先とコピー元が別々のカウンタを持つことになり、Wait()が正しく機能しなくなります。そのため関数に渡すときは必ず*sync.WaitGroup(ポインタ)で渡します。この制約はgo vetのcopylocksチェックで静的に検出されます。
もう1つ知っておくべきことは、sync.WaitGroup自体にはタイムアウトの概念がないという点です。「一定時間待ってダメなら諦める」をやりたい場合は、wg.Wait()を別goroutineの中で実行し、その完了をchannelのcloseで外側に通知し、selectでtime.Afterと競わせる、という組み合わせ技が必要になります。前回学んだselectが、ここでまさに活きてきます。
📝 問題
以下の要件を満たす、1つの実行可能なGoプログラムを実装してください。
runWorkers(n int, task func(id int) string) []stringを実装してください。n個のgoroutineを起動し、それぞれがtask(id)(idは0からn-1)を実行します。sync.WaitGroupを使ってすべてのgoroutineの完了を待ってから、taskの戻り値をidの順に並べた[]stringを返してください。Addはgoroutine起動前に呼ぶこと、Doneはdeferで呼ぶことを守ってくださいwaitWithTimeout(wg *sync.WaitGroup, timeout time.Duration) boolを実装してください。wg.Wait()がtimeout以内に完了すればtrue、間に合わなければfalseを返してください。sync.WaitGroup自体にはタイムアウト機能がないため、別goroutineでwg.Wait()を実行してその完了をchannelのcloseで通知し、selectとtime.Afterを組み合わせて実装することmainでは、runWorkersに5個のworkerを異なる待ち時間で走らせ、結果がid順にすべて揃っていることを出力で確認してくださいmainでは、waitWithTimeoutを2パターン呼び出してください。(a)timeout内にすべてのgoroutineが完了してtrueが返るケース、(b)timeoutより処理が長くかかりfalseが返るケース
go run main.goでそのまま実行できる、完全な1つのプログラムとして提出してください。
🔍 ヒント(段階的開示)
ヒント1 — 方向性
sync.WaitGroupは「あといくつ待つべきgoroutineが残っているか」を数えるだけのカウンタです。Addで増やしDoneで減らしWaitでゼロになるのを待つ、という単純なライフサイクルを守ることが全てです。runWorkersでは各goroutineが結果スライスの異なるindexにしか書き込まないため、複数goroutineが同じメモリ位置を同時に書き換えることはなく、sync.Mutexのような排他制御は不要です(これはPhase 2の次のテーマで扱います)。
ヒント2 — アプローチ
waitWithTimeoutでは、wg.Wait()をそのまま呼ぶとブロックしたままタイムアウトできません。done := make(chan struct{})を用意し、別goroutineで「wg.Wait()→完了したらclose(done)」を行い、外側ではselect { case <-done: ... case <-time.After(timeout): ... }のように、前回学んだselectで2つのイベント(完了通知 / タイムアウト)を競わせます。
ヒント3 — コード骨格
package main
import (
"fmt"
"sync"
"time"
)
func runWorkers(n int, task func(id int) string) []string {
results := make([]string, n)
var wg sync.WaitGroup
for id := 0; id < n; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
// results[id] = task(id)
}(id)
}
wg.Wait()
return results
}
func waitWithTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}
func main() {
// ここから、runWorkersとwaitWithTimeoutの動作確認を書いていく
fmt.Println("skeleton")
}
✅ 模範解答
package main
import (
"fmt"
"sync"
"time"
)
// runWorkers は n 個のgoroutineを起動してそれぞれ task(id) を実行し、
// sync.WaitGroup で全ての完了を待ってから、id順に整列した結果を返す。
func runWorkers(n int, task func(id int) string) []string {
results := make([]string, n)
var wg sync.WaitGroup
for id := 0; id < n; id++ {
wg.Add(1)
go func(id int) {
defer wg.Done()
results[id] = task(id)
}(id)
}
wg.Wait()
return results
}
// waitWithTimeout は wg.Wait() が timeout 以内に完了すれば true、
// 間に合わなければ false を返す。sync.WaitGroup自体にはtimeout機能がないため、
// 別goroutineでWait()し、その完了をchannelのcloseで通知してselectと組み合わせる。
func waitWithTimeout(wg *sync.WaitGroup, timeout time.Duration) bool {
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
}
func main() {
fmt.Println("=== runWorkers: 5個のworkerの完了を待って結果を集約 ===")
results := runWorkers(5, func(id int) string {
time.Sleep(time.Duration(5-id) * 30 * time.Millisecond)
return fmt.Sprintf("worker-%d done", id)
})
for i, r := range results {
fmt.Printf(" [%d] %s\n", i, r)
}
fmt.Println("\n=== waitWithTimeout: timeout内に完了するケース ===")
var wgFast sync.WaitGroup
wgFast.Add(3)
for i := 0; i < 3; i++ {
go func(i int) {
defer wgFast.Done()
time.Sleep(50 * time.Millisecond)
}(i)
}
ok := waitWithTimeout(&wgFast, 500*time.Millisecond)
fmt.Println(" 完了:", ok)
fmt.Println("\n=== waitWithTimeout: timeoutより遅いケース ===")
var wgSlow sync.WaitGroup
wgSlow.Add(1)
go func() {
defer wgSlow.Done()
time.Sleep(1 * time.Second)
}()
ok = waitWithTimeout(&wgSlow, 200*time.Millisecond)
fmt.Println(" 完了:", ok)
}
▶ 実行結果を見る(go run main.go / go run -race main.go 両方で検証済み)
=== runWorkers: 5個のworkerの完了を待って結果を集約 ===
[0] worker-0 done
[1] worker-1 done
[2] worker-2 done
[3] worker-3 done
[4] worker-4 done
=== waitWithTimeout: timeout内に完了するケース ===
完了: true
=== waitWithTimeout: timeoutより遅いケース ===
完了: false
※ go run -race main.goでもdata race検出なしを確認済みです。
🪜 Step-by-Step 解説
runWorkersでは、forループの中でwg.Add(1)をgoroutineを起動する行より前に呼んでいます。もしwg.Add(1)をgoroutineの内側に書いてしまうと、main側のループが5回まわってgo文を5回発行し終えた直後、まだどのgoroutineもAddを実行していないタイミングでwg.Wait()が呼ばれる可能性があります。その瞬間カウンタは0のままなので、Wait()は「もう全部終わった」と誤解してすぐに戻ってしまいます。Addをgoroutine起動前の同期的な位置(ループ本体)で呼ぶことで、このraceを構造的に防いでいます。
results[id] = task(id)は、5つのgoroutineがそれぞれresults[0]〜results[4]という重ならないメモリ位置に書き込みます。Goのメモリモデル上、異なるインデックスへの書き込みは互いに競合しないため、sync.Mutexのような排他制御なしで安全です。go run -raceで実行しても検出されない理由はこれです。もし複数goroutineが同じ変数(例えば集計用のtotal int)に書き込む設計なら、話は別で排他制御が必須になります(次回のテーマ)。
done := make(chan struct{})
go func() {
wg.Wait()
close(done)
}()
select {
case <-done:
return true
case <-time.After(timeout):
return false
}
wg.Wait()自体はブロッキングでタイムアウトの概念を持たないため、それを別のgoroutineに閉じ込めてしまい、「終わったらchannelをcloseする」という形でイベント化しています。close(done)されたchannelからの受信は即座に成功する(ゼロ値が返る)という性質を利用し、doneを「完了通知専用のchannel」として使っています。外側のselectは、この完了通知とtime.Afterのタイムアウト通知のどちらが先に来るかを競わせているだけで、これはDay 011で学んだselectパターンそのものです。
💡 設計思想・なぜこう書くのか
sync.WaitGroupです。今回のrunWorkersのように各goroutineの結果を集めたいだけなら、結果を格納する場所(今回はスライスの各index)は最初から確保しておき、WaitGroupは純粋に「いつ全員が仕事を終えたか」を知らせる役割に専念させる、という設計がGoでは自然です。channelで同じことをやろうとすると、受信側で「あと何回受信すればいいか」を自分で数える必要があり、WaitGroupはその数を数える責務を専用の型に切り出したもの、と捉えると理解しやすくなります。sync.WaitGroupにタイムアウト機能がないのも意図的な設計です。Goは「1つの型に多くの責務を詰め込まない」ことを好みます。WaitGroupはカウンタとしての役割に徹し、「時間切れで諦める」という別の関心事はtime.Afterとselectという既存のプリミティブの組み合わせで表現します。この結果、WaitGroup自体は驚くほどシンプルなAPI(Add/Done/Waitの3つだけ)に保たれています。🌐 他言語との比較
| 観点 | Go | Java | Python | JavaScript/Node.js |
|---|---|---|---|---|
| 複数スレッド/タスクの完了待ち | sync.WaitGroup(Add/Done/Wait) | CountDownLatch または ExecutorService.invokeAll(...) | threading.Thread.join()をループ、またはconcurrent.futures.wait(...) | Promise.all([...]) |
| カウンタの増減 | Add(n) / Done()を手動で対にする | CountDownLatchは初期値固定でコンストラクタ引数、countDown()のみ(Addに相当する再増加はできない) | 明示的なカウンタなし(Futureのリストをwaitに渡すだけ) | 明示的なカウンタなし(Promise配列を渡すだけ) |
| タイムアウト付き待機 | Wait()を別goroutine化しselect+time.Afterで自前実装 | latch.await(timeout, unit)がAPIとして標準搭載 | concurrent.futures.wait(fs, timeout=...)がAPIとして標準搭載 | Promise.race([Promise.all([...]), timeoutPromise])を自前で組み合わせ |
| 値渡しの安全性 | コピー禁止(go vetのcopylocksで検出) | 参照型なのでコピーの概念自体がない | 参照型なのでコピーの概念自体がない | 参照型なのでコピーの概念自体がない |
Java のCountDownLatchはカウンタが一度0になったら再利用できず初期値もコンストラクタで固定という点でWaitGroupより制約が強い一方、await(timeout, unit)のようにタイムアウトが最初からAPIに組み込まれています。GoのWaitGroupは逆に「カウンタの増減が自由(Addを後からさらに呼べる)」という柔軟性と引き換えに、タイムアウトは自分でselectと組み合わせて作る、というトレードオフになっています。
🏆 実務での使いどころ
- 並列APIコールの集約: 複数の外部APIやマイクロサービスに並行してリクエストを送り、全レスポンスが揃ってから1つの結果としてまとめて返す(今回の
runWorkersはこの簡易版) - バッチ処理のfan-out: 大量のレコードをN個のgoroutineで分担処理し、全処理が終わったことを確認してから後続処理(DB書き込みの確定など)に進む
- graceful shutdown時のドレイン待ち: サーバー停止時に「処理中のリクエストが全て完了するまで待つ」ために、リクエストごとに
Add(1)/Done()するWaitGroupをハンドラに仕込む設計 - タイムアウト付き並列処理:
waitWithTimeoutのパターンは、「複数の並行処理をなるべく待つが、無限には待てない」という本番運用でよくある要件(例: ヘルスチェック集約、複数キャッシュノードへの書き込み確認)に直接使える
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
| wg.Add(1)をgoroutineの内側で呼んでしまう | 「goroutineが自分の分をカウントする」という感覚で書いてしまう | Addはgoroutine起動前に呼ばないと、Wait()がまだカウンタ0のタイミングで素通りしてしまうrace conditionになりうる |
sync.WaitGroupを値として関数に渡してしまう(func f(wg sync.WaitGroup)) | 他の小さな構造体と同じ感覚でコピー渡ししてしまう | WaitGroupは内部状態を共有する必要があるため、コピーすると別々のカウンタになり正しく機能しない。必ず*sync.WaitGroupで渡す(go vetのcopylocksが検出してくれる) |
| goroutine内でdefer wg.Done()を書き忘れる | 正常系だけを想定してコードを書いてしまう | 処理中にpanicするとDoneが呼ばれずカウンタが減らないままになり、Wait()が永遠にブロックする(デッドロック)。deferで書くことで異常系でも確実にDoneが呼ばれる |
| AddとDoneの回数が合わずカウンタが負になる | Done()を条件分岐の中で複数回呼んでしまう等 | カウンタが負になるとsync: negative WaitGroup counterでpanicする。1つのgoroutineにつきDone()は正確に1回だけ呼ぶ設計にする |
🚀 次のステップ
- 発展:
runWorkersを改造し、各goroutineが個別のindexではなく共有の合計値(例: 全taskの処理時間の合計)を書き込むように変更してみましょう。その場合、単純なtotal += xではdata raceになることをgo run -raceで確認し、次回学ぶsync.Mutexで保護する必要性を体感してください - 次回予告: Day 013 — sync.Mutex / RWMutex(実装問題)。複数goroutineが同じ変数を読み書きする場合の排他制御を学びます