3つのAPIを並列に叩いて、1本でも失敗したら残りを止めたい。sync.WaitGroup でやると、エラーの受け渡しと中断処理を自分で組むことになります。errgroup はその2つを1つの型に畳み込みます。
WaitGroupで複数goroutineのエラーを拾う面倒さ
並列処理の素朴な出発点は sync.WaitGroup です。ただ、各goroutineが返すエラーを集めようとした瞬間に話がややこしくなります。
共有変数とMutexが増えていく
WaitGroupはカウンタしか持ちません。エラーを外へ渡す口が無い。だから共有変数とロックを足すことになります。
func fetch(url string) error {
resp, err := http.Get(url)
if err != nil {
return err
}
resp.Body.Close()
return nil
}
func main() {
urls := []string{"https://example.com", "https://bad.invalid"}
var (
wg sync.WaitGroup
mu sync.Mutex
firstErr error
)
for _, url := range urls {
wg.Go(func() { // Go 1.25 の WaitGroup.Go
if err := fetch(url); err != nil {
mu.Lock()
if firstErr == nil {
firstErr = err
}
mu.Unlock()
}
})
}
wg.Wait()
if firstErr != nil {
fmt.Println("failed:", firstErr)
}
}
実行結果。
failed: Get "https://bad.invalid": dial tcp: lookup bad.invalid: no such host
エラーを1つ保持するだけで Mutex と firstErr の番をすることになります。「最初のエラーが出たら他を止める」を足すと、ここに context と cancel がさらに乗ってきます。
Go 1.25で起動は楽になったが集約は別問題
上のコードで使った wg.Go は Go 1.25 で入った新メソッドです。リリースノートはこう書いています。
The new WaitGroup.Go method makes the common pattern of creating and counting goroutines more convenient.
Add(1) と defer Done() が消えて起動は短くなりました。ただ、エラーの集約と中断は WaitGroup の守備範囲の外。そこを引き取るのが errgroup です。
errgroupで2つのHTTPフェッチを並列化する
最小の形から。golang.org/x/sync/errgroup はゼロ値の Group がそのまま使えます。
package main
import (
"fmt"
"net/http"
"golang.org/x/sync/errgroup"
)
func main() {
urls := []string{"https://example.com", "https://pkg.go.dev"}
var g errgroup.Group
for _, url := range urls {
g.Go(func() error {
resp, err := http.Get(url)
if err != nil {
return err
}
defer resp.Body.Close()
fmt.Printf("%s -> %d\n", url, resp.StatusCode)
return nil
})
}
if err := g.Wait(); err != nil {
fmt.Println("failed:", err)
return
}
fmt.Println("all ok")
}
実行結果。出力順はgoroutineのスケジューリング次第で入れ替わります。
https://pkg.go.dev -> 200
https://example.com -> 200
all ok
Goメソッドにfunc() errorを渡すだけ
g.Go は func() error を取ります。WaitGroupのときの Mutex も firstErr も要りません。エラーは戻り値で返すだけ。ループ変数を退避する url := url も Go 1.22 以降は不要です。各反復で別変数になります。
Waitが最初のエラーを返す
g.Wait() は全goroutineの終了を待ち、最初に返った non-nil エラーを1つ返します。2本目以降のエラーは捨てられる。複数のエラーを全部見たいなら、自前でスライスに集める作りが要ります。
WithContextは最初のエラーで残りを止める
WithContext を使うと挙動が変わります。1本がエラーを返した瞬間に、派生 Context がキャンセルされます。
func main() {
g, ctx := errgroup.WithContext(context.Background())
// 30msで失敗するワーカー
g.Go(func() error {
time.Sleep(30 * time.Millisecond)
return errors.New("query failed")
})
// 本来1秒かかるワーカー。ctxの中断を見ている
g.Go(func() error {
select {
case <-time.After(1 * time.Second):
return nil
case <-ctx.Done():
return ctx.Err()
}
})
start := time.Now()
err := g.Wait()
fmt.Printf("waited %v, err: %v\n", time.Since(start).Round(time.Millisecond), err)
}
実行結果。
waited 30ms, err: query failed
2本目は1秒待つはずでした。が、1本目が30msで失敗して ctx がキャンセルされ、ctx.Done() が発火して即座に抜けます。1秒待たずに 30ms で Wait が返りました。
キャンセルのタイミングは公式が明記している
派生 Context がいつキャンセルされるかは、WithContext のドキュメントにそのまま書いてあります。
The derived Context is canceled the first time a function passed to Go returns a non-nil error or the first time Wait returns, whichever occurs first.
キャンセルされるのは「最初のエラー」か「Waitの完了」の早い方。だから各ワーカーは ctx を受け取り、ctx.Done() を見る作りにしておく必要があります。見ていないワーカーは中断されず、最後まで走り切ります。
ctxを実際のI/Oに渡さないと中断は効かない
ここを踏みやすい。WithContext が返す ctx は、各ワーカーが実際のI/Oに渡して初めて効きます。前の最小例の http.Get のままでは ctx を見ないので、グループがキャンセルされても通信は走り続けます。
g.Go(func() error {
req, _ := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err // ctxキャンセル時は context.Canceled が返る
}
defer resp.Body.Close()
return nil
})
他のワーカーが先に失敗したときの実行結果。
failed: Get "https://example.com": context canceled
DBドライバなら QueryContext、自前のループなら ctx.Done() の監視。ctx を末端まで配るのが前提です。配り忘れると WithContext は中断の口だけ作って実際には何も止めません。
Waitが返すのは最初のエラーだけ
上の出力で err は query failed でした。2本目が返した ctx.Err()(context.Canceled)ではありません。Wait が返すのは時系列で最初の non-nil エラー。中断側のエラーで上書きされることはない。
SetLimitで同時実行数に上限をかける
1万件を一気に Go へ渡すと、goroutineが1万本立ちます。SetLimit で同時数に天井を付けられます。
var g errgroup.Group
g.SetLimit(3) // 同時に走るのは最大3本
for i := 0; i < 9; i++ {
id := i
g.Go(func() error {
fmt.Printf("start %d\n", id)
time.Sleep(100 * time.Millisecond)
return nil
})
}
g.Wait()
実行結果。3本ずつ約100ms間隔で動きます(各バッチ内の順序は不定)。
start 0
start 1
start 2
(約100ms後)
start 3
start 4
start 5
(約100ms後)
start 6
start 7
start 8
SetLimit に負数を渡すと無制限、0 なら全goroutineがブロックされます。制限はgoroutineが動いている間に変えてはいけません。空きが無いとき即座に false を返す TryGo も用意されています。
errgroupとWaitGroupはどちらを使うか
起動の手軽さだけなら Go 1.25 の WaitGroup.Go でも足ります。差が出るのはエラーと中断の扱い。
| 観点 | sync.WaitGroup (Go 1.25) | errgroup |
|---|---|---|
| goroutineの起動 | wg.Go(func(){...}) | g.Go(func() error {...}) |
| エラーの集約 | 共有変数+Mutexを自前で | Wait が最初のnon-nilを返す |
| 最初のエラーで中断 | context/channelを自前で | WithContext で自動 |
| 同時実行数の制限 | セマフォを自前で | SetLimit |
| panicの回復 | しない | しない(v0.20.0時点) |
エラーの集約も中断も要らない「ただ待つだけ」なら WaitGroup.Go で十分です。1つでも失敗したら全部畳みたい、同時数を絞りたい。そこが要件に入った時点で errgroup に寄せると、自前のロックとキャンセル配線を消せます。
panicはerrgroupが素通しする
panicは別扱いです。errgroup はワーカー内のpanicを回復しません。1本がpanicするとプロセスごと落ちます。
var g errgroup.Group
g.Go(func() error {
var m map[string]int
m["x"] = 1 // nil mapへの書き込み -> panic
return nil
})
if err := g.Wait(); err != nil {
fmt.Println("failed:", err) // ここには到達しない
}
実行結果。
panic: assignment to entry in nil map
goroutine 6 [running]:
main.main.func1(...)
/path/to/main.go:11
exit status 2
Wait の err は受け取れません。recover() がどこにも無いので、panicはそのままランタイムへ抜けます。並列バッチの1本がnil参照でpanicし、Wait のエラーログだけ眺めて原因にたどり着けなかったことがあります。落ちていたのは Wait の手前でした。
ワーカー側でrecoverしてerrorに変える
panicをエラーとして扱いたいなら、ワーカーの中で recover し、名前付き戻り値に詰めます。
g.Go(func() (err error) {
defer func() {
if r := recover(); r != nil {
err = fmt.Errorf("recovered: %v", r)
}
}()
var m map[string]int
m["x"] = 1
return nil
})
実行結果。
failed: recovered: assignment to entry in nil map
これでpanicが Wait のエラーに変わり、WithContext 経由なら他のワーカーも中断されます。
panic伝播の提案は受理済みだが未リリース
この挙動を変える提案が動いています。panicを Wait 側へ伝播させる golang/go#53757 が受理済みで、PanicValue / PanicError 型で Wait がpanicを再送する設計です。ただし現行の golang.org/x/sync/errgroup v0.20.0 には未反映。今のところワーカー側の recover が要ります。
まとめ
errgroupは WaitGroup にエラー集約とキャンセルを足した薄いラッパーです。要点を並べます。
g.Go(func() error)でエラーは戻り値。共有変数とMutexが要らないWaitが返すのは時系列で最初の non-nil エラー1つ。残りは捨てられるWithContextの派生Contextは「最初のエラー」か「Wait完了」の早い方でキャンセルされるSetLimitで同時実行数に上限。負数で無制限、0で全停止- panicは回復されず(v0.20.0時点)プロセスが落ちる。ワーカー内の
recoverでerrorに変える - 起動の手軽さだけなら Go 1.25 の
WaitGroup.Goで足りる。集約と中断が要るなら errgroup
並行処理の基礎はGo言語の並行処理入門:goroutineとchannelの使い方を完全解説で、起動したまま終わらないケースはGoのgoroutineリーク対策で扱っています。

