PythonのマルチスレッドWSGIサーバーの選定

今までuWSGIをシングルスレッド、マルチプロセスで使っていたのだけれども、昔に比べて外部のAPI呼び出しが増えているのでマルチスレッド化を検討している。

uWSGI

uWSGIでマルチスレッドを有効にした時は、各workerスレッドがacceptする形で動作する。スレッド数以上の接続をacceptすることがないので安心。

プロセス内のスレッド間ではmutexで排他されて、同時にacceptを実行するのは1スレッドのみに制限されている。つまりthendering herd問題はプロセス間でしか起こらない。マルチスレッド化でプロセス数はむしろCPUコア数まで減らせるので、thendering herd問題はむしろ今よりも軽減できる。(ちなみにプロセス間でもロックしてthendering herdを許さないオプションもあるけど、プロセス間同期は怖いので使っていなかった。)

ただしuWSGIのマルチスレッド対応は、graceful shutdownの動作に問題がある。終了時に pthread_cancel() を利用してworkerスレッドを止めようとしているために、うまく終了できずにハングすることがある。特にメモリ使用量が増えたときにworkerを再起動する reload-on-rss や、指定回数リクエスト処理したあとに再起動する max-requests オプションを使う場合にこの問題を踏んでしまう。先週から今週にかけてこの問題をデバッグしていて、 pthread_cancel() を使うのを止めない範囲で他スレッドの終了待ちをもっと丁寧にするプルリクエストと、pthread_cancel()を使うのを止めるプルリクエストを作った。

なお、uWSGIはメンテナンスモードに入っていて、一人のメンテナがアクティブに月に数件〜数十件のプルリクエストを処理してくれているものの、将来を考えると他の選択肢も考慮しておきたい。

Gunicorn/gthread

Pythonで一番使われているWebサーバーはGunicornだろう。あちこちのドキュメントでサーバーを立てる手段として紹介されている。

Gunicornはworkerを選択できて、gunicorn本体に同梱されているマルチスレッドのワーカーとしてgthreadがある。ちなみに後述するUvicornもGunicornのworker classを提供しているので、Gunicornのプロセス監視機能を利用できる。

gthreadの動作は、メインスレッドでaccept loopを回して接続を受け付けて、その接続をスレッドプールで処理するモデルになる。このモデルではワーカースレッド数以上の接続を受け付けないかが心配になるが、Gunicornには同時接続数を制限する worker_connections オプションがあり、gthreadは接続数の上限に到達した場合は新規のacceptを止めてくれる。acceptを止めている間は、他の余裕のあるworkerプロセスがacceptしてくれるか、リクエストが待たされることになる。gthreadを使う場合はこのオプションを設定しておくと良いだろう。

gthread workerはシングルスレッドのsync workerと同じく、HTTP parserが独自のpure Python実装になってしまっているので、性能面では uWSGI に劣る。非常に高いrequest/secを必要とする場合は慎重に性能を評価するべきだろう。

後で時間ができたときにこの部分の性能改善にも取り組んでみたい。

Uvicorn

別の人気のあるWebサーバーとして、FastAPIが推奨しているUvicornがある。

UvicornはHTTP parserとして httptools を使える。 httptools は実装として node.js と同じ llparse を使っているので、リクエストを受け付ける速度はGunicorn+gthreadよりも速いだろう。実際、FastAPIはUvicornを使うことで高いパフォーマンスを実現している。

しかし、UvicornはasyncioをベースにしたASGIサーバーだ。WSGIアプリケーションを動かすためには、a2wsgiというライブラリを使って、ASGIリクエストを受け取ってWSGIリクエストに変換し、スレッドプールの中でWSGIアプリケーションを動作させることになる。このオーバーヘッドがあるので、WSGIアプリケーションはUvicornの性能を最大限に活かすことはできないだろう。

そして問題なのが、Uvicornにはgthreadのworker_connectionsオプションに相当するオプションがないことだ。

このため、nginxの後ろで1つのlisten socketをマルチプロセスで処理しようとすると、一部のプロセスに接続が集中して処理速度が悪化する可能性が出てくる。これを避けるためにはUvicornやGunicornのオプションで1つのインスタンスのワーカーをマルチプロセス化するのではなく、シングルプロセスのUvicornインスタンスを複数立てて前段で負荷分散するような構成が必要になってくる。

k8s等を使っていてすでにこういう構成になっているとか、簡単にその構成に対応できる場合を除いて、Gunicornの方が不安の少ない選択肢になると思う。または、非常に高いrequest/secを実現したくてUvicornを選定するのであれば、アプリケーションもASGIに移行するべきだろう。

Nginx Unit

まだあまりPython界で広まってなさそうに見えるが、uWSGIからの移行先としては有望かもしれない。今後時間があるときに調査してみる。

github.com

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