📚 背景知識(読んでから問題へ)
Day 032ではerrgroup.Groupを使い、複数goroutineの「エラー集約」と「早期打ち切り」を同時に実現しました。しかし、そこで書いたfetchAllにはもう1つの弱点が残っています。idsが5件ではなく1000件だったらどうなるでしょうか——forループが1000回まわり、g.Goが1000個のgoroutineを一気に起動してしまいます。
実務で外部APIを呼ぶ場合、多くのサービスにはレートリミット(例: 同時10リクエストまで)が設定されています。1000件を一度に投げつければ、レートリミットエラーの連発か、最悪サービス側から一時的にブロックされます。この「同時に走らせてよい数の上限」を制御する仕組みがerrgroup.Group.SetLimit(n)です。
SetLimit(n)を呼ぶと、そのグループで同時にアクティブになれるgoroutineの数が最大nに制限されます。nを超えるタイミングで呼ばれたg.Go(f)は、内部でブロックし、既存のgoroutineのどれかが終了して枠が空くまで新しいgoroutineを起動しません。つまりg.Goは「必ず即座にgoroutineを起動する」わけではなく、「上限内に収まるまで待ってから起動する」という、Worker Poolに似た挙動に変わります。
重要なのは、SetLimitが実現しているのは同時実行数の制御だけだという点です。エラー集約・早期打ち切り(ctxのキャンセル伝播)は、Day 032で学んだerrgroup本来の機能がそのまま生きています。つまりerrgroupは「待ち合わせ」「エラー集約」「キャンセル伝播」「同時実行数制御」という4つの関心事を、1つの小さな型に段階的に積み上げて提供しています。
📝 問題
Day 032のfetchAllを拡張し、以下の要件を満たすfetchAllLimitedを実装してください。
要件
- IDのリスト(100件程度を想定)を受け取り、
fetchPrice(ctx, id)を並列に呼び出して結果を集める errgroup.Group.SetLimit(limit)を使い、同時にアクティブなgoroutine数がlimitを超えないようにすること- Day 032と同様、どれか1つが失敗したら残りは早期打ち切りされること(
ctxのキャンセル伝播を維持する) - 結果はID順のスライスとして返す(インデックス固定書き込みで、Day 032同様データ競合がないこと)
- 同時実行数が実際に
limitを超えていないことを、コード自身で検証できるようにすること(atomicパッケージでアクティブ数をカウントし、最大値を記録・出力する)
制約・ヒント条件
limitに0を渡すとどうなるか、その挙動をコメントで説明すること(実際に試す必要はなく、ドキュメントの記述から挙動を導く)SetLimitを呼ぶタイミング(g.Goを1回でも呼んだ後にSetLimitを呼ぶとどうなるか)についても、模範解答のコード上で正しい順序を守ること
🔍 ヒント(段階的開示)
ヒント1 — 方向性
errgroup.Groupに「今アクティブなgoroutineが何個あるか」を外から見る手段はありません。だから「同時実行数がlimitを超えていないか」を検証したいなら、自分でカウンタを持つしかありません。goroutine開始時にインクリメント、終了時にデクリメントする、「増減する共有カウンタ」は複数goroutineから同時に触るので同期が必要な危険地帯です。
ヒント2 — アプローチ
- カウンタの増減には
sync/atomicのatomic.Int32(Go 1.19+の型付きAPI)を使うと、mutexを書かずに安全にインクリメント・デクリメントできます - 「これまでの最大値」を記録するには、単純な代入では競合するため
atomic.Int32のCompareAndSwapでループさせるか、sync.Mutexで最大値更新だけを保護します SetLimitはg.Goを1回でも呼ぶ前に設定しなければなりません。公式ドキュメントには「SetLimitはグループ内のgoroutineがアクティブな間は変更してはならない」という制約が明記されています
ヒント3 — コード骨格
g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(limit) // g.Go を呼ぶ前に必ず設定する
var active atomic.Int32
var maxActive atomic.Int32
results := make([]fetchResult, len(ids))
for i, id := range ids {
i, id := i, id
g.Go(func() error {
cur := active.Add(1)
defer active.Add(-1)
// 最大値の更新(CompareAndSwapで競合なく更新する)
for {
m := maxActive.Load()
if cur <= m || maxActive.CompareAndSwap(m, cur) {
break
}
}
r, err := fetchPrice(ctx, id)
if err != nil {
return err
}
results[i] = r
return nil
})
}
err := g.Wait()
fmt.Println("max concurrent:", maxActive.Load()) // limit以下であることを確認
✅ 模範解答
ファイル構成: day033/go.mod + day033/main.go
// go.mod
module example.com/day033
go 1.22
require golang.org/x/sync v0.8.0
package main
import (
"context"
"fmt"
"sync/atomic"
"time"
"golang.org/x/sync/errgroup"
)
type fetchResult struct {
name string
price int
}
// fetchPrice は外部APIをシミュレートする。idが13の倍数の場合だけ失敗させる。
// 待機はselectで実装し、ctxのキャンセルで即座に打ち切れるようにしている。
func fetchPrice(ctx context.Context, id int) (fetchResult, error) {
select {
case <-time.After(20 * time.Millisecond):
case <-ctx.Done():
return fetchResult{}, ctx.Err()
}
if id%13 == 0 {
return fetchResult{}, fmt.Errorf("item %d: fetch failed", id)
}
return fetchResult{name: fmt.Sprintf("item-%d", id), price: id * 10}, nil
}
// fetchAllLimited はerrgroup.SetLimitで同時実行数をlimitに制限しつつ、
// エラー集約・早期打ち切り・インデックス固定書き込みによる競合回避を両立する。
//
// 補足: limitに0を渡すと「同時にアクティブになれるgoroutineが0個」という制約になり、
// g.Goがいつまでも枠を待ち続けて全体がデッドロックする(公式ドキュメントで明示されている挙動)。
// limitに負値を渡した場合は「無制限」を意味する(SetLimitを呼ばない場合と同じ)。
func fetchAllLimited(ids []int, limit int) ([]fetchResult, int32, error) {
g, ctx := errgroup.WithContext(context.Background())
g.SetLimit(limit) // g.Goを1回でも呼ぶ前に設定する(呼んだ後の変更は未定義動作)
var active atomic.Int32
var maxActive atomic.Int32
results := make([]fetchResult, len(ids))
for i, id := range ids {
i, id := i, id
g.Go(func() error {
cur := active.Add(1)
defer active.Add(-1)
for {
m := maxActive.Load()
if cur <= m || maxActive.CompareAndSwap(m, cur) {
break
}
}
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, maxActive.Load(), err
}
return results, maxActive.Load(), nil
}
func main() {
ids := make([]int, 100)
for i := range ids {
ids[i] = i + 1
}
const limit = 10
start := time.Now()
results, maxConcurrent, err := fetchAllLimited(ids, limit)
elapsed := time.Since(start)
fmt.Printf("limit=%d, max observed concurrency=%d, elapsed=%s\n", limit, maxConcurrent, elapsed)
if err != nil {
fmt.Println("error:", err)
return
}
fmt.Printf("fetched %d results, first=%v, last=%v\n", len(results), results[0], results[len(results)-1])
if maxConcurrent > limit {
panic("BUG: concurrency limit was violated")
}
}
▶ セットアップと実行手順を見る
mkdir day033 && cd day033
go mod init example.com/day033
go get golang.org/x/sync@v0.8.0
# main.go を上記の内容で保存
go run -race .
id=13, 26, 39, ... のいずれかで失敗するためerror: id=13: item 13: fetch failedのようなエラーが出力され、max observed concurrencyがlimit=10を超えていないことが確認できます。
🪜 Step-by-Step 解説
g := errgroup.WithContext(...)の直後、最初のg.Go呼び出しより前にg.SetLimit(limit)を呼びます。ドキュメント上、稼働中のgoroutineがある状態でSetLimitを変更することは許されていないため、ループの外側・最初の1回だけ呼ぶのが安全な使い方です。
errgroup.Group自体は「今何個アクティブか」を外部に公開しないため、atomic.Int32のactiveカウンタをgoroutineの開始時に+1、deferで終了時に-1します。これにより、SetLimitが本当に効いているかどうかを、コードから検証可能にします。
「これまでの最大同時実行数」を記録する
maxActiveは、単純にif cur > maxActive { maxActive = cur }と書くと複数goroutineから同時に読み書きされて競合します。atomic.Int32.CompareAndSwapを使い、「読んだ値が変わっていなければ更新する」というループにすることで、mutexなしに安全な最大値更新を実現します。
mainの最後でmaxConcurrent > limitをpanicでチェックしています。これは本番コードとしては極端ですが、演習として「本当にSetLimitが効いているか」を目視ではなくコードで裏付ける習慣を作るための仕掛けです。
💡 設計思想・なぜこう書くのか
SetLimitは「Worker Poolを自分で書かなくていい」という利便性を提供していますが、内部で新しいプリミティブを発明しているわけではありません。実装は本質的に「アクティブ数を数えるセマフォ」をerrgroup.Groupに組み込んだものです。Goのライブラリ設計でしばしば見られるように、errgroupは既存の考え方(セマフォによる同時実行数制御)を、既に慣れ親しんだg.Go(func() error {...})というAPIの上にシームレスに統合することで、利用者が新しい概念を学ばずに済むようにしています。SetLimitは同時実行数だけを制御し、リトライやバックオフには一切関与しません。Goの標準的な設計思想である「小さいインターフェース・小さいユーティリティを組み合わせる」に忠実で、リトライが必要ならそれは呼び出し側が別途実装すべき関心事として明確に切り離されています。🌐 他言語との比較
| 言語 | 同等の概念 | Goとの違い |
|---|---|---|
| Java | Semaphore + ExecutorService(Executors.newFixedThreadPool(n)) | Javaはスレッドプールのサイズそのものを固定することで同時実行数を制限するのが一般的。Goはgoroutine自体は軽量なため「起動数は無制限、ただしerrgroup内部の枠だけを制限する」という発想で、スレッドプールという重い概念を必要としない |
| Python | asyncio.Semaphore(n) | async with sem:で囲む形で、errgroupのSetLimitとほぼ同じ思想。ただしPythonは例外伝播とタスクキャンセルをasyncio.TaskGroupが別途担当し、同時実行数制御はSemaphoreという別部品に分かれている点がGoの「1つの型に統合」する方針と異なる |
| Node.js | p-limitライブラリ、または手動のキュー管理 | Node.jsの標準ライブラリには同時実行数制御の仕組みがなく、p-limitのような外部パッケージに頼るのが一般的。Goはgolang.org/x/syncという準標準パッケージで同種の機能を提供し、errgroupという1つの型でエラー集約・キャンセル・同時実行数制御をまとめて扱える点が際立つ |
🏆 実務での使いどころ
- レートリミットのある外部APIへの一括問い合わせ: 決済代行会社・SMS送信APIなど、同時接続数やRPS上限があるサービスへの一括処理で、上限を超えないように制御しつつ並列化の恩恵も受ける
- DBコネクションプールを使い切らないバッチ処理: 数千件のレコードを並列更新する際、コネクションプールのサイズを超えてgoroutineを起動すると接続待ちで詰まるため、
SetLimitをプールサイズ以下に設定する - 大量ファイルの並列変換・アップロード処理: S3などへの並列アップロードで、帯域やAPIのスロットリングを考慮し、同時アップロード数を制御する
- クローラー・スクレイパーの並列度制御: 対象サイトに過度な負荷をかけないよう、同時リクエスト数を制限しながら並列クロールする
⚠️ よくある誤解・ミス
| 誤解・ミス | なぜ起こるか | 正しい理解 |
|---|---|---|
SetLimitを呼べば自動でリトライやバックオフもしてくれると誤解する | 「レートリミット対策」という文脈から連想する | SetLimitが制御するのは同時実行数の上限だけ。リトライ・指数バックオフは呼び出し側が別途実装すべき関心事として分離されている |
SetLimit(0)を「制限なし」だと思い込む | 0という数値から「制限が無効化される」感覚的な連想をしてしまう | ドキュメント上、0は「アクティブになれるgoroutineが0個」という意味になり、g.Goが永久にブロックしてデッドロックする。「無制限」にしたい場合はSetLimitを呼ばないか、負の値を渡す |
g.Goを何度か呼んだ後にSetLimitを呼んで動的に制限値を変えようとする | 「途中で制限を強めたい」という自然な発想から試みる | 稼働中のgoroutineがある状態でのSetLimit変更は未定義動作とされている。制限値は最初のg.Goより前に一度だけ設定するのが正しい使い方 |
同時実行数をカウントする変数をint型の通常変数のままcur++のようにインクリメントしてしまう | SetLimitを使っているから安全だと思い込む | SetLimitはgoroutineの起動タイミングを制御するだけで、カウンタ変数自体への同期を提供するわけではない。検証用カウンタもatomicかmutexで保護しないと、それ自体がデータ競合の原因になる |
maxActiveの更新をif cur > maxActive.Load() { maxActive.Store(cur) }のように「読んで比較して書く」2ステップで書いてしまう | atomic型を使っているから安全だと錯覚する | LoadとStoreの間に他のgoroutineが割り込むと更新が失われる(read-modify-writeの競合)。CompareAndSwapを使い「読んだ値が変わっていなければ書く」というアトミックな1操作にする必要がある |
🚀 次のステップ
- 発展:
fetchPriceが呼ばれるたびにHTTPリクエストのタイムアウトを個別に設定したい場合、context.WithTimeout(ctx, ...)を各goroutine内でさらに派生させる設計を試してみましょう(親のctxのキャンセルと子のctxのタイムアウトがどう合成されるかを確認する) - 次回予告: race condition検出(
go test -race)— これまで手動でデータ競合を見つけてきましたが、Day 034では体系的に「どんなコードパターンが-raceで検出されるか」を網羅的に学びます