Gunicorn の gthread と freethreading

前回は sync worker が uwsgi の置き換えになるかという視点で調べていましたが、次は gthread について調べてみます。 sync worker はシンプルに次のような処理をしていました。

  • accept (複数socketをlistenしている時は select->accept)
  • リクエストを受け取る
  • wsgiアプリを実行する
  • レスポンスを返す
  • close

gthread は次のような処理になります。

  • メインスレッド
    • epoll等で listen socket やリクエスト待ちの接続を監視する
    • listen socket の場合: accept してスレッドプールに投げる
    • リクエスト待ちの接続の場合: スレッドプールに投げる
  • スレッドプール
    • リクエストを受け取る
    • wsgiアプリを実行する
    • レスポンスを返す
    • keep-alive可能な場合はメインスレッドの監視対象に戻す。それ以外はclose

ポイント

  1. keep-aliveに対応している。keep-alive中の接続はスレッドを占有しない。
  2. スレッド数の上限以上にkeep-alive可能。worker_connectionsという設定で接続数の上限を指定し、その接続数に到達したら新規acceptを止める。

nginx などのリバースプロキシの後ろで動かす場合、クライアントとのkeep-aliveはリバースプロキシ側でやっているので、 gthread の worker_connections はスレッド数と同じにするのが良いと思います。でないと余裕があるworkerよりも先にスレッドが埋まってるworkerがacceptしてしまう可能性があるので、負荷の偏りがひどくなり、99%tileのレスポンスタイムが酷く悪化する可能性があります。

freethreadingとの相性

よく知られている通りPythonにはGILがあり複数スレッドが同時に動くことができません。gthreadの場合メインスレッドもGILを必要とするので競合が発生しやすくなります。競合するとオーバーヘッドが発生するので、例えば3スレッドがGILの取りあいをしているとスレッドあたりの性能が3分の1以下に下がることもあり得ます。

もし1workerでそのマシンのCPU利用率を十分に出せるまでスケールするのであれば1workerだけで動かすことも可能で、その場合は上で述べたworker間の負荷分散の問題にも悩まされなくなります。

試しに gthread を 3.14と3.14tで動かしてみます。

# gunicorn_conf.py
workers = 1
threads = 2
accesslog = None
bind = "127.0.0.1:5000"
# benchmark_app.py
def application(environ, start_response):
    body = b"Hello, World!\n"
    start_response(
        "200 OK",
        [("Content-Type", "text/plain"), ("Content-Length", str(len(body)))],
    )
    return [body]

起動コマンド:

$ .venv/bin/gunicorn  -c gunicorn_conf.py benchmark_app:application

ベンチマークコマンド:

$ ./hey -c 2 -z 10s http://127.0.0.1:5000/

結果

# 3.14
Summary:
  Total:        10.0003 secs
  Slowest:      0.0046 secs
  Fastest:      0.0001 secs
  Average:      0.0002 secs
  Requests/sec: 10032.0812

  Total data:   1404536 bytes
  Size/request: 14 bytes

# 3.14t
Summary:
  Total:        10.0018 secs
  Slowest:      0.0057 secs
  Fastest:      0.0001 secs
  Average:      0.0001 secs
  Requests/sec: 17708.5014

  Total data:   2479638 bytes
  Size/request: 14 bytes

2倍にまではなりませんでしたが、かなりの性能向上をしているのがわかります。

ちなみにGIL版の性能は前回の記事のsync workerに若干負けています。HTTP keep-aliveを使って再接続がなくなる分よりも、mainスレッドとスレッドプール間でのやりとりが発生する方が重いのでしょう。

まとめ

単純に性能が欲しい時はsync workerが一番良いです。負荷分散が理想的で、freethread版Pythonを使わなくてもGILがほとんど問題にならないのでOSのフェアなスケジューリングにも助けられます。時々遅くなる外部APIを利用していて、その時にworkerが詰まってサービスが停止するのを回避したいという時にはgthreadが選択肢になるでしょう。

このブログに乗せているコードは引用を除き CC0 1.0 で提供します。