はじめに
Go言語(Golang)の最大の特徴の一つは、並行処理を簡単に扱える設計になっていることです。その中核となる機能がゴルーチン(goroutine)と、それらを同期するための仕組みです。特にWaitGroupは複数のゴルーチンの完了を待つための一般的な同期メカニズムですが、使い方を間違えるとデッドロックを引き起こす可能性があります。
この記事では、WaitGroupの使い方に関する一般的な間違いとその解決策について説明します。
WaitGroupの基本
WaitGroupはsync
パッケージに含まれる同期プリミティブで、主に3つのメソッドを持っています:
-
Add(delta int)
: カウンタを増加させる -
Done()
: カウンタを1減少させる -
Wait()
: カウンタが0になるまでブロックする
基本的な使い方は次のとおりです:
var wg sync.WaitGroup
// ゴルーチンを開始する前にカウンタを増加
wg.Add(1)
// ゴルーチンを起動
go func() {
// 処理が完了したらDone()を呼び出す
defer wg.Done()
// 何らかの処理
}()
// すべてのゴルーチンが完了するまで待つ
wg.Wait()
よくあるWaitGroupの間違いとデッドロック
問題例1: Add()とDone()の呼び出し回数の不一致
最も一般的なデッドロックの原因は、Add()
とDone()
の呼び出し回数が一致しないことです。以下に例を示します:
func processItems(items []string) {
var wg sync.WaitGroup
// 間違い: itemsの長さ分カウンタを増加
wg.Add(len(items))
// しかし、ゴルーチンは1つしか起動していない
go func() {
// Done()は1回しか呼ばれない
defer wg.Done()
for _, item := range items {
processItem(item)
}
}()
// デッドロック発生: カウンタはlen(items)-1の値で止まる
wg.Wait()
}
この例では、wg.Add(len(items))
でカウンタをitems
の長さ分増加させていますが、wg.Done()
は1回しか呼び出されません。その結果、カウンタは完全に0になることがなく、wg.Wait()
でプログラムがデッドロックします。
解決策1: Add()の呼び出しを修正
func processItems(items []string) {
var wg sync.WaitGroup
// 修正: ゴルーチンが1つなので、Add(1)にする
wg.Add(1)
go func() {
defer wg.Done()
for _, item := range items {
processItem(item)
}
}()
wg.Wait()
}
解決策2: 各アイテムに対してゴルーチンを起動
もし本当に各アイテムを並行処理したい場合は、各アイテムに対してゴルーチンを起動します:
func processItems(items []string) {
var wg sync.WaitGroup
for _, item := range items {
wg.Add(1)
go func(i string) {
defer wg.Done()
processItem(i)
}(item) // 変数をゴルーチンに渡す
}
wg.Wait()
}
問題例2: ゴルーチン起動後のAdd()呼び出し
別の一般的な間違いは、ゴルーチンが起動した後にAdd()
を呼び出すことです:
func downloadFiles(urls []string) {
var wg sync.WaitGroup
results := make(chan string)
for _, url := range urls {
// 間違い: ゴルーチンを先に起動
go func(u string) {
// wg.Add(1)が呼ばれる前にDone()が呼ばれる可能性がある
defer wg.Done()
// ダウンロード処理
results <- fmt.Sprintf("Downloaded: %s", u)
}(url)
// 後からAdd()を呼び出し
wg.Add(1)
}
// レース条件: Add()が呼ばれる前にDone()が呼ばれるとパニック
wg.Wait()
close(results)
}
この例では、ゴルーチンがメインルーチンより先に実行される可能性があり、wg.Add(1)
が実行される前にwg.Done()
が呼び出されてしまうとパニックが発生します。
解決策: ゴルーチン起動前にAdd()を呼び出す
func downloadFiles(urls []string) {
var wg sync.WaitGroup
results := make(chan string)
for _, url := range urls {
// 修正: 先にAdd()を呼び出す
wg.Add(1)
go func(u string) {
defer wg.Done()
// ダウンロード処理
results <- fmt.Sprintf("Downloaded: %s", u)
}(url)
}
go func() {
wg.Wait()
close(results)
}()
// 結果の受信
for result := range results {
fmt.Println(result)
}
}
問題例3: 負の値を持つAdd()
Add()
メソッドは負の値を受け取ることもできますが、これによりカウンタが負の値になるとパニックが発生します:
func processData() {
var wg sync.WaitGroup
// カウンタは0から始まる
// 間違い: カウンタが0の状態で負の値を追加
wg.Add(-1) // パニック: negative WaitGroup counter
wg.Wait()
}
解決策: 常に適切な数のAdd()とDone()の呼び出しを保証する
func processData() {
var wg sync.WaitGroup
// 正しいパターン: 先にAdd()を呼び出す
wg.Add(1)
go func() {
defer wg.Done()
// 処理
}()
wg.Wait()
}
まとめ
WaitGroupを使用する際の主なポイントは以下です:
-
Add()
とDone()
の呼び出し回数を一致させる - 必ずゴルーチンを起動する前に
Add()
を呼び出す - 各ゴルーチン内で
defer wg.Done()
を使用して、確実にDone()
が呼び出されるようにする - カウンタが負の値になるような
Add()
の呼び出しを避ける
これらのポイントを守ることで、WaitGroupに関連するデッドロックやパニックを回避できます。Go言語の並行処理は非常に強力ですが、正しく使用するためには同期メカニズムについての理解が必要です。
発展的な使い方: errgroup
より高度な並行処理のパターンが必要な場合は、golang.org/x/sync/errgroup
パッケージの使用を検討してください。このパッケージはWaitGroupの機能に加えて、エラー処理も組み込まれています:
func processWithErrorHandling(items []string) error {
g, ctx := errgroup.WithContext(context.Background())
for _, item := range items {
item := item // ループ変数のコピーを作成
g.Go(func() error {
return processItemWithError(ctx, item)
})
}
// すべてのゴルーチンが完了するまで待ち、最初のエラーを返す
return g.Wait()
}
これらの方法を実践することで、Go言語での並行プログラミングをより安全かつ効率的に行うことができます。