Goにおける並行処理の制御:errgroup、ワーカプール、バックプレッシャー

単にキャンセルするだけでなく、進行中の作業を制限してください。

目次

無制限のファンアウトは、Go の並行処理におけるデフォルトの失敗パターンです。すべての go 文が、上限もなく、責任者もいないまま、進行中のタスクを1つ増やします。この記事は、それら両方を与えることについてです。

キャンセルとバウンディングは、独立した制御機構です。キャンセルは作業がいつ止まるかを決定し、バウンディングはいくつの作業を同時に存在させるかを決定します。サービスを完璧にキャンセルできたとしても、1万ものゴルーチンがそれぞれ独立して「今がデータベースを呼び出す良いタイミングだ」と判断したために、システムが倒れる可能性があります。

ガラスの漏斗が、調整されたゲートを通じて輝く粒子の流れを制御している

このページ周辺のページは、問題の另一半を扱っています。Go の context.Context を正しく使う は、作業を止めるための制御プレーンとして context を扱いますが、ここでは主役は容量プレーンです —— errgroup.SetLimit、セマフォチャネル、ワーカープール、バウンデッドキュー —— および、すべてのキャンセルパスが適切に接続されているように見えても、errgroup.Wait を生き延びる1つのリークパターンです。

5つのプリミティブ、1つの決定表

プリミティブ 部分的な結果 フェイルファスト バックプレッシャー 動的な作業 最適な用途
sync.WaitGroup + errors.Join はい いいえ いいえ いいえ すべての結果が重要な全待機バッチ
errgroup.WithContext いいえ はい いいえ いいえ 最初のエラーでリクエストを終了させるファンアウト
errgroup.SetLimit(n) いいえ はい はい(Go がブロック) いいえ 有界なフェイルファストファンアウト
セマフォチャネル はい 手動 はい(獲得時にブロック) はい カスタムなアドミッションコントロール
ワーカープール はい 手動 はい(キューが満杯) はい 安定したストリーム、長時間生存するワーカー

行を選ぶための2つの質問があります。第一に:1つのタスクが失敗した場合、他のタスクの結果は必要ですか?はいの場合は、フェイルファストなグループのセマンティクスは、必要とした作業を破棄します。第二に:作業は連続して到着するか、それとも開始前に数えられる固定のバッチですか?グループはバッチを処理し、プールやキューはストリームを処理します。

errors.Join は、素の WaitGroup の行を実用的なものにします —— 最初のものしか保持せず、すべてのワーカーのエラーを集めるため、Go エラー処理アーキテクチャ の境界翻訳ルールと組み合わさります。

errgroup.Wait を生き延びるリーク

最も有名な errgroup の失敗モードは、バウンディングとは無関係です。これはキャンセルのギャップであり、正しいように見えるコードの背後に隠れています:

g, gctx := errgroup.WithContext(ctx)
results := make(chan int) // バッファなし

go func() { // プロデューサー — このゴルーチンの責任者は誰もいない
    for i := 0; i < 100; i++ {
        results <- i
    }
    close(results)
}()

g.Go(func() error { // コンシューマー — グループメンバー
    for r := range results {
        if r == 5 {
            return fmt.Errorf("save failed")
        }
    }
    return nil
})

err := g.Wait() // コンシューマーのエラーで返る

コンシューマーが返ると、gctx がキャンセルされ、Wait が返ります。プロデューサーは results <- i で停止しています — そして、コンテキストのキャンセルはチャネル送信をブロック解除しません。そのゴルーチンの責任者が誰もいないため、プロセスが生きている限り停止したままです。このパターンをループ内で実行すると、リークは正確に線形になります:

バリアント 500反復後の生きているゴルーチン
ctx.Done アームなしの送信 501 (+500リーク、反復ごとに1つ)
<-gctx.Done() を含む select でラップされた送信 502 (+1 ベースライン)
// 修正:キャンセル可能なタスク内のすべてのチャネル操作に
// Done アームを付ける
select {
case results <- i:
case <-gctx.Done():
    return
}

同じギャップは受信側にも存在します。ルールは機械的です:キャンセルされ得るコード内のすべてのチャネル操作に <-ctx.Done() アームを付け、すべてのゴルーチンに、それ待ち合わせるかキャンセルする責任者を付けます。CI での goleak は、レビューが見落とすケースをキャッチします — Go Linters:コード品質のための必須ツール にあるツールと同様の自動化品質ゲートに組み込むことができます。

2つの関連するパターンに名前を付ける価値があります。プロデューサー自身がグループメンバーである場合、同じコードはリークではなくデッドロックを生みます — Wait は停止した送りを待って永遠にブロックしますが、少なくともこれは明確に失敗します。また、select で送信するが、コンシューマーが終了したチャネルで受信するプロデューサーは、同じようにリークします;Done アームは両側に必要です。

errgroup.SetLimit:有界なフェイルファストファンアウト

SetLimit は、errgroup をアドミッションコントロールされたグループに変えます。制限を超える各 Go 呼び出しは、スロットが解放されるまでブロックします:

g, ctx := errgroup.WithContext(ctx)
g.SetLimit(8)
for _, item := range items {
    g.Go(func() error {
        return process(ctx, item)
    })
}
err := g.Wait()

本番環境では、3つのセマンティクスが重要です。Go がブロックするため、上記のループはプロデューサーにバックプレッシャーを適用します — それは通常望ましいことですが、呼び出し元のゴルーチンがスタールする可能性があることを意味するため、大量のバッチを供給するリクエストハンドラーは、ループ全体围绕う独自のタイムアウト予算が必要です。SetLimit(0) は、すべての Go 呼び出しを永遠にブロックします。また、ゴルーチンがアクティブな間に制限を変更するとパニックが発生します — 制限はグループの生存期間中に固定です。

SetLimit はグループのセマンティクスを継承します:最初のエラーがコンテキストをキャンセルし、Wait は最初のエラーを返し、まだ進行中の作業の結果を破棄します。最初の失敗で操作全体を失敗させることが正しい場合、それは適切なプリミティブです — 1ページのデータ取得、独立したヘルスチェックセットの呼び出し、リクエストのシャードへのファンアウトなど。

セマフォチャネル:バックプレッシャーを機能として

容量 n のバッファ付きチャネルは、依存関係のないセマフォです:

sem := make(chan struct{}, 8)
var wg sync.WaitGroup
for _, item := range items {
    wg.Add(1)
    sem <- struct{}{} // 8つのタスクが進行中の場合にブロック
    go func() {
        defer wg.Done()
        defer func() { <-sem }()
        _ = process(ctx, item)
    }()
}
wg.Wait()

獲得は go 文の前に起こるため、ゴルーチンはスロットが存在する場合にのみ作成されます。バッファサイズは契約です:それは同時に、並行性の上限であり、待機中のアドミッションのキューです。

このプリミティブがその地位を確保するのは、グループが表現できないアドミッションコントロールが必要になった場合です — コンテキスト認識型獲得、ダウンストリームごとのプール、または失敗時の部分的な結果:

select {
case sem <- struct{}{}: // スロットを獲得
case <-ctx.Done():      // 入る前に呼び出し元が諦めた
    return ctx.Err()
}

同じワークロードで測定すると、セマフォチャネルと SetLimit(8) は、互いの1.5%以内で完了します — 選択は性能ではなくセマンティクスです。

ワーカープール:ストリームのためのもの、バッチのためのものではない

長時間生存するワーカーの固定プールは、アドミッション(キュー)と実行(ワーカー)を分離します:

func pool(ctx context.Context, workers int) chan<- func() {
    jobs := make(chan func())
    var wg sync.WaitGroup
    for i := 0; i < workers; i++ {
        wg.Add(1)
        go func() {
            defer wg.Done()
            for {
                select {
                case f, ok := <-jobs:
                    if !ok {
                        return
                    }
                    f()
                case <-ctx.Done():
                    return
                }
            }
        }()
    }
    return jobs
}

プールは、連続して到着する作業に適しています — クューコンシューマー、ポーリングループ、リクエストワーカーなど、バッチごとのグループが永遠に作成・解体されるようなもの。Go 1.25 以降、sync.WaitGroup.Go は、一般的な形状における Add/Done ペアリングのボイラープレートコードを削除します。シャットダウン契約は、正しくすべき部分です:jobs をクローズすると、キューが排空された後にワーカーを終了させます;ctx をキャンセルすると、キューにある作業を放棄します。両方は正当ですが、文書化された挙動として許されるのは1つだけです。Go 1.27 は同じ方向に進んでいます — ジェネリックメソッド、新しい go fix モダナイザー、インメモリの httptest.NewTestServer — これはすべて Go 1.27 アップグレードガイド でカバーされています。

バウンデッドキュー:ブロックするかドロップするか

バッファ付きチャネルは、上限のあるキューでもあります。バッファが満杯になると、プロデューサーはブロックします — そして、その瞬間が設計の判断です。ブロックは、キューに供給する誰かに上流へプレッシャーを伝播させます;ドロップは、作業喪失のコストで負荷を減らすものです。監視パイプラインはドロップできますが、注文パイプラインは、ブロックするか、永続ストレージに溢れ出さなければなりません。

select {
case queue <- event: // 承認された
default:            // キュー満杯:ドロップポリシーはこちら
    dropped.Add(1)
}

ポリシーが何であれ、明示的かつ測定可能にしてください。サイレントな default ブランチは、イベントが消える場所です。

バウンディングのコスト — 測定された

同じワークロード、5ms のシミュレート I/O を持つ10,000タスク、Go 1.27.1:

戦略 ピーク時のゴルーチン ウォールタイム
タスクごとの無制限 go ~10,000 起動 18 ms
errgroup.SetLimit(8) 8 6.9 s
セマフォチャネル (cap 8) 8 6.6 s

無制限の実行はウォールタイムで勝りますが、実験の中で何ものも押し戻していないためです — 1万のタイマーゴルーチンは単に並列で寝ているだけです。まさにそれが罠です。無制限のファンアウトのコストは、自プロセス内の CPU ではなく、1万の同時接続、バーストの下でタイムアウトし始めるダウンストリーム、そしてあなたが呼び出しているもののキュー深度に従うメモリグラフです。実際の依存関係において、無制限の実行は18msで完了しません — タイムアウトします。有界な実行は6.6秒を払いますが、10,000タスク ÷ 8スロット × 5 ms は避けられない直列化の6.25 sであり、上限がポイントです:それは制御されないバーストを、予測可能な6.25秒の排空に変えます。

上限が数個のスロットより高い場合、1つの改善が重要です:上限を、ダウンストリームが並列に持続できるレベル以下に保ち、それをその制限(接続プールサイズ、レート制限、ワーカー容量)から導き出してください、「合理的な」並列性についての感覚からではなく。

有界なコードの測定とテスト

3つのシグナルが大部分をカバーします。ゴルーチン数のトレンド(Prometheus Go コレクタ経由でエクスポートされる /sched/goroutines:goroutines)は、リークを傾きとしてキャッチします。スケジューラ遅延(/sched/latencies:seconds)は飽和をキャッチします — CPU タイムを待つ runnable なゴルーチンは、数に関係なく実際のプレッシャーです。2つのゴルーチンダンプの pprof デルタは、停止したゴルーチンがどこに存在するかを特定します。

テストについて、有界なワーカーは、synctest を用いた並行 Go コードのテスト が構築されたケースです:fake time は、10,000タスクの完全な排空をミリ秒で実行させ、synctest.Wait は安定期の「sleep-and-hope」を置き換え、ワーカーが永続的にブロックされたときにバブルは速やかに失敗します — 上記のリーク再現は、本番環境ではなくテストバブルで死にます。

サービススケールにおいて、同じバウンディングの質問はサービス間にも再出現し、そこでセマフォはレート制限になり、サーキットブレーカーがダウンストリームを守ります — Go におけるサーキットブレーカーパターン はその層をカバーし、AI/ML オーケストレーションのための Go マイクロサービス は、オーケストレーション層が取るキューバックされた形式を示しています。

選択

flowchart TD A[並行実行する作業] --> B{固定バッチか連続ストリーム?} B -- 連続ストリーム --> C[ワーカープールまたはバウンデッドキュー] B -- 固定バッチ --> D{最初の失敗時の部分的な結果が必要か?} D -- はい --> E[WaitGroup + errors.Join
またはセマフォチャネル] D -- いいえ --> F[errgroup.WithContext] F --> G{並行性の上限が必要か?} G -- はい --> H[errgroup.SetLimit] G -- いいえ --> I[バッチが明らかに小さい場合のみ
制限なしグループ] C --> J{キュー満杯:ブロックかドロップ?} J -- ブロック --> K[上流へバックプレッシャー] J -- ドロップ --> L[負荷を減らし、ドロップ数を数える]

表とフローチャートは、1つの習慣に圧縮されます:go を書く前に、上限と責任者を名指しすること。上限は SetLimit、セマフォ、またはバウンデッドキューです;責任者は Wait、チャネル操作すべての ctx.Done アーム、またはその両方です。これらの2つの名前に1瞥で答えられるゴルーチン生成サイトはすべて、深夜3時に誰かを起こしません。

便利なリンク

購読する

システム、インフラ、AIエンジニアリングの新記事をお届けします。