Day 032 — errgroup パッケージ

2026-08-29 🟡 上級者 / Phase 3 デバッグ・コードレビュー errgroup パッケージ

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

複数のgoroutineを起動し、そのうち1つでもエラーになったら全体を失敗として扱いたい、というケースは実務で非常に多いです(複数APIへの並列問い合わせ、複数ファイルの並列処理、複数DBクエリの並列実行など)。これをDay 012で学んだsync.WaitGroupだけで素朴に実装しようとすると、「エラーをどの変数に集約するか」「複数goroutineが同時にその変数へ書き込んだらどうするか」「1つが失敗した時点で残りのgoroutineを打ち切れないか」という3つの問題に必ずぶつかります。

golang.org/x/sync/errgroupは、Goチーム自身がメンテナンスする準標準パッケージで、この3つの問題をまとめて解決します。中心となるのは2つのAPIだけです。

  • g, ctx := errgroup.WithContext(parent): *errgroup.Groupと、parentから派生したcontext.Contextを返す。このctxは、グループ内のどれか1つのgoroutineがエラーを返した瞬間に自動的にキャンセルされる
  • g.Go(func() error { ... }): 新しいgoroutineを起動する。複数のgoroutineが同時にエラーを返しても、内部で最初の1つだけが安全に保持される(呼び出し側がmutexを書く必要はない)
  • g.Wait(): sync.WaitGroup.Wait()と同様に全goroutineの終了を待つが、戻り値として保持された最初のエラー(またはnil)を返す

sync.WaitGroupが「待ち合わせ」だけを担当するのに対し、errgroup.Groupは「待ち合わせ + エラー集約 + キャンセル伝播」の3点セットを1つの型に凝縮しています。ただし、ctxがキャンセルされても、各goroutine自身がselect { case <-ctx.Done(): ... }のように能動的にチェックしなければ実際には止まらない、という点はDay 031のPipelineパターンと共通する設計です。

📝 問題

以下は、複数の商品IDについて価格を並列取得するfetchAll関数です。一見動きそうに見えますが、複数の欠陥を含んでいます。

package main

import (
	"fmt"
	"sync"
	"time"
)

type fetchResult struct {
	name  string
	price int
}

// fetchPrice は外部APIをシミュレートする。idが3の場合だけ失敗させる。
// 取得にかかる時間はidに比例するものとする(id=5が最も遅い)。
func fetchPrice(id int) (fetchResult, error) {
	time.Sleep(time.Duration(id) * 100 * time.Millisecond)
	if id == 3 {
		return fetchResult{}, fmt.Errorf("item %d: fetch failed", id)
	}
	return fetchResult{name: fmt.Sprintf("item-%d", id), price: id * 100}, nil
}

func fetchAll(ids []int) ([]fetchResult, error) {
	var wg sync.WaitGroup
	var results []fetchResult
	var firstErr error

	for _, id := range ids {
		wg.Add(1)
		go func(id int) {
			defer wg.Done()
			r, err := fetchPrice(id)
			if err != nil {
				firstErr = err
				return
			}
			results = append(results, r)
		}(id)
	}
	wg.Wait()

	if firstErr != nil {
		return nil, firstErr
	}
	return results, nil
}

func main() {
	ids := []int{1, 2, 3, 4, 5}
	results, err := fetchAll(ids)
	if err != nil {
		fmt.Println("error:", err)
		return
	}
	fmt.Println(results)
}

設問

  1. このコードをgo run -race main.goで実行するとWARNING: DATA RACEが検出されます。競合している変数を2つ挙げ、それぞれなぜ競合が起きるのか説明してください
  2. データ競合を無視したとしても、このコードには「早期打ち切りの欠如」という設計上の無駄があります。ids := []int{1, 2, 3, 4, 5}の場合に何が無駄になるか、具体的な時間の目安とともに説明してください
  3. golang.org/x/sync/errgroupを使ってfetchAllを書き直し、上記2つの欠陥(データ競合・早期打ち切りの欠如)を両方解消してください。結果の順序は保持しなくてよいですが、どのIDの結果かは分かるようにすること

要件

  • fetchPriceはcontext.Contextを受け取れるように書き換え、キャンセル時には即座に処理を打ち切ってctx.Err()を返すこと(time.Sleepではなくselect { case <-time.After(d): case <-ctx.Done(): ... }を使う)
  • go.modにgolang.org/x/syncへの依存を追加し、go run .でそのまま実行できる完全なコードとして提示すること
  • 修正後のコードをgo run -race .で実行してもデータ競合が検出されないこと

🔍 ヒント(段階的開示)

ヒント1 — 方向性

バグは大きく分けて2種類あります。1つは「複数goroutineが同じ変数に同期なしで読み書きしている」というデータ競合。もう1つは「1つのgoroutineが失敗しても、他のgoroutineがそれを知る手段がない」という設計上の問題です。後者を解決するには、goroutine同士が「もう続行する必要がない」という合図を共有する仕組みが要ります。

ヒント2 — アプローチ
  • results = append(results, r)はスライスヘッダ(ポインタ・長さ・容量)への書き込みを伴うため、複数goroutineから同時に呼ぶと競合します。インデックスを固定した書き込み(results[i] = r)に変えれば、各goroutineが担当する要素が重ならない限り同期不要になります
  • firstErr = errも同様に複数goroutineからの同時書き込みで競合します。errgroup.Group.Goに渡す関数がerrorを返す仕組みに乗せれば、この変数自体が不要になります
  • 早期打ち切りにはerrgroup.WithContextが返すctxを各goroutineに渡し、fetchPrice内の待機処理をctx.Done()と競合するselectにします
ヒント3 — コード骨格
g, ctx := errgroup.WithContext(context.Background())
results := make([]fetchResult, len(ids)) // インデックス書き込み用に事前確保

for i, id := range ids {
	i, id := i, id // ループ変数をgoroutineごとに固定
	g.Go(func() error {
		r, err := fetchPrice(ctx, id)
		if err != nil {
			return err
		}
		results[i] = r
		return nil
	})
}

if err := g.Wait(); err != nil {
	return nil, err
}
return results, nil

fetchPrice(ctx, id)の内部ではtime.Sleepの代わりにselect { case <-time.After(d): case <-ctx.Done(): return fetchResult{}, ctx.Err() }を使うことで、ctxがキャンセルされた瞬間に待機を打ち切れるようにします。

✅ 模範解答

ファイル構成: day032/go.mod + day032/main.go

// go.mod
module example.com/day032

go 1.22

require golang.org/x/sync v0.8.0
package main

import (
	"context"
	"fmt"
	"time"

	"golang.org/x/sync/errgroup"
)

type fetchResult struct {
	name  string
	price int
}

// fetchPrice は外部APIをシミュレートする。idが3の場合だけ失敗させる。
// time.Sleepの代わりにselectを使い、ctxのキャンセルで即座に打ち切れるようにしている。
func fetchPrice(ctx context.Context, id int) (fetchResult, error) {
	delay := time.Duration(id) * 100 * time.Millisecond
	select {
	case <-time.After(delay):
	case <-ctx.Done():
		return fetchResult{}, ctx.Err()
	}
	if id == 3 {
		return fetchResult{}, fmt.Errorf("item %d: fetch failed", id)
	}
	return fetchResult{name: fmt.Sprintf("item-%d", id), price: id * 100}, nil
}

// fetchAll はerrgroupを使い、データ競合なくエラー集約と早期打ち切りを両立する。
func fetchAll(ids []int) ([]fetchResult, error) {
	g, ctx := errgroup.WithContext(context.Background())
	results := make([]fetchResult, len(ids)) // 各goroutineが担当indexだけ書くのでmutex不要

	for i, id := range ids {
		i, id := i, id // ループ変数をgoroutineローカルに固定
		g.Go(func() error {
			r, err := fetchPrice(ctx, id)
			if err != nil {
				return fmt.Errorf("id=%d: %w", id, err)
			}
			results[i] = r
			return nil
		})
	}

	if err := g.Wait(); err != nil {
		return nil, err
	}
	return results, nil
}

func main() {
	start := time.Now()
	ids := []int{1, 2, 3, 4, 5}
	results, err := fetchAll(ids)
	elapsed := time.Since(start)

	if err != nil {
		fmt.Printf("error: %v (elapsed=%s)\n", err, elapsed)
		return
	}
	fmt.Printf("results=%v (elapsed=%s)\n", results, elapsed)
}
▶ セットアップと実行手順を見る
mkdir day032 && cd day032
go mod init example.com/day032
go get golang.org/x/sync@v0.8.0
# main.go を上記の内容で保存
go run .
go run -race .   # データ競合が検出されないことを確認

go.modにgolang.org/x/syncへの依存を追加し、go run .でそのまま実行できる完全なコードとして提示済みです。

🪜 Step-by-Step 解説

1
データ競合の原因を特定する
元のコードではresults = append(results, r)とfirstErr = errの2箇所が、複数のgoroutineから同期なしに読み書きされていました。appendはスライスヘッダ(内部的にはポインタ・長さ・容量の3つ組)の再代入を伴うため、2つのgoroutineが同時に実行すると片方の書き込みが失われたり、-race検出器が警告を出します。firstErrも同様に、複数goroutineが同時に同じ変数へ書き込む古典的な競合です。
2
競合をerrgroupのAPIに委譲する
errgroup.Group.Go(func() error)に渡す関数の戻り値がそのままエラー集約の役割を果たすため、firstErrという共有変数自体が不要になります。errgroupの内部ではsync.Once相当の仕組みで最初のエラーだけを安全に保持しており、呼び出し側がmutexを書く必要はありません。
3
スライスの競合をインデックス固定書き込みで解消する
appendの代わりにresults := make([]fetchResult, len(ids))で事前にサイズを確保し、各goroutineが自分の担当indexにだけ書き込む形にします。これにより、異なるgoroutineが異なるメモリ領域(スライスの異なる要素)に書き込むことが保証され、同期なしでも安全になります。ただしスライスヘッダ自体(results変数)はメイン側で一度だけ確保し、goroutine側は要素の中身だけを変更する点が重要です。
4
errgroup.WithContext で早期打ち切りを実現する
errgroup.WithContext(context.Background())が返すctxは、いずれかのg.Goに渡した関数がエラーを返した瞬間にキャンセルされます。これをfetchPriceに渡し、内部の待機をselect { case <-time.After(d): case <-ctx.Done(): ... }にすることで、id=3の失敗が判明した時点でid=4・id=5の待機中のselectが即座にctx.Done()分岐へ落ち、無駄な待機を打ち切れます。

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

📌
標準ライブラリは小さく保つ、の延長線上: errgroupが標準ライブラリではなくgolang.org/x/syncという準標準パッケージに置かれているのは、Goの「標準ライブラリは小さく保つ」という設計判断の延長線上にあります。sync.WaitGroupはあくまで「待ち合わせ」という最小限のプリミティブを提供するだけで、「エラー集約」や「キャンセル伝播」という一段上の関心事は、それを必要とするコードだけが明示的に取り込む——という役割分担です。
📌
新しい魔法ではなく既存プリミティブの組み合わせ: errgroupの内部実装はsync.WaitGroup + sync.Once + context.Contextという、これまでのDayで学んできたプリミティブの組み合わせに過ぎません。Goの並行処理ライブラリの多くが「新しい魔法」ではなく「既存のchannel・context・syncプリミティブを定石通りに組み合わせたユーティリティ」である、という点を体感するのが本問の狙いの一つです。

🌐 他言語との比較

言語同等の概念Goとの違い
JavaExecutorCompletionService + CompletableFuture.allOf(...).exceptionally(...)Javaは例外機構で失敗を伝搬でき、CompletableFutureが結果とエラーの両方を1つのオブジェクトに包む。Goはerrgroupで「エラーは戻り値、キャンセルはcontext」という2つの独立した明示的チャネルに分けて扱う
Pythonasyncio.TaskGroup(Python 3.11+)TaskGroupはerrgroupとほぼ同じ思想(1つのタスクが例外を投げると他のタスクもキャンセルされる)を持つが、Pythonは例外機構ベース、Goはエラー値+ctx.Done()という明示的なポーリングベースで実現する
TypeScript / Node.jsPromise.all / Promise.allSettledPromise.allは最初のrejectで即座に他の結果を待たずreject(ただし裏のPromiseの実行自体はキャンセルされない)。Goのerrgroupはctx経由で他のgoroutineに実際の処理停止を明示的に伝える点が異なる

🏆 実務での使いどころ

  • 複数外部API/DBへの並列問い合わせ: ユーザープロフィールAPI・注文履歴API・レコメンドAPIを並列取得し、1つでも失敗したら残りを打ち切って早くエラーレスポンスを返す
  • バッチ処理の並列ファイル処理: 複数ファイルを並列で読み込み・変換する際、1ファイルの破損を検知したら残りの処理を打ち切ってリソースを節約する
  • マイグレーション・一括更新処理: 複数テーブル/シャードへの並列書き込みで、1つが失敗した時点で残りをキャンセルし、部分的な不整合を最小化する
  • ヘルスチェックの並列実行: 複数の依存サービスへ並列にヘルスチェックを飛ばし、最初の異常検知で残りをキャンセルしてレイテンシを抑える

⚠️ よくある誤解・ミス

誤解・ミスなぜ起こるか正しい理解
errgroupを使えばctxを意識しなくても自動でgoroutineが止まると思うWithContextという名前から「勝手にキャンセルしてくれる」印象を持つerrgroupが保証するのは「ctxをDone状態にすること」だけ。各goroutine自身がselectやctx.Err()チェックで能動的に気づかない限り、処理は止まらない
results = append(results, r)をそのまま複数goroutineから呼んでも「要素が違うから大丈夫」と思い込むスライスの要素単位のイメージで考えてしまうappendはスライスヘッダ(results変数自体)への再代入を伴うため、要素の中身ではなくヘッダの競合が起きる。インデックス固定書き込みかmutexが必要
g.Goに渡す関数の中でpanicしてもg.Wait()がエラーとして拾ってくれると思うerrorを返す関数だから例外もキャッチされると誤解するpanicはerrorの戻り値とは別物で、errgroupは自動でrecoverしない。goroutine内のpanicはプロセス全体をクラッシュさせるため、必要なら自前でrecoverを書く
ループ変数をi, id := i, idのように固定するのを省略してよいと思う(Go 1.22以降だから)Go 1.22でループ変数のスコープがイテレーションごとに変わる仕様変更を知っている仕様変更自体は正しいが、go.modのgoディレクティブが1.22未満のモジュールでビルドされる可能性がある現場では、明示的な固定が事故を防ぐ安全な書き方として今も広く使われている
errgroup.GroupのSetLimitの存在を知らず、常に無制限にgoroutineを起動してしまうg.Goを呼べば呼ぶほどgoroutineが増えることに無頓着になるerrgroup.GroupにはSetLimit(n)で同時実行数を制限する機能があり、大量のタスクを扱う際はWorker Pool的な制御と組み合わせるのが定石

🚀 次のステップ

  • 発展: g.SetLimit(n)を使い、idsが数百件ある場合でも同時実行数を制限しながらerrgroupでエラー集約・早期打ち切りを両立する版を実装してみましょう
  • 次回予告: インターフェース設計の原則(小さいインターフェース)— 「Accept interfaces, return structs」の実践を、これまで学んだ並行処理コードのテスト容易性向上と絡めて学びます

🎯 自己評価

自分の回答

気づき・メモ