sync.WaitGroup、Mutex、errgroup:Goでゴルーチンを調整する
1. 概要
Goでゴルーチンを実行するのは簡単で、go キーワードを追加するだけです。より難しいのは、ゴルーチンがいつ完了したかを知る、共有データを保護する、そしてゴルーチンが失敗した場合にエラーを処理することです。
この記事では、これら3つのタスク用の3つのツール:sync.WaitGroup、sync.Mutex、errgroup.Group を紹介します。読み終わったら、どの状況でどのツールを選ぶべきか、-race を使ってテストを実行する方法、そして避けるべき一般的なエラーが分かります。
例ではGo 1.22以上を使用しています。このバージョンから、各 for ループの反復ごとに新しい変数が作成されるため、id := id を書く必要がなくなりました。
2. サンプル問題
注文コード(Order)のリストがあるとします。各注文は遅いサービスを呼び出す必要があり、約100msかかります。10個の注文を順番に呼び出すと、約1秒かかります。並行に呼び出すと、約100msだけかかります。
これはサービス呼び出しをシミュレートする関数です:
package main
import (
"fmt"
"time"
)
func fetchOrder(id int) string {
time.Sleep(100 * time.Millisecond)
return fmt.Sprintf("order-%d", id)
}
ループ内で go fetchOrder(id) と書くだけの場合、main 関数はゴルーチンが完了する前に終了します。プログラムは終了し、結果が失われます。次のセクションでこの問題に対処します。
3. sync.WaitGroup:ゴルーチンのグループを待つ
3.1. 基本的な使い方
WaitGroup はカウンタです。Add でカウンタを増やし、Done で減らし、Wait はカウンタが0になるまでブロックします。
func main() {
var wg sync.WaitGroup
ids := []int{1, 2, 3}
for _, id := range ids {
wg.Add(1)
go func() {
defer wg.Done()
fmt.Println("fetched", fetchOrder(id))
}()
}
wg.Wait()
fmt.Println("all done")
}
上記のコードは次のように動作します:
wg.Add(1)はゴルーチンを開始する前に、親ゴルーチン内で呼ばれます。defer wg.Done()はゴルーチンが早期に終了したりpanicしても、カウンタが必ず減ることを保証します。wg.Wait()は3つすべてのゴルーチンがDoneを呼ぶまで、mainをブロックします。
結果は次のようになります。fetched 行の順序は実行ごとに変わります:
fetched order-2
fetched order-1
fetched order-3
all done
3.2. Go 1.25のWaitGroup.Go
Go 1.25から、WaitGroup に Go メソッドが追加されました。このメソッドは Add、go、Done を1つの呼び出しに統合します:
var wg sync.WaitGroup
for _, id := range ids {
wg.Go(func() {
fmt.Println("fetched", fetchOrder(id))
})
}
wg.Wait()
こう書く方が短く、2つの一般的なエラーを避けられます:Done の呼び忘れ、または Add をゴルーチン内で呼ぶこと。
3.3. ロックなしに結果を収集する
通常、結果を保持する必要があります。画面に出力するだけではなく。安全な方法は、あらかじめ長さを持つsliceを作成し、各ゴルーチンに独自のインデックスに書き込ませることです:
results := make([]string, len(ids))
var wg sync.WaitGroup
for i, id := range ids {
wg.Go(func() {
results[i] = fetchOrder(id)
})
}
wg.Wait()
fmt.Println(results)
[order-1 order-2 order-3]
ゴルーチンの中で同じ要素に書き込むことはないため、ここではデータレースがありません。wg.Wait() は happens-before の関係も作成します。したがって、Wait の後、親ゴルーチンは子ゴルーチンが書き込んだ全データを読むことができます。
注意: ゴルーチン内で
wg.Add(1)を呼ばないでください。そうすると、Wait()はAddの前に実行され、作業が完了する前に返る可能性があります。
4. sync.Mutex:共有データを保護する
4.1. データレースの見え方
今、顧客(Customer)ごとの売上を累積し、複数のゴルーチンが同じmapを更新する必要があるとします:
revenue := map[string]float64{}
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
revenue["alice"] += 10
})
}
wg.Wait()
このプログラムは次のエラーでクラッシュする可能性があります:
fatal error: concurrent map writes
単純な int 変数を使う場合はさらに危険です。プログラムはクラッシュしませんが、例えば10000の代わりに9730のように、静かに間違った結果を出力します。この種のエラーを検出するには、race detectorで実行します:
go run -race main.go
WARNING: DATA RACE
Write at 0x00c000012345 by goroutine 8:
...
4.2. Mutexで修正
sync.Mutex は、各時点で1つのゴルーチンだけが Lock と Unlock の間にあるコードを実行できることを保証します。一般的な方法は、mutexを保護する同じデータと共に構造体にバンドルすることです:
package shop
import "sync"
type RevenueTracker struct {
mu sync.Mutex
total map[string]float64
}
func NewRevenueTracker() *RevenueTracker {
return &RevenueTracker{total: make(map[string]float64)}
}
func (r *RevenueTracker) Add(customer string, amount float64) {
r.mu.Lock()
defer r.mu.Unlock()
r.total[customer] += amount
}
func (r *RevenueTracker) Total(customer string) float64 {
r.mu.Lock()
defer r.mu.Unlock()
return r.total[customer]
}
注意すべき点がいくつかあります:
totalへのすべてのアクセス、読み込みでさえ、ロックを通す必要があります。defer r.mu.Unlock()は、関数が複数のreturn分岐を持つ場合でも、ロックが必ず解放されることを保証します。- メソッドはポインタレシーバを使用します(
*RevenueTracker)。値レシーバを使用すると、各呼び出しでmutex全体がコピーされ、そのロックは何も保護しなくなります。
読み取りが書き込みより圧倒的に多い場合、sync.RWMutex を使用できます。読み込みメソッドは RLock/RUnlock を呼び、書き込みメソッドは Lock/Unlock を呼びます。複数のゴルーチンは同時に読み取ることができ、書き込み時は1つのゴルーチンだけロックを保持します。
注意: 使用した後、
Mutexを含む構造体をコピーしないでください。go vetはこのケースに対してcopylocksメッセージで警告します。また、Goには再入可能ロックがありません。ゴルーチンが同じmutexでLock()を2回呼び出すと、デッドロックします。
4.3. Mutexが不要な場合
単純なカウンタの場合、sync/atomic の方がコンパクトで高速です:
var processed atomic.Int64
processed.Add(1)
fmt.Println(processed.Load())
データがゴルーチンからゴルーチンへストリーム形式で流れる必要がある場合、channelの方が一般的です。Mutexは、mapや構造体のような共有状態があり、複数のゴルーチンが読み書きする必要がある場合に最適です。
5. errgroup:エラー処理を含むWaitGroup
WaitGroup はエラーについて何も知りません。fetchOrder がエラーを返す可能性がある場合、mutexまたはchannelを使ってエラーを自分で集約し、残りのゴルーチンを自分でキャンセルする必要があります。errgroup パッケージはこれらを事前に処理します。
5.1. 依存関係を追加
errgroup は golang.org/x/sync モジュール内にあり、標準ライブラリの一部ではありません:
go get golang.org/x/sync
5.2. エラーを集約してcontextでキャンセル
最初のステップは、fetchOrder を書き直して関数が context.Context を受け取り、エラーを返すことができるようにすることです:
package shop
import (
"context"
"fmt"
"time"
"golang.org/x/sync/errgroup"
)
type Order struct {
ID int
Customer string
}
func fetchOrder(ctx context.Context, id int) (Order, error) {
if id < 0 {
return Order{}, fmt.Errorf("invalid order id %d", id)
}
select {
case <-time.After(100 * time.Millisecond):
return Order{ID: id, Customer: "alice"}, nil
case <-ctx.Done():
return Order{}, ctx.Err()
}
}
func LoadOrders(ctx context.Context, ids []int) ([]Order, error) {
g, ctx := errgroup.WithContext(ctx)
g.SetLimit(3)
orders := make([]Order, len(ids))
for i, id := range ids {
g.Go(func() error {
o, err := fetchOrder(ctx, id)
if err != nil {
return err
}
orders[i] = o
return nil
})
}
if err := g.Wait(); err != nil {
return nil, err
}
return orders, nil
}
上記のコードは次のように動作します:
errgroup.WithContext(ctx)はグループとチャイルドcontextを作成します。最初のゴルーチンがエラーを返すと、チャイルドcontextはすぐにキャンセルされます。g.SetLimit(3)は最大3つのゴルーチンが同時に実行されることを許可します。次のg.Go呼び出しはスペースが利用可能になるまでブロックします。これにより、リストに数千の注文コードがある場合、バックエンドサービスに過負荷をかけません。g.Goはfunc() error型の関数を受け取り、Add/Doneを呼ぶ必要がありません。g.Wait()はすべてのゴルーチンが終了するのを待ってから、最初のエラーを返します。その後に発生するエラーは無視されます。fetchOrderはctx.Done()をリッスンするため、残りのゴルーチンは100ms全体を実行する代わりに早期に停止します。
無効な注文コードで関数を呼び出します:
_, err := shop.LoadOrders(context.Background(), []int{1, 2, -5, 4})
fmt.Println(err)
invalid order id -5
制限に達したときにブロックせずに実行してみたい場合は、g.TryGo(f) を使用します。この関数は待つ代わりに false を返します。
注意: contextのキャンセルは、ゴルーチン内のコードが 主動的にチェックする場合にのみ有効です。
fetchOrderがtime.Sleepを使用し、contextをリッスンしない場合、グループが失敗した場合でも、ゴルーチンは完全な時間を実行します。
6. -raceでテストを書く
Goには相当するJUnitやAssertJがありません。標準ライブラリの testing パッケージを使用します。最も重要なことは、常に -race フラグでテストを実行することです。多くのconcurrencyバグはrace detectorがある場合にのみ表示されるためです。
package shop
import (
"context"
"sync"
"testing"
)
func TestRevenueTracker_ConcurrentAdd(t *testing.T) {
tracker := NewRevenueTracker()
var wg sync.WaitGroup
for i := 0; i < 1000; i++ {
wg.Go(func() {
tracker.Add("alice", 10)
})
}
wg.Wait()
if got := tracker.Total("alice"); got != 10000 {
t.Fatalf("Total(alice) = %v, want 10000", got)
}
}
func TestLoadOrders_ReturnsAllOrders(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, 2, 3})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
for i, o := range orders {
if o.ID != i+1 {
t.Errorf("orders[%d].ID = %d, want %d", i, o.ID, i+1)
}
}
}
func TestLoadOrders_StopsOnFirstError(t *testing.T) {
orders, err := LoadOrders(context.Background(), []int{1, -1, 3})
if err == nil {
t.Fatal("expected error, got nil")
}
if orders != nil {
t.Errorf("expected nil orders, got %v", orders)
}
}
テストを実行します:
go test -race ./...
ok example.com/shop 0.412s
Add 内の Lock/Unlock の2行を削除してから再度実行してみてください。テストは WARNING: DATA RACE で失敗するか、concurrent map writes のためにクラッシュします。これは、mutexが本当に必要であることを確認する高速な方法です。
2番目のテストは、結果の順序が正しく保持されていることも確認します:orders[i] は ids[i] と一致します。ゴルーチンはどの順序でも終了する可能性があります。
7. 一般的なエラーとツール選択
最も一般的なエラーは次のとおりです:
- ゴルーチン内で
Addを呼ぶ。Waitが早期に返される可能性があります。goコマンドの前にAddを呼ぶか、wg.Goを使用します。 WaitGroupまたはMutexを値で渡す。 関数は受け取ったコピーを使用し、元のカウンタまたはロックは変わりません。ポインタ*sync.WaitGroupを渡すか、closureを使用します。- I/O呼び出し中にロックを保持する。 例えば、
Lock内でHTTPを呼び出します。他のすべてのゴルーチンはキューに入る必要があり、プログラムは並行実行の利点を失います。ロックは最短のコード部分でのみ保持します。 - 空の
errgroup.Groupを使用(var g errgroup.Group)しながら、contextがキャンセルされることを期待する。WithContextだけがキャンセル可能なcontextを作成します。 g.Wait()の戻り値を無視する。 その場合、エラーは誰にも知られずに消えます。- 数十万のリクエストに対して無制限のゴルーチンを起動する。
SetLimitを使用するか、ワーカープールを使用します。
ツール選択ガイド:
| 状況 | ツール |
|---|---|
| エラーを返さない複数のタスクを待つ | sync.WaitGroup |
| 複数のゴルーチンが共有mapまたは構造体を変更する | sync.Mutex / sync.RWMutex |
| シンプルなカウンタまたはフラグ | sync/atomic |
| 失敗する可能性のあるタスク、早期キャンセル、ゴルーチン数制限 | errgroup |
注意:
errgroupは最初のエラーだけを保持します。すべてのエラーを知る必要がある場合、例えば失敗した注文を報告するには、各ゴルーチンにエラーをerrs[i]に書き込ませ、Wait後にerrors.Join(errs...)で統合します。
8. まとめ
sync.WaitGroup は「いつ完了するか」という質問に答えます。sync.Mutex は「今、誰がデータを変更できるか」という質問に答えます。errgroup は待機とエラー処理を統合し、contextでキャンセルし、同時に実行するゴルーチン数を制限します。
実際のコードでは、複数のサービスを並行に呼び出す場合、errgroup.WithContext に SetLimit を組み合わせるのが通常、合理的なデフォルト選択です。どのツールを使用する場合でも、常に go test -race を実行してください。これは、本番環境に到達する前にデータレースを検出する最もコスト効率的な方法です。