2025年10月11日·1分で読めます

バックグラウンドジョブにおける Go のワーカープール vs goroutine ごとに起動する方法

Go のワーカープールと goroutine ごとに起動する方式の違いが、スループット、メモリ利用、バックプレッシャーにどう影響するかを学びます。バックグラウンド処理や長時間ワークフロー向けの実践的な考え方を解説します。

バックグラウンドジョブにおける Go のワーカープール vs goroutine ごとに起動する方法

どんな問題を解いているか?

ほとんどの Go サービスは HTTP リクエストに応答するだけではありません。バックグラウンドでメール送信、画像リサイズ、請求書生成、データ同期、イベント処理、検索インデックスの再構築なども行います。あるジョブは短く独立しています。別のジョブは各ステップが前の結果に依存する長いワークフローになります(カードを請求し、確認を待ち、顧客に通知してレポートを更新するなど)。

「Go のワーカープール vs goroutine ごとにタスクを起動する」と比較する際、人々は通常ひとつの本番的な問題を解こうとしています:大量のバックグラウンド作業をサービスを遅く、高価、もしくは不安定にすることなく実行するにはどうするか、です。

影響は主に次の点で感じます:

  • レイテンシ: バックグラウンド作業が CPU、メモリ、DB コネクション、ネットワーク帯域を奪い、ユーザー向けリクエストに影響します。
  • コスト: 管理されない同時実行はより大きなマシンや追加の DB 容量、キューや API の料金増加に向かわせます。
  • 安定性: インポート、マーケティング送信、再試行ストームのようなバーストがタイムアウト、OOM クラッシュ、連鎖的な障害を引き起こすことがあります。

本当のトレードオフは シンプルさ vs 制御 です。タスクごとに goroutine を起動するのは書くのが簡単で、ボリュームが少ないか自然に制限されている場合は問題ありません。ワーカープールは構造化を追加します:同時実行の固定、明確な上限、タイムアウトやリトライ、メトリクスを置く自然な場所です。代償は追加のコードと、システムが忙しいときにタスクをどう扱うか(待たせる、拒否する、別に保存する)を決める必要があることです。

これは日常的なバックグラウンド処理についての話です:スループット、メモリ、バックプレッシャー(オーバーロードを防ぐ方法)。すべてのキュー技術、分散ワークフローエンジン、あるいは完全一回実行(exactly-once)セマンティクスを網羅するものではありません。

AppMaster のようなプラットフォームでバックグラウンドロジックを含むフルアプリを作る場合でも、同じ疑問はすぐに出てきます。ビジネスプロセスや統合は、データベースや外部 API、メール/SMS プロバイダ周りに制限を設けないと、あるワークフローが他すべてを遅くしてしまいます。

平易な言葉での 2 つの一般的パターン

goroutine ごとにタスクを起動

最も単純なアプローチです:ジョブが到着したらそれを処理する goroutine を起動します。"キュー" はチャンネル受信や HTTP ハンドラからの直接呼び出しなど、作業をトリガーするものがそのままキューになることが多いです。

典型的な形は:ジョブを受け取って go handle(job) を実行する、というものです。場合によってはチャンネルをハンドオフに使いますが、制限には使わないことが多いです。

ジョブが主に I/O(HTTP 呼び出し、DB クエリ、アップロード)で待つ時間が多く、ボリュームが控えめでバーストが小さいか予測可能な場合にはうまく機能します。

欠点は同時実行が明確な上限なく増え得ることです。それがメモリのスパイクやコネクションの過剰オープン、下流サービスの過負荷を招く可能性があります。

ワーカープール

ワーカープールは固定数のワーカー goroutine を起動し、通常はメモリ内のバッファ付きチャンネルからそれらにジョブを流します。各ワーカーはループしてジョブを取り、処理し、繰り返します。

重要な違いは制御です。ワーカー数が同時実行の厳格な上限になります。ジョブがワーカーの処理速度より速く到着すると、ジョブはキューで待つ(またはキューが満杯なら拒否される)ことになります。

ワーカープールは、作業が CPU 集約(画像処理、レポート生成)である場合、リソース使用を予測可能にする必要がある場合、または DB やサードパーティ API をバーストから保護する必要がある場合に適しています。

キューはどこに置くか

どちらのパターンもインメモリのチャンネルを使えます。これは高速ですが再起動時に消えます。"絶対に失ってはならない" ジョブや長いワークフローでは、キューをプロセス外(DB テーブル、Redis、メッセージブローカー)に移すことが多くなります。そのセットアップでも、goroutine-per-task とワーカープールのどちらかを選びますが、外部キューのコンシューマとして動きます。

単純な例として、突然 10,000 通のメールを送る必要が出たとします。goroutine-per-task はそれらを一度に全部発射しようとするかもしれません。プールなら一度に 50 通ずつ送り、残りは制御された形で待たせます。

スループット:何が変わり、何は変わらないか

ワーカープールと goroutine-per-task の間に大きなスループット差を期待するのは一般的ですが、ほとんどの場合、生のスループットはどのように goroutine を始めるかではなく別の要因によって制限されます。

スループットは通常、最も遅い共有リソースで頭打ちになります:データベースや外部 API の制限、ディスクやネットワーク帯域、CPU 集約の処理(JSON/PDF/画像リサイズ)、ロックや共有状態、あるいは負荷で遅くなる下流サービスです。

共有リソースがボトルネックであれば、より多くの goroutine を起動しても仕事が速く終わるわけではありません。主に同じネックポイントでの待ちが増えます。

タスクが短くほぼ I/O バウンドで共有制限で争わない場合、goroutine-per-task が有利になることがあります。goroutine の起動は安く、Go は大量の goroutine をうまくスケジューリングします。"取得、解析、1 行書き込み" のような繰り返しでは、CPU を忙しく保ちネットワーク遅延を隠すことができます。

ワーカープールは高価なリソースを限定する必要がある場合に勝ちます。各ジョブが DB 接続を保持したりファイルを開いたり大きなバッファを割り当てたり API クォータに触れるなら、固定同時実行数でサービスを安定させつつ安全な最大スループットに到達できます。

レイテンシ(特に p99)はしばしば差が出るポイントです。goroutine-per-task は低負荷時に良く見えますが、タスクが積み重なると急落することがあります。プールはキューイング遅延を導入しますが、同じ制限を巡る大競合を避けるので挙動は安定します。

簡単なメンタルモデル:

  • 作業が安く独立しているなら、より多くの同時実行でスループットは上がる可能性がある。
  • 作業が共有制限によりゲートされているなら、より多くの同時実行は主に待ち時間を増やすだけ。
  • p99 を気にするなら、キュー時間と処理時間を別々に測る。

メモリとリソースの使い方

ワーカープール vs goroutine-per-task の議論は多くの場合メモリが中心です。CPU はスケールアップや水平分散で対応しやすいですが、メモリ障害は突然でサービス全体を落とすことがあります。

goroutine は安価ですが無料ではありません。各 goroutine は呼び出しが深くなったり大きなローカル変数を保持すると増えるスタックで始まり、スケジューラやランタイムの管理コストもあります。1 万の goroutine は問題ないことが多いですが、10 万は各 goroutine が大きなジョブデータを参照していると驚きになります。

大きな隠れたコストは多くの場合 goroutine 自体ではなく、それが保持しているものです。タスクが処理より到着の方が早ければ、goroutine-per-task は無限のバックログを作ります。キューが暗黙(ロックや I/O で待つ goroutine)であれ明示(バッファ付きチャンネル、スライス、インメモリバッチ)であれ、メモリはバックログとともに増えます。

ワーカープールは上限を強制するため役に立ちます。固定ワーカーと有界キューがあれば実際のメモリ上限が得られ、キューが満杯になったときの明確な失敗モード(ブロック、負荷削減、上流へ戻す、永続化など)を持てます。

大まかな計算式の目安:

  • ピーク goroutine 数 = ワーカー + 処理中ジョブ + あなたが作った「待ち」ジョブ
  • ジョブあたりメモリ = ペイロード(バイト) + メタデータ + 参照するもの(リクエスト、デコード済み JSON、DB 行)
  • ピークバックログメモリ ≈ 待ちジョブ数 * ジョブあたりメモリ

例:各ジョブが 200 KB のペイロードを保持している(または 200 KB のオブジェクトグラフを参照している)場合に 5,000 ジョブを溜めると、ペイロードだけで約 1 GB になります。たとえ goroutine が魔法のように無料でも、バックログは消えません。

バックプレッシャー:システムを溶かさないために

長いワークフローをオーケストレーションする
エクスポートや通知のようなマルチステップジョブをドラッグ&ドロップでマップします。
BP エディタを試す

バックプレッシャーは単純です:作業が処理速度を上回るときに、システムは静かに積み上げる代わりに制御された方法で押し返します。これがないと、単に遅くなるだけでなくタイムアウト、メモリ増加、再現しにくい障害が起きます。

欠けているバックプレッシャーは、バースト(インポート、メール、エクスポート)がメモリを上昇させて下がらない、キュー時間が伸びる一方で CPU はずっと忙しい、関連のないリクエストのレイテンシがスパイクする、再試行が積み上がる、"too many open files" やコネクションプール枯渇のようなエラーが出ることで気づかれます。

実用的なツールは有界チャンネルです:待てるジョブ数を制限します。チャンネルが満杯だとプロデューサーはブロックし、これがソース側でジョブ作成を遅らせます。

ブロックが常に正しいわけではありません。オプション作業の場合は、オーバーロード時の挙動を予測可能にする明示的なポリシーを選びます:

  • 低優先度の作業を破棄 する(例えば重複通知)
  • 多くの小さな作業をバッチ化 して一回の書き込みや API 呼び出しにまとめる
  • ジッタ付きで遅延 してリトライスパイクを避ける
  • 永続キューに委ねてすぐに返す
  • 既に過負荷なら明確なエラーで負荷を削ぐ

レート制限とタイムアウトもバックプレッシャーの道具です。レート制限は外部依存(メールプロバイダ、DB、サードパーティ API)への到達速度を制限します。タイムアウトはワーカーが長時間ハングするのを防ぎます。組み合わせると、遅い依存先がフルの障害につながるのを止められます。

例:月末の明細生成。10,000 リクエストが一度に来たら、無制限の goroutine は 10,000 回の PDF レンダーとアップロードを引き起こすかもしれません。有界キューと固定ワーカーがあれば、安全なペースでレンダリングとリトライを行えます。

ワーカープールの作り方(ステップバイステップ)

ワークフローをより速く出荷する
キュー、タイムアウト、リトライを手作業で配線せずにバックエンドを作成します。
構築を始める

ワーカープールは固定数のワーカーを走らせ、キューからジョブを供給して同時実行を抑えます。

1) 安全な同時実行上限を決める

ジョブが何に時間を費やすかから始めます。

  • CPU 重たい作業ならワーカー数を CPU コア数に近づける。
  • I/O 重たい作業(DB、HTTP、ストレージ)ならもう少し増やせますが、依存先がタイムアウトやスロットリングを始める地点で止めます。
  • 混合型なら計測して調整します。一般的な開始点は CPU コアの 2x〜10x の範囲で、そこからチューニングすることが多いです。
  • 共有制限を尊重してください。DB プールが 20 接続なら、200 ワーカーはその 20 と争うだけです。

2) キューとサイズを選ぶ

組み込みで分かりやすいのでバッファ付きチャンネルがよく使われます。バッファはバーストのショックアブソーバです。

小さいバッファは過負荷を早く露呈させます。大きいバッファはスパイクを平滑にしますが問題を隠し、メモリとレイテンシを増やします。バッファは意図的にサイズを決め、満杯になったときの挙動を設計します。

3) すべてのタスクをキャンセル可能にする

各ジョブに context.Context を渡し、ジョブ内のコードがそれを使うようにします(DB、HTTP)。これがデプロイやシャットダウン、タイムアウト時にクリーンに止める方法です。

func StartPool(ctx context.Context, workers, queueSize int, handle func(context.Context, Job) error) chan<- Job {
    jobs := make(chan Job, queueSize)
    for i := 0; i < workers; i++ {
        go func() {
            for {
                select {
                case <-ctx.Done():
                    return
                case j := <-jobs:
                    _ = handle(ctx, j)
                }
            }
        }()
    }
    return jobs
}

4) 実際に使うメトリクスを追加する

もし少数の指標だけ追うなら、これを入れてください:

  • キュー深さ(どれだけ遅れているか)
  • ワーカーの稼働時間(プールの飽和度)
  • タスク所要時間(p50, p95, p99)
  • エラー率(リトライ回数も)

これだけでワーカー数やキューサイズを証拠に基づいて調整できます。

よくある間違いと罠

ほとんどのチームは「間違った」パターンを選んで問題になるのではありません。小さなデフォルトがトラフィックのスパイク時に障害に変わることが多いのです。

goroutine が増えすぎるとき

古典的な罠はバーストでジョブごとに goroutine を生成することです。数百なら問題ありませんが、数十万はスケジューラ、ヒープ、ログ、ソケットを圧迫します。各 goroutine が小さくても、合計コストは無視できず、進行中の仕事が既にあるため回復に時間がかかります。

もうひとつの誤りは巨大なバッファ付きチャンネルを「バックプレッシャー」とみなすことです。大きなバッファは単に隠れたキューに過ぎません。時間を稼げますが、メモリの壁に当たるまで問題を隠します。キューが必要なら意図的にサイズを決め、満杯時にどうするか(ブロック、破棄、再試行、永続化)を決めてください。

隠れたボトルネック

多くのバックグラウンドジョブは CPU ボトルネックではありません。下流で制限されています。それを無視すると、高速なプロデューサが遅いコンシューマを圧倒します。

よくある罠:

  • キャンセルやタイムアウトがないためワーカーが API リクエストや DB クエリで永遠にブロックする
  • DB 接続やディスク I/O、サードパーティのレート制限など実際の限界を無視したワーカー数の選択
  • リトライが負荷を増幅する(1,000 件の失敗ジョブで即時リトライ)
  • 1つの共有ロックや単一トランザクションがすべてを直列化してしまい、「ワーカーを増やす」ことがオーバーヘッドになる
  • 視認性の欠如:キュー深さ、ジョブ年齢、リトライ数、ワーカー稼働率のメトリクスがない

例:夜間エクスポートが 20,000 の「通知送信」タスクを作るとします。それぞれが DB とメールプロバイダに当たると、コネクションプールやクォータを簡単に超えます。50 ワーカーとタスクごとのタイムアウト、小さなキューなら上限が明らかになります。ジョブごとの goroutine と巨大バッファだと、システムは一見問題ないように見えて突然壊れます。

例:バーストするエクスポートと通知

バックプレッシャーを見える化する
API と並んで運用管理ツールやジョブコントロールを追加し、何が起きているかを可視化します。
アプリを作る

監査のためにデータが必要なサポートチームを想像してください。ある人が「エクスポート」ボタンを押し、数人が続いて、1 分で 5,000 のエクスポートジョブが作られることがあります。各エクスポートは DB から読み、CSV を生成し、ファイルを保存し、準備完了を通知します(メールや Telegram)。

goroutine-per-task のアプローチだと、最初はシステムが素早く感じられます。5,000 のジョブがほぼ同時にスタートし、キューがさばかれているように見えます。しかしその代償として大量の DB クエリが同時発生しコネクションを奪い、ジョブがバッファを保持してメモリが上昇し、タイムアウトが多発します。短時間で終わるはずのジョブが再試行や遅いクエリのせいで詰まります。

ワーカープールでは開始は遅く感じるかもしれませんが全体は落ち着いて進みます。50 ワーカーなら一度に重い作業をしているのは 50 件だけです。DB 使用量は予測できる範囲に収まり、バッファは再利用され、レイテンシは安定します。総完了時間も見積もりやすくなります:概ね (ジョブ数 / ワーカー数) * 平均ジョブ時間 + オーバーヘッド です。

重要な違いは、プールが魔法のように速いわけではない点です。バースト時にシステムが自壊するのを止める点が違います。管理された 50 並列は、多数が争う 5,000 より早く終わることがよくあります。

どこにバックプレッシャーを置くかは守りたい対象によります:

  • API 層でシステムが忙しいときに新しいエクスポート要求を拒否または遅延する
  • キューで受け入れて安全な速度でドレインする
  • ワーカープール内で重い部分(DB 読み、ファイル生成、通知送信)の同時実行を抑える
  • リソースごとに制限を分ける(例:エクスポートは 40、通知は 10)
  • 外部呼び出しをレート制限してブロックされないようにする

本番投入前の簡単なチェックリスト

同時実行の前提を検証する
実際のバックエンドと実際のエンドポイントで早い段階からワーカー制限とジョブ挙動を検証します。
プロトタイプを作る

バックグラウンドジョブを本番で走らせる前に、制限、可視性、障害処理を確認してください。多くのインシデントは「遅いコード」によるものではなく、負荷がスパイクしたときや依存先が不安定になったときの防護策不足から起きます。

  • 依存ごとに最大同時実行を設定する。 すべてに合う一つのグローバルな数値に頼らない。
  • キューを有界かつ観測可能にする。 保留ジョブ数に実際の上限を設け、キュー深さ、最古ジョブの年齢、処理率を公開する。
  • ジッタ付きリトライとデッドレター経路を追加する。 選択的にリトライし、リトライを分散し、N 回失敗したら再試行用のデッドレターキューや失敗テーブルに移す。
  • シャットダウンの挙動を検証する:ドレイン、キャンセル、安全な再開。 デプロイやクラッシュ時の振る舞いを決める。ジョブは冪等にして再処理を安全にし、長いワークフローは進捗を保存する。
  • タイムアウトとサーキットブレーカでシステムを守る。 外部呼び出しにはすべてタイムアウトを設定し、依存先が落ちているときは早く失敗させるか取り込みを止める。

実践的な次の一手

通常の日に合わせてパターンを選んでください。到着がバーストする(アップロード、エクスポート、メール送信)なら、有界キューと固定ワーカープールが安全なデフォルトです。作業が安定していて各タスクが小さいなら、goroutine-per-task でも構いませんが、どこかに必ず制限を設けてください。

勝つ選択はたいてい「失敗を退屈にする」ものです。プールは限界を明らかにします。goroutine-per-task は最初は簡単ですが、本当のスパイクまでは限界を忘れがちにします。

シンプルに始めて、上限と可視性を早めに追加する

単純なものから始めつつ、早いうちに 2 つの制御を入れてください:同時実行の上限とキューおよび障害を見れるようにすること。

実用的なロールアウト計画:

  • ワークロードの形状を定義する:バースト、安定、混合(ピークがどの程度か)
  • 処理中の作業に対するハードキャップを置く(プールサイズ、セマフォ、有界チャンネル)
  • キャップに達したときの挙動を決める:ブロック、破棄、明確なエラーを返す
  • 基本的なメトリクスを追加する:キュー深さ、キュー滞在時間、処理時間、リトライ、デッドレター
  • 期待ピークの 5 倍のバーストで負荷テストし、メモリとレイテンシを観察する

プールだけでは不十分なとき

ワークフローが分や数日かかるような場合、単純なプールは苦戦します。作業は "一度やって終わり" ではなく、状態、リトライ、再開可能性が必要になるからです。進捗を永続化し、ステップを冪等にし、バックオフを適用する必要があります。また一つの大きなジョブを小さなステップに分けることでクラッシュ後に安全に再開できるようにもします。

フルバックエンドをより速く出荷したいなら、AppMaster (appmaster.io) は実用的な選択肢になり得ます:データとビジネスロジックを視覚的にモデル化し、実際の Go コードを生成するので、同時実行の制限やキューイング、バックプレッシャー周りの設計を手作業で全部配線せずに済みます。

よくある質問

タスクごとに goroutine を起動する代わりにワーカープールを使うべきはいつですか?

ジョブがバーストで到着したり、DB コネクションや CPU、外部 API のクォータなど共有リソースに触れる可能性がある場合は、デフォルトでワーカープールにするのがよいです。ボリュームが控えめで、タスクが短く、どこかに明確な上限(セマフォやレートリミッタなど)があるなら、goroutine をタスクごとに起動する方法でも問題ありません。

goroutine-per-task とワーカープールの本当のトレードオフは何ですか?

goroutine をタスクごとに起動するのは書くのが速く、負荷が低いときは優れたスループットを出せますが、スパイク時に制御されないバックログを作る危険があります。ワーカープールは同時実行数に上限を入れ、タイムアウトやリトライ、メトリクスを適用する明確な場所を提供するため、本番での挙動が予測しやすくなります。

ワーカープールは goroutine-per-task に比べてスループットを下げますか?

ほとんどの場合、さほど差は出ません。多くのシステムではスループットはデータベースや外部 API、ディスクやネットワーク帯域、CPU 重たい処理など共有ボトルネックで制限されます。追加の goroutine はその限界を超えられないため、主に待ち時間や競合を増やすだけです。

これらのパターンはレイテンシ(特に p99)にどう影響しますか?

負荷が低いときは goroutine-per-task の方がレイテンシがよいことが多いですが、負荷が高くなると一斉に競合して急激に悪化します。プールはキューイング遅延を導入しますが、同じ依存先に対する一斉負荷を防ぐため p99 を安定させやすいです。

なぜ goroutine-per-task がメモリスパイクを引き起こすことがあるのですか?

問題は通常 goroutine 自体ではなくバックログです。タスクが積み重なり、それぞれがペイロードや大きなオブジェクトを保持するとメモリが急増します。ワーカープールと有界キューはこれを明確なメモリ上限と予測可能なオーバーロード動作に変えます。

バックプレッシャーとは何で、Go にどう実装しますか?

バックプレッシャーとは、システムがすでに忙しいときに新しい仕事の受け入れを遅らせたり止めたりして、見えないまま積み上がらせないことです。Go では有界チャンネルを使うのが簡単な方法で、チャンネルが満杯になると生産側がブロックするかエラーを返して過負荷を防げます。

適切なワーカー数はどう選びますか?

まず実際の制限から考えます。CPU 重たいジョブなら CPU コア数に近い数から始めます。I/O が主ならもう少し増やせますが、DB やネットワーク、サードパーティがタイムアウトやスロットリングを始める地点で増やすのを止めます。コネクションプールサイズも尊重してください。

ジョブキュー/バッファのサイズはどのくらいが良いですか?

通常のバーストを吸収できるが、問題を数分間隠さないサイズを選びます。小さめのバッファは過負荷を早く露呈させます。大きなバッファはメモリ使用量を増やし、失敗が遅れて出ることがあるので、キューが満杯になったらどうするか(ブロック、拒否、破棄、永続化)を決めておきます。

ワーカーが永遠にハングするのをどう防ぎますか?

各ジョブに context.Context を渡し、DB や HTTP 呼び出しがそれを尊重するようにします。外部呼び出しにタイムアウトを設定し、シャットダウン時にワーカーがきちんと停止できるよう振る舞いを明確にしてください。

バックグラウンドジョブでどんなメトリクスを監視すべきですか?

キュー深さ、キュー待ち時間、タスク所要時間(p50/p95/p99)、エラー/リトライ回数を監視してください。これらでワーカー数やキューサイズ、タイムアウト、外部依存へのレート制限の必要性が判断できます。

始めやすい
何かを作成する 素晴らしい

無料プランで AppMaster を試してみてください。
準備が整ったら、適切なサブスクリプションを選択できます。

始める