ゴルーチンの最適運用とワーカープール選定法|並列数制御やエラー時の実践対応も解説

Published on: | Last updated:

ゴルーチンとワーカープール選び迷ったら?今っぽい並列処理の『ちょっとやれば変わる』効率ワザ集

  1. まず errgroup.SetLimit を 5〜10 に設定して、1 分間だけ処理量の変化をチェックしてみて!

    いきなり全部ゴルーチンに投げるより、上限付けるとリソース無駄遣い減る。60 秒後に CPU 使用率が前より下がってたら成功(top コマンドで即確認)

  2. I/O系はセマフォで同時実行数を 3 に制限しつつ、1 時間運用してタイムアウト回数を記録して!

    ファイルやAPIの叩き過ぎで詰まる現象を防げるし、タイムアウト発生が半分以下になってたら効果あり(エラーログで見比べてみて)

  3. GOMAXPROCS を物理コア数ぴったりにして、10 分動かした後のシステム負荷グラフを一度見て!

    CPUバウンドなら無理に並列化しすぎると逆に遅くなる。10 分後にロードアベレージが理想値±1 以内なら最適化できてる(htop で数字を確認)

  4. 1 週間だけキューの長さを毎日モニターして、ピーク時に 20 件超えたら即ワーカープール増員してみて!

    キュー詰まり=処理が追いついてないサイン。増員して翌日の最大長が半分以下になってたらOK(グラフを毎日比べるだけで済む)

ゴルーチンとワーカープールの最適な選択方法を見極める

まあ…先に結論出しちゃうけど、Goのgoroutineってさ、確かに軽いけど別にゼロコストではないよな。ぶっちゃけ無料だと思い込んで雑に使うと後悔するかも。特に最近はmodern Goだとconcurrency前提っぽい雰囲気強くて、「どうせ安いんだから」って際限なく投げまくりがちなんだけど……まあ本当どうなんだろうね。

goroutine自体は小さい関数をスレッドっぽく動かせるやつ、Go runtimeのおかげで勝手に割り振ってくれるらしい。それとworker poolっていう方式も普通になってきたし、それなら決まった数のgoroutineがqueueから仕事ピックして動くだけ──うーん、仕組みとしては想像しやすい方かな。

何が違う?みたいな部分だと正直…latencyやメモリ消費だけじゃなくCPUの負荷バランス、それから障害起きた時どう挙動するの?とかまで影響出たりする。なんとなーく「同時実行数上げれば全体早くなる」みたいな夢持つけど、多分そこまで単純じゃない。良い面と悪い面両方あるわ。

いやまあ、本当にこのタイミングでこんなの議論して意味ある?て思われそうだけど…事実としてGo 1.20~1.22あたりでruntimeとかpollerめっちゃ改良入ったみたい。でも現状でも「無限fan-outできる最強並列!」的なの期待すると痛い目見るよたぶん。「backpressure全然ケアせずゴリ押し」→普通にサーバー死ぬかメモリ全部溶けるシーン、まだあり得るので超注意した方がいいかな。まあ、疲れたから終わりでいいよな……

GoランタイムのM:Nスケジューラが並列処理に与える影響を理解する

うわ!Goのgoroutineマジでヤバい!!スケジューリング…何それ?超複雑な仕組みだし、本気でM:Nスケジューラちゃんと理解しないと、プーリング?あれって判断ぶれるって感覚あるね!G(ジョブ)、R(ランキュー)、M(OSスレッド) - この3つがグチャグチャ絡まって動いてるんだよ。ちなみにさ、G=job(関数として書くやつ)→R=run queueにまず流れて、その後でM=OS threadにどんどん投げ込まれる感じっぽい!ハイテンションすぎて言葉まとめられないけど。

えーっと、いやこれ下の図見たら本当に「+--------+」って箱並び出てて爆笑なんだけど、それぞれ役割めっちゃパッと見えるわ!斜線になってる所はさ、「work stealing」だからアレ超重要ポイント!あとさ、GOMAXPROCS値…これでM(OSスレッド数)縛りあるよ。「これ忘れたらオワリじゃん」と思うくらいgoroutineはいくらでも作れる雰囲気あるのに…OSスレッド少ない上にごちゃ混ぜで高速動作してる。それだけ増やして無限最高か?全然違うから注意しろ!!(力説)

たとえばね〜I/O関連なら何千もspawnぶち込んでも平気だと思う!(あ、CPUバウンドだと一転)CPUのコアが現実的には取り合うしかなくなる感じなの。でも適当にgoroutine増やす→切り替え回数めっちゃ多くなっちゃって逆効果だったり!?効率落ちるみたい。「安い=無限可能」そんなワケない。そこホント盲点になる。「新しいgoroutine作成」は絶対バックプレッシャー意識して、リミット設定とか絶必須かも。

いつプーリング意味出る?逆に邪魔?それがねぇ…ワークロード内容や環境条件によって毎回変化しちゃう事案!だから断言するのむずかしいケース多発だよ!(混乱注意)

GoランタイムのM:Nスケジューラが並列処理に与える影響を理解する

プール導入が効果的なワークロード条件を具体的に整理する

どんな時にプールが効果出るか整理しとく。まずね、作業内容をざっくり表にしてるんだけど…メインはI/Oバウンド系。APIやDBみたいなリモート側でQPS制限が絡んできたら、プール導入ちょっと考えてもいいかなって印象。うーん、でも、そもそも呼び出し自体がまれなら手間かけて管理するのは微妙かもしれない。

次にCPUバウンドなケースどうよ?ってとこだけど、処理数>物理コアの時は一応プール案浮上。ただし極端に軽いタスクや回数自体少ない時は逆にデメリット強めかなって思うわ。すぐ終わるやつにはプール向いてなさそうだし。

で、突然まとめて発火するBurst型ファンアウトタスク。ここはね、もしメモリ心配ならプール役立つ場合ある。でもRAM余裕あってAIO対応できてれば必要ないパターン結構ありそう。この辺、人によって判断ブレそうだな。

長時間動くタイプのジョブも触れとく。途中でキャンセル操作いるシチュでは推奨されがち。でも実際は秒殺で済むなら、その分だけ待ち発生して意味無かったり。「ほんとかこれ…?」ってなる人多い気もするね。

ちなみになんだけど…そもそもプール組み込むとキューイング遅延とか複雑さ増しやすいらしい。もし外部への依存度低くて全部高速処理できちゃう構造なら、それこそスループット下げちゃうことも全然有り得るよ!このポイント忘れると事故るわ。「DBやAPI・ディスクなど詰まりボトルネック想定されるところ基準でリミット決めろ」って書いてあった、たしか。

現実的な初手設計案としては、「ゴルーチン自由生成+チャンネルセマフォ型で同時実行キャップ」、これ超シンプル&安定策だと思った。その形使えば1ゴルーチン=1仕事の直感運用保ったまま流量制御いける仕組みになる!

<pre><code class="language-css">Go言語の場合例。
sem := make(chan struct{}, 256) // 最大256in flight
for _, u := range urls {
sem <- struct{}{}
go func(u string) {
defer func() { <-sem }()
ctx, cancel := context.WithTimeout(parent, 2*time.Second)
defer cancel()
_ = fetch(ctx, u) // エラー処理追加OK
}(u)
}
// 終了後全部排出(開放用)
for i := 0; i < cap(sem); i++ { sem <- struct{}{} }

セマフォを使ってI/O処理の同時実行数を手軽に制御する方法

Worker Poolパターンについて簡単に整理。複数ワーカーが順番にジョブ取って、処理する仕組み。もし途中でエラーやタイムアウト出たら全体ごと一気にストップできる感じ。えーと、「Task」インタフェースにはDo(context.Context) errorだけあって、それらをRunPool関数にまとめて渡すイメージだな。あと並列の人数(workers)も自由設定可能。

実装手順→まずjobs用のチャネルを作成。その後、errgroup.WithContextでグローバルなキャンセル管理とエラートラッキング兼ねる。それぞれのワーカーはg.Go使い無限ループ中、select文でctx.Done()監視→終了時は即座に抜ける設計。そしてjobsチャネルからTask拾ったタイミングだけTask.Do実行…こんな流れ。ただしタスク実行中どれか一つでも失敗した瞬間、残り全部巻き込んで即終了になるんだよね。

例えば動画エンコードとか「部分的成功だと意味ない」系にはこれ最高。1個でもNGなら全体やり直した方がいい場合多いから。ただ逆に「せっかくなので全部走らせて失敗も網羅したい!」というニーズの場合、この方式だとうまく噛み合わないこともある気がする。この時点でcancelポリシー自体がかなり明確化されちゃうので、その割り切り前提となるよね。

もうひとつ違うアプローチとして、面倒なキュー無しでgoroutine上限だけサクッと指定したい場合はerrgroup.SetLimit活用。一人一goroutine—要するに「1job, 1go」で進めるやつ。ただGo 1.20+なら最大同時数(例:128)しっかり制御できて暴走防止も万全。その各goroutine内でcontext.WithTimeout等ももちろん併用可。「全部並列動作させたいけどコアへの負担&異常管理をもっと簡単化したい」とき重宝するパターン。

まあ結局、一番重要なのはfailure時の方針設計。誰かこけたら直ちに全員停止? それとも関係なく完遂させて失敗ログまとめ? どっち選ぶかによって仕組み根本変わる感じだと思うわ。

セマフォを使ってI/O処理の同時実行数を手軽に制御する方法

ワーカープールで安全なキャンセル管理と失敗時の振る舞いを構築する

うわーこれ!スキャッターギャザー系のジョブなら、自前でプール組むより絶対こっち使ったほうが神ってる!!!本当にヤバいよ!ポイントはね、各ワーカーでローカル状態とかキャッシュ(つまりhot cacheみたいなやつ)要らない場合限定ってこと!これ知らずに適用しちゃダメなやつな!

CPUガツガツ回すタスクの場合さ、結局ゴルーチンの数をコア数ジャストに揃えるのが正義っぽい。タスク無理やり増やしても効果ほぼナシだし、てかむしろコア超えた辺りから体感変わらなくなるんだよねー。runtime.GOMAXPROCS(0)で現状動いてるCPUスレッド数調べて、それfor文でぶん回すスタイルが安定じゃない?ね。

例えばだけど、jobsっていうチャネル立てて、sync.WaitGroup作るじゃん?そしたらコアn個分wg.Add(n)って足してから、i=0~n-1までgo func回すだけ!あとは各workerがjobsからWork拾ってきたらw.Calc()呼び出す感じ。もうお決まり!

inputs配列(全部の仕事ぶちこんどくリストね)をfor文でjobsへ流してくだけ。終わったらjobsクローズしてwaitするだけ!ヤバくない?めちゃ簡単…ていうか、余計なコンテキストスイッチも激減するからマジでスループット安定するっぽいわ。

たださ、処理時間めっちゃばらつくときあるじゃん?そういうときは、小さめサイズでほんのちょいオーバーサブスクライブ(例えばe.g.くらい)の方が良かったりも。でもこれは案件によるから実際に動かしながら様子見だね〜!!!

errgroup.SetLimitで簡単に並列数をコントロールしタスク効率化につなげる

あー最初に結論言っちゃうけど、いい?えっとね、「[, 2× cores)」みたいにスレッド数をコアの2倍とかにしてみるとtail latencyのバランスが一応整う…かもしれない!けど、それって本当に自分で測定しないと何とも言えないし、多分だけどケースバイケースだと思うんだわ。あと、もうほんと強く言いたいけど、小さなCPUタスクごとに毎回Goroutine作るみたいなの絶対やめた方が良いって!うん、本当これは。「バッチ化」がマジ大事だよ、意識すると驚くほどオーバーヘッド消えるからさ!この点、多くの人忘れちゃいがちだけど、大きな差出るから気をつけて!

で、次は負荷制御なんだけど…これメチャクチャ大事ね。真ん中じゃなく入り口部分で「Backpressure(バックプレッシャー)」かけないとダメ、特に負荷掛かりそうなところにはちゃんと限度入れてよ!たとえば「API裏でファンアウト」「DBアクセスやHTTP経由」「キャッシュ/キューへ投げる」とこ全部、それぞれ [limit] 置こうぜ?実はグローバルプール1個体制だとホットスポットも隠れること多くなるから要注意なんだよ。全体ストップとか泣ける。だからDBならDB用、外部APIにも専用の上限値設定する、これ鉄則ね。

最後!絶対守ってほしいポイントあるよ。同時実行上限は「依存先ごと」でやらなきゃダメ。一括じゃなく各リソースごとな。「サイズの決め方」はSLAガチ基準がおすすめ!でさ、「Queue満タンなら即エラー返す」ルール徹底、これ超基本。細かい管理をサボっちゃうと詰む状況ガンガン来るぞ!?気を抜かず管理してこ!!

errgroup.SetLimitで簡単に並列数をコントロールしタスク効率化につなげる

CPUバウンドタスクではGOMAXPROCS値で実行数調整してリソース最適化する

うーん、そもそもリソースの制限って、ちゃんと現実的なボトルネック基準で考えないと本当に意味薄いかもしれないね。まあ、適当な数字をなんとなく設定するだけだと後々トラブルになる予感がある。大事なのは根拠。

あとさ、Go使ってる時に地味に面倒なのがゴルーチンリークとか詰まりやすいキューかなぁ。ここ油断できないポイントだと思う。例えばなんだけど、チャンネルで受信し続けてるパターンで、生産側が絶対チャンネル閉じないままだと…あれ?ずっとfor v := range ch { use(v) } みたいにしちゃって、そのまま詰むよな、普通に。

依存先ごとにバックプレッシャー設計しボトルネック回避へつなげる

APIのレートリミット、大事なのはまず外部サービスが決めてるクォータを絶対守ることね。Goだと例として rate.NewLimiter(rate.Every(10*time.Millisecond), 5) みたいな感じ、これで「1秒にざっくり100回」ってイメージ。でもlocal側だけCPU効率とか上げてもさ、API制限でBANされたら本当に意味ないと思うな。

複数リクエストまとめて投げたい場合、errgroup.WithContext(parent) を使ってゴルーチン展開するパターン多い。その時も全部rate limiter通す感じで、もしlim.Wait(ctx)でブロックされたら、ちゃんとその場で止まってくれる仕組み。同時処理してても結局「外部サービス側が設定したQPS」ぴったりに合わせて動くイメージになるよ。

あとプールね。ローカルでCPUや一時的な負荷吸収する用途ならプール(バッファ)が役立つ。でもremote APIのQPS規制とは別の話だから、「API予算管理=リミッター」「ローカル効率化=プール」ときれいに役割分け考えると安定しやすい。どっちかだけよりセット運用おすすめかも。

さらに偏り対策。1テナント(ユーザーとかアカウント)だけ優遇されちゃうの嫌なら、「キーごとにtoken bucket作る」手法も有効かもしれない。一個しかバケツ無いとヘビーな依頼が他を押し除けちゃうので、キーごと分割すると平等感あるよ。

あ、それと、小さい作業が山ほどある場合、一件ずつ逐次処理するよりbuffer入れてまとめてflushしたほうが全体コスト下げられること多い。この例だと最大64件item詰めて、5msおきにticker回してflush(buf)実行。ログ送信とかメトリック系は「ちょっと遅れてもOK」前提だからこそこの方法向いてると思うんだわ。

最後にchannel経由パターン。in channel閉じた瞬間も即座にflush(buf)してreturn。一方buf満タンでもflush。他にもticker.Cで定期flush、と何重ものタイミング用意して抜け漏れ防ぐ形。連続syscall減らせたりヒープ割り当ても減少傾向だから、多分すごく効率いい仕組みじゃないかな。

依存先ごとにバックプレッシャー設計しボトルネック回避へつなげる

ゴルーチンリークやキュー停止リスクを素早く検知・防止するコツ

タスクがサブミリ秒単位なら、まとめてバッチ処理した方が手っ取り早い。正直これだけは外せないんだよな。経験的に思ったことだからまあ、間違いないと思うけど。

なんか「グローバルプール」って一見便利そうに感じるんだけど、実際やってみたら本当微妙だった。別のエンドポイントなのに一箇所スパイクすると他も全部止まるみたいな現象が起こってイライラ。全然スマートじゃないわ。

で、その解決策としては依存ごとに制限設けることね。それと並列化するときは`errgroup.SetLimit`を使う。このパターンがやっぱり一番安定してた気がするな。

適当に確認したいとき、自分の判断ミスってないか簡単にチェックしたいなら、とりあえずコード書いたあとローカルでトレース取るのおすすめ。「go test -run=NONE -bench=YourBench -trace trace.out ./...」これ叩いて、「go tool trace trace.out」で動きを見る感じ。シンプルだよ。

ついでにゴルーチンとかヒープ状況ざっと見たいなら「go test -run=NONE -bench=YourBench -cpuprofile cpu.out ./...」を使ってCPUプロファイル出して、「go tool pprof cpu.out」で分析できる。すぐ試せて手間かからんから結構楽。

並列度しっかり調整できてれば、負荷高くてもゴルーチン数はそこそこ安定してるし、チャネル待ちも無駄に長引いたりしないと思う。計算タスク系ならCPU使用率もちゃんと維持される…はず。不適切だと逆にはっきり症状出てくるんだよな。例えばキュー詰まり始めたりアイドルワーカーばかり増えたり、なんかそんな傾向。

念のため簡単なチェックリストだけ貼っとく。「負荷時にゴルーチン数 plateau していること」、これは必須。他にも状況次第で色々見る項目あると思うけど、この点だけは気をつければ損なし。まぁそんな感じかな。

レートリミット・バッチ処理・トレース活用で生産性向上につなげる

ねぇ、P95レイテンシ、キャップ付けるだけで爆下がり!ヤバい、グラフ見ると明らか!!トレース確認すると長時間詰まりのスタック消えてる、スカっとして気持ちいいわ。APIのエラー率も、リミッター導入したら下がるね。マジ数字に正直、はっきり結果出るの、最高。

一気にまとめると、「Goroutine」はめっちゃ軽量!!だけど…あのね、同期まじで悩みポイントだな。現場だとそここそ勝負、運用者的には「いかに制御するか」みたいな場面多発だと思う!だからI/Oブワーっと並べたくなったら、ゴリゴリ好きにgoroutine起こせばOK。ただしね、セマフォ挟むとかerrgroup.SetLimitとか――ああいう道具でちゃんと制限入れると事故防げる。失敗談多いし!

てか、「さらに上」を目指したくなったらどうすんの?うーん、単純制約以上に状態制御や確実なキャンセル・抑止制御(バックプレッシャー)まで欲しくなったら、その時はさっさとワーカープール方式へ舵切る方がシンプルで早いっぽい気がしてきた。

そうそう、CPUが主役になる系タスク。CPUコア数分だけ並列回し&細切れ作業をまとめてバッチ化!これ意外とサクサク進むケース多いよ。でも、「限界」をどこに置くか超重要!!結局さぁ、どこで実際止まってんのか、tracingとかpprof殴って特定しないと、意味ない。思い込み禁物!!!わかった?

Related to this topic:

Comments