Day 012 — sync.WaitGroup

2026-08-09 🔵 中級者 / Phase 2 実装 sync.WaitGroup

📚 背景知識(読んでから問題へ)

前回学んだ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 vetcopylocksチェックで静的に検出されます。

もう1つ知っておくべきことは、sync.WaitGroup自体にはタイムアウトの概念がないという点です。「一定時間待ってダメなら諦める」をやりたい場合は、wg.Wait()を別goroutineの中で実行し、その完了をchannelのcloseで外側に通知し、selecttime.Afterと競わせる、という組み合わせ技が必要になります。前回学んだselectが、ここでまさに活きてきます。

📝 問題

以下の要件を満たす、1つの実行可能なGoプログラムを実装してください。

  1. runWorkers(n int, task func(id int) string) []stringを実装してください。n個のgoroutineを起動し、それぞれがtask(id)id0からn-1)を実行します。sync.WaitGroupを使ってすべてのgoroutineの完了を待ってから、taskの戻り値をidの順に並べた[]stringを返してください。Addはgoroutine起動前に呼ぶこと、Donedeferで呼ぶことを守ってください
  2. waitWithTimeout(wg *sync.WaitGroup, timeout time.Duration) boolを実装してください。wg.Wait()timeout以内に完了すればtrue、間に合わなければfalseを返してください。sync.WaitGroup自体にはタイムアウト機能がないため、別goroutineでwg.Wait()を実行してその完了をchannelのcloseで通知し、selecttime.Afterを組み合わせて実装すること
  3. mainでは、runWorkersに5個のworkerを異なる待ち時間で走らせ、結果がid順にすべて揃っていることを出力で確認してください
  4. 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 解説

1
Add→go→Doneの順序が安全性の核心
runWorkersでは、forループの中でwg.Add(1)goroutineを起動する行より前に呼んでいます。もしwg.Add(1)をgoroutineの内側に書いてしまうと、main側のループが5回まわってgo文を5回発行し終えた直後、まだどのgoroutineもAddを実行していないタイミングでwg.Wait()が呼ばれる可能性があります。その瞬間カウンタは0のままなので、Wait()は「もう全部終わった」と誤解してすぐに戻ってしまいます。Addをgoroutine起動前の同期的な位置(ループ本体)で呼ぶことで、このraceを構造的に防いでいます。
2
各goroutineが異なるindexにしか書き込まない設計
results[id] = task(id)は、5つのgoroutineがそれぞれresults[0]results[4]という重ならないメモリ位置に書き込みます。Goのメモリモデル上、異なるインデックスへの書き込みは互いに競合しないため、sync.Mutexのような排他制御なしで安全です。go run -raceで実行しても検出されない理由はこれです。もし複数goroutineが同じ変数(例えば集計用のtotal int)に書き込む設計なら、話は別で排他制御が必須になります(次回のテーマ)。
3
waitWithTimeout——WaitGroupとselectの組み合わせ
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パターンそのものです。

💡 設計思想・なぜこう書くのか

📌
完了待ち合わせの責務をカウンタ専用の型に切り出す: Goの標準ライブラリは「完了待ち合わせ」に対して2つの道具を用意しています。値の受け渡しを伴う場合はchannel、単に「N個の作業が全部終わったか」だけを知りたい場合はsync.WaitGroupです。今回のrunWorkersのように各goroutineの結果を集めたいだけなら、結果を格納する場所(今回はスライスの各index)は最初から確保しておき、WaitGroupは純粋に「いつ全員が仕事を終えたか」を知らせる役割に専念させる、という設計がGoでは自然です。channelで同じことをやろうとすると、受信側で「あと何回受信すればいいか」を自分で数える必要があり、WaitGroupはその数を数える責務を専用の型に切り出したもの、と捉えると理解しやすくなります。
📌
1つの型に多くの責務を詰め込まない: sync.WaitGroupにタイムアウト機能がないのも意図的な設計です。Goは「1つの型に多くの責務を詰め込まない」ことを好みます。WaitGroupはカウンタとしての役割に徹し、「時間切れで諦める」という別の関心事はtime.Afterselectという既存のプリミティブの組み合わせで表現します。この結果、WaitGroup自体は驚くほどシンプルなAPI(Add/Done/Waitの3つだけ)に保たれています。

🌐 他言語との比較

観点GoJavaPythonJavaScript/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が同じ変数を読み書きする場合の排他制御を学びます

🎯 自己評価

自分の回答

気づき・メモ