VISASQ Dev Blog

ビザスク開発ブログ

gunicorn/gthreadのロードバランシングを調査

gunicorn/gthreadのロードバランシングを調査

基盤チームの寺坂です。
エンジニアの皆さま(あるいはそうでなくても皆さま)におかれましては「前提を確認する」ことの重要性を存じているかと思います。

以前、他のエンジニアが調査したgunicornのパフォーマンスについて、さらに深掘りしてみました。

低ワークロードであれば理論値が出ると思い込んでいた私にとっては信じたくない程度には衝撃的だったので、自分でも調べてみたいと思っていました。

tech.visasq.com

概要・結論

本記事では、実環境での発生状況は別として、技術的な観点で原因の理解を試みます。
パフォーマンスを安定させたいものの、容易に理論値から乖離します(させることができます)。

リクエストを処理するworkerプロセスに偏りが生じることがありますが、対処としてreuse_port=Trueがあります。
とはいえ根本的に解決されるわけではないので、実運用ではgunicorn以外の調整と組み合わせて現実的な落としどころを探る必要がありそうです。

前提

  • gunicorn/gthread環境. WSGIアプリ.
  • gunicorn: 2025-08現在のGitHubのorigin/master [a86ea1e]
    • 現最新23.0.0ではreuse_portは機能しません。gunicorn#2938 が含まれていないからです。

理論値との乖離

gunicorn/gthread環境において、リクエストの最大並行処理数 workers * threads に対して実際の並行処理性能が偶発的に低下するケースがあります。

理由は、gunicorn自体はどのworkerプロセスが接続要求を掴むかを能動的にはロードバランシングしておらず、OS(カーネル)の振る舞い1や各workerプロセスによる自然な競争(早いもの勝ち)に任せているからです。

OSはworkerプロセスの負荷状況を考慮しません。
したがって、偶発やOSのスケジューリング特性などから特定のプロセスへ偏ることがあります。

特に高レイテンシなリクエストが混在すると「後続の軽いリクエストが先行する重いリクエストに巻き込まれる」という状況が発生しやすくなります。


接続要求のロードバランシングに関する一般的な背景に関してはAIに次のように質問するとthundering herd, SO_REUSEPORT, EPOLLEXCLUSIVEなどをキーワードとしてよく訓練された返答をしてくれます。

  • 「 pre-forkモデルのWebサーバーの実装において、接続要求はどのようにworkerプロセスへロードバランシングされますか? 」
  • 「各workerプロセスがepollでlistening socketのイベントを待っている場合はどうなりますか?」

再現

恣意的ですが、いくつかの再現実験をします。

注目しているのはgunicorn/gthreadの振る舞いなので、実環境である必要はありません。
ここではローカル環境での結果を載せています。

前提

  • Linuxカーネル(Docker Desktop可)
  • 疎通経路はリバプロ->gunicorn
    (この記事で説明している範囲に関係あるかは未確認)
  • HTTP/1.1
  • gunicornの設定値 reuse_port=False ; keepalive=0
  • WSGIアプリ: 以下のようにモックとして実装
def app(environ, start_response):
    """ ?delay=N を受け取って sleep(N) して返却。workerプロセスのpidを含める。"""
    delay: float = parse_delay_param(environ)  # ?delay=N パラメーターの取得
    time.sleep(delay)
    response_body = {"pid": os.getpid()}
    ...

thundering herdの発生確認

gunicorn - FAQ > Does Gunicorn suffer from the thundering herd problem?

古きよきprintデバッグでわかります。

gunicorn --access-log=-  --workers=4
diff --git a/gunicorn/workers/gthread.py b/gunicorn/workers/gthread.py
@@ -118,6 +118,7 @@ class ThreadWorker(base.Worker):
 
     def accept(self, server, listener):
         try:
+            self.log.info(f"Trying to accept a new connection: pid={os.getpid()}")
             sock, client = listener.accept()
             # initialize the connection object
             conn = TConn(self.cfg, sock, client, server)
@@ -131,6 +132,8 @@ class ThreadWorker(base.Worker):
             if e.errno not in (errno.EAGAIN, errno.ECONNABORTED,
                                errno.EWOULDBLOCK):
                 raise
+            else:
+                self.log.info(f"🚫 Failed to accept(): pid={os.getpid()}")
 
     def on_client_socket_readable(self, conn, client):
         with self._lock:
Trying to accept a new connection: pid=7
Trying to accept a new connection: pid=8
Trying to accept a new connection: pid=9
Trying to accept a new connection: pid=10
🚫 Failed to accept(): pid=7
🚫 Failed to accept(): pid=10
🚫 Failed to accept(): pid=8

accept()の前にsleep()を入れると確実になります)

リクエストを処理するworkerプロセスが偏ることの確認

30秒で700リクエストを捌いた時の、リクエスト数とpid:

[reqs]    [worker pid]
  46         7
  44         8
  58         9
  59        10
  64        11
  72        12
  96        13
 265        14

コード

コードやコマンドは抜粋です。

gunicorn --workers=8 --threads=4

k6 run --out json=results.json.gz test.js

# pidカウント
gzcat results.json.gz | jq 'select(.type=="Point" and .metric=="count") | .data.tags.pid | tonumber' | sort | uniq -c | sort -n -k2
// Grafana k6
const count = new Counter("count");

export const options = {
  vus: 30,
  duration: "30s",
};

export default function () {
  const delay = Math.random();
  const res = http.get(`${baseUrl}/?delay=${delay}`);

  const pid = res.json("pid");
  count.add(1, { pid: '' + pid });
}

補足

ローカル環境なので理論値workers * threads = 32に対して30並行は遅滞なく処理できることが期待(duration < 約1s)されますが実際には何らかの遅延が生じていることがわかります。

HTTP
http_req_duration...: avg=1.31s min=1.77ms med=797.13ms max=3.98s p(90)=3.02s p(95)=3.27s
http_reqs...........: 705    21.639633/s

余談ですが、コード生成に長けたAIに以下のようなプロンプトを投げればほぼ同じものを生成してくれます。

Python, gunicorn/gthreadの環境において、リクエストのworkerプロセスへのロードバランシングを確認したいです。
そのために以下の要件でWSGIアプリとGrafana k6テストを作成し、その結果の確認方法も教えてください(ワンライナーのコマンドで十分です)。

- WSGIアプリ: レスポンスにworker pidを含める
- k6:
  - worker pidをメトリクスのタグとして記録する
  - 実行結果を.json.gzで保存する

先の記事の再現

遅滞のある並行処理

単純に理論値近くの並行リクエストを投げ続けるだけでも顕在化しますが、重いリクエストを混ぜるとわかりやすくなります。
まさに先の記事が行った検証と同じです。

コード

gunicorn --workers=4 --threads=2

K6_SUMMARY_MODE=full k6 run --out json=results.json.gz test.js
// Grafana k6

export const options = {
  scenarios: {
    light: {
      exec: "light",
      executor: "constant-vus",
      vus: 5,
      duration: "5m",
    },
    heavy: {
      exec: "heavy",
      executor: "constant-vus",
      vus: 2,
      duration: "5m",
    },
  },

  summaryTrendStats: ["avg", "min", "max", "p(50)", "p(60)", "p(70)", "p(80)", "p(90)", "p(99)"],
};

const overheadLight = new Trend("overhead_light", true);
const overheadHeavy = new Trend("overhead_heavy", true);

export function light() {
  const delay = Math.random();
  send(overheadLight, delay);
}

export function heavy() {
  const delay = 10;
  send(overheadHeavy, delay);
}


function send(metric, delay) {
  const res = http.get(`${baseUrl}/?delay=${delay}`);
  if (res.status === 200) {
    const pid = res.json("pid");
    metric.add(res.timings.duration - delay * 1000, { pid: '' + pid });
  }
}

結果

overhead_heavy:
  min=5.32ms  p(50)=869.85ms  p(60)=1.03s   p(80)=1.33s  p(90)=2.1s  p(99)=3.1s
overhead_light:
  min=2.83ms  p(50)=291.98ms  p(60)=703.42ms   p(80)=1.7s  p(90)=2.79s  p(99)=10.55s  
# pidカウント
overhead_light:
[reqs]    [worker pid]
 113         7
  95         8
 132         9
 455        10

overhead_heavy:
[reqs]    [worker pid]
   4         7
   8         8
   7         9
  37        10

条件
  • worker * threads = 4 * 2 = 8
  • 軽量リクエスト: 5並列
    • delay(sleep時間)はランダム[0,1]s
  • 重いリクエスト: 2並列
    • delay=10s
計測値
  • overhead_light = ラウンドトリップ時間 - delay時間
    • ローカル環境であることを踏まえると、overhead_*時間は実質的に「想定外にかかった時間(何らかの待たされた時間)」を意味します。
結果
overhead_light:
  min=2.83ms  p(50)=291.98ms  p(60)=703.42ms   p(80)=1.7s  p(90)=2.79s  p(99)=10.55s

理想的なロードバランシングが行われていればリクエストは遅延なく処理され、overhead_lightは小さい値で安定するはずです。
しかし実際には安定していません。

補足1

worker * threads = 1 * 8とした場合:

overhead_light:
  min=1.43ms  p(50)=7.33ms   p(60)=8.12ms   p(80)=9.65ms  p(90)=10.4ms  p(99)=13.07ms

(しかし、なぜだか「GIL... GIL...」と聞こえてくる気がします)

補足2

reuse_port=True(後述)とした場合:

overhead_light:
  min=3.3ms  p(50)=10.43ms  p(60)=81.48ms   p(80)=679.69ms  p(90)=1.11s  p(99)=8.02s
overhead_light:
[reqs]    [worker pid]
 407         7
 380         8
 422         9
 393        10

理解してみる

改めて前提: reuse_port=False -> masterプロセスが作成したlistening socketを全workerプロセスが共有している

どのgthread workerプロセスが接続要求を掴むかは以下の経路で決まります

  • 各workerプロセスはそれぞれのself.poller = selectors.DefaultSelector()Linuxではepoll)でlistening socketを監視する
  • 接続要求の到着時、基本的には全てのworkerプロセスのpoller.select()がそのイベントを受け取る
  • 最初にlistener.accept()を呼んだworkerプロセスが接続を掴み、他は待機に戻る

  • 実測では接続を掴むworkerプロセスが偏るので、イベントの発火タイミングやCPUスケジューラーによるプロセス処理順に偏りがあると思われる(未精査)

理解を裏取りする・実験

同じ状況設定で、gunicorn側に調整をいれることで実験します。

すごく雑なロードバランシング

調整
  • listener.accept()の直前に、無条件にsleep(random.uniform(0, 0.01)) を入れる
    • どのworkerプロセスがlistener.accept()を取るかが、ほとんどこのsleep()時間に支配されるようになる(と思われる)
結果
overhead_light:
  min=3.44ms  p(50)=13.76ms  p(60)=37.82ms   p(80)=594.87ms  p(90)=1.04s  p(99)=8.06s

overhead_light:
[reqs]    [worker pid]
 420         7
 382         8
 426         9
 406        10

概ねworkerプロセスが均等に利用されるようになります。

雑なロードバランシング

各workerプロセスは他のworkerプロセスの忙しさは把握できませんが、自身の忙しさは把握できます。
条件付きでsleepするようにします。

調整
  • listener.accept()の直前に条件付きでsleep(0.001)を入れる
    • 自身が忙しいときに限りsleepすることで、余裕のある他のworkerプロセスに先行してもらいやすくする

コード

@@ -119,4 +119,10 @@ class ThreadWorker(base.Worker):
     def accept(self, server, listener):
+        nr_running_or_pending = sum(1 for f in self.futures if not f.done())
+        low_priority = nr_running_or_pending >= self.cfg.threads
+        if low_priority:
+            time.sleep(0.001)
         try:
             sock, client = listener.accept()
+            if low_priority:  # 様子を眺めるためのおまけ.
+                overflow = nr_running_or_pending - self.cfg.threads
+                self.log.warning(f" 😌  Accepted connection in low priority: pid={os.getpid()} {'🔥' * overflow}")
             # initialize the connection object

結果:
overhead_light:
  min=2.06ms  p(50)=7.64ms  p(60)=8.33ms   p(80)=9.86ms  p(90)=10.73ms  p(99)=15.33ms

[reqs]    [worker pid]
 720         7
 586         8
 856         9
 746        10


overhead_heavy:
  min=3.16ms  p(50)=7.84ms  p(60)=8.58ms   p(80)=9.62ms  p(90)=10.8ms  p(99)=205.35ms

[reqs]    [worker pid]
   9         7
  22         8
   9         9
  20        10

worker * threads = 1 * 8とした場合と同様に、遅延が生じていないことがわかります。

重いリクエストを受け持ったworkerプロセスに偏りはありますが、軽いリクエストの分配によって相殺される傾向も見て取れます。(何度か実行して傾向を見ています)

設定値について

reuse_port=True

workerプロセスへのロードバランシングが、実質的にOSによる制御(SO_REUSEPORT)のみで決まります。

  • masterプロセス由来のものを共有せず、各workerプロセスがそれぞれ独自のlistening socketを持つようになる
    spawn_worker() -> create_sockets()
  • 接続要求に対してOSが一つのlistening socketを選ぶ(つまり一つのworkerプロセスを選ぶ)
    • この分配はおおむね均等になる2
    • workerプロセスの忙しさが考慮されるわけではない
  • thundering herd問題が生じなくなる

keepalive = N

後続のリクエストが確立済みの接続を再利用する場合、reuse_portなどの接続確立時のロードバランシングは作用しません。

後続のリクエストは接続を確立した後のロジックに直接入ります。
つまり、その接続を持っているworkerプロセスに直接キューイングされます。
finish_request() -> on_client_socket_readable() -> enqueue_req()

とはいえ、reuse_portによって最初の接続時の偏りを減らすことは、結果的に全体の偏りを軽減することに繋がります。

おわり

先の記事で確認された振る舞いの原因を、さらに一段深掘りしました。

この問題をgunicorn/gthread下で根本的に解消するのは難しく、
実際的な対応としてはASGIへの移行、より前段でのロードバランシング、あるいはスケールアウトといったマルチプロセスとは別のアプローチを取るのが筋のようです。

単純な緩和策としては、threadsに余裕をもたせるという手が考えられます。


ビザスクではエンジニアの仲間を募集しています! 少しでもビザスク開発組織にご興味を持たれた方は、ぜひ一度カジュアルにお話ししましょう! developer-recruit.visasq.works


Footnotes