uWSGIでマルチスレッド利用時のプロセス間ロードバランス
概要
私は、uWSGIを使う時は基本的にマルチプロセス、シングルスレッドの設定を推奨します。しかし、レスポンスタイムがときどき遅くなる外部API呼び出しを含む場合など、メモリ使用量やthundering herd問題を考慮しつつ多くの並列数が必要な場合にマルチスレッドとマルチプロセスを組み合わせてより高い並列数を稼ぎたい場合があります。
マルチスレッドとマルチプロセスを併用する場合、プロセス間でリクエストをうまく分散させるロードバランスが問題になります。この記事ではその問題と解決方法について実験を交えて解説します。
マルチスレッド
まずはマルチスレッドの特性を確認していきましょう。サンプルとしてこんなwebアプリケーションを用意します。実験に使っているPC上では、 fib(25) 1回あたりに約10msかかり、全体で100ms程度かかっています。
# wsgi.py import time import threading def fib(n): if n < 2: return n return fib(n-1) + fib(n-2) def app(environ, start_response): for _ in range(5): time.sleep(0.01) # 10ms fib(25) # about 10ms start_response("200 OK", [("Content-type", "text/plain; charset=utf-8")]) return [b"Hello, world"]
これを次のような設定で nginx + uWSGI で動かします。
# nginx.conf worker_processes 1; pid nginx.pid; error_log error.log; events { worker_connections 768; } http { include mime.types; access_log access.log; upstream myapp { server 127.0.0.1:5000; } server { listen 8000 default_server; server_name _; location / { include uwsgi_params; uwsgi_pass myapp; } } }
# uwsgi.ini [uwsgi] socket = 127.0.0.1:5000 master = 1 pidfile = uwsgi.pid die-on-term = 1 lazy-app = 1 disable-logging=1 threads = 4 module=wsgi callable=app
wrkを使って1, 2, 4並列で負荷をかけてみます。
$ wrk -t1 -c1 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 1 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 107.36ms 535.89us 112.24ms 96.77%
Req/Sec 9.62 1.32 10.00 92.47%
Latency Distribution
50% 107.30ms
75% 107.47ms
90% 107.68ms
99% 111.54ms
186 requests in 20.03s, 36.69KB read
Requests/sec: 9.28
Transfer/sec: 1.83KB
$ wrk -t1 -c2 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 2 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 117.63ms 3.49ms 140.45ms 88.82%
Req/Sec 17.43 4.36 20.00 74.47%
Latency Distribution
50% 116.27ms
75% 116.54ms
90% 124.00ms
99% 129.20ms
340 requests in 20.03s, 67.07KB read
Requests/sec: 16.98
Transfer/sec: 3.35KB
$ wrk -t1 -c4 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 4 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 231.09ms 27.29ms 315.45ms 66.86%
Req/Sec 17.43 6.86 30.00 48.07%
Latency Distribution
50% 229.52ms
75% 248.26ms
90% 272.18ms
99% 300.65ms
344 requests in 20.03s, 67.86KB read
Requests/sec: 17.18
Transfer/sec: 3.39KB
2並列の場合はレスポンスタイムが1割ほど悪化しつつ、スループット(req/sec)は2倍弱に増えています。4並列ではスループットが2並列とほぼ同じで、それに応じてレスポンスタイムが悪化しています。
これは1並列時のレスポンスタイムのうち約50%がPythonのGILを必要としているためで、マルチスレッドでは2並列までしかスケールしません。
マルチプロセス
次にマルチプロセスでの特性を確認します。先ほどの設定の threads=4 を workers=4 に書き換えて、今度は4,8並列での結果をみていきます。
$ wrk -t1 -c4 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 4 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 108.95ms 3.10ms 142.10ms 88.95%
Req/Sec 36.70 5.93 40.00 72.86%
Latency Distribution
50% 107.89ms
75% 109.99ms
90% 112.21ms
99% 119.35ms
733 requests in 20.04s, 144.60KB read
Requests/sec: 36.58
Transfer/sec: 7.22KB
$ wrk -t1 -c8 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 8 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 212.50ms 7.43ms 232.21ms 98.54%
Req/Sec 38.17 5.99 40.00 90.16%
Latency Distribution
50% 212.84ms
75% 213.05ms
90% 213.28ms
99% 219.39ms
751 requests in 20.04s, 148.15KB read
Requests/sec: 37.48
Transfer/sec: 7.39KB
マルチスレッドと比較すると4並列まで綺麗にスケールしていることがわかります。
8並列にした場合はワーカー数が足りなくなり、平均レスポンスタイムが倍になっています。1つのlistenキューからacceptされたリクエストが順番に処理されるので、90%tileや99%tileのレスポンスタイムが平均から大きく乖離していないのは好ましい特性です。先ほどの4スレッドの1ワーカーに4並列でアクセスした場合に比べると、99%tileレスポンスタイムは2/3程度に抑えられています。
マルチプロセス * マルチスレッド
次はマルチプロセスとマルチスレッドを同時に利用します。
workers = 4 threads = 4
マルチプロセスの時と同じように4,8並列で負荷をかけてみます。
$ wrk -t1 -c4 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 4 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 185.46ms 50.06ms 298.03ms 60.70%
Req/Sec 21.77 9.33 40.00 64.92%
Latency Distribution
50% 194.01ms
75% 224.45ms
90% 245.66ms
99% 276.01ms
430 requests in 20.03s, 84.82KB read
Requests/sec: 21.47
Transfer/sec: 4.24KB
$ wrk -t1 -c8 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 8 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 187.29ms 52.88ms 382.29ms 60.63%
Req/Sec 42.60 14.37 80.00 67.34%
Latency Distribution
50% 189.82ms
75% 229.73ms
90% 254.79ms
99% 298.24ms
850 requests in 20.02s, 167.68KB read
Requests/sec: 42.45
Transfer/sec: 8.37KB
マルチプロセスの時と比べると8並列の時のスループットはよくなっているものの、99%tileのレスポンスタイムは逆に1.5倍に悪化しています。しかも4並列のときはレスポンスタイムだけでなくスループットも悪化しています。
これがプロセス間ロードバランスの問題です。各プロセスが最大4並列でリクエストを処理できるため、リクエストが1プロセスに集中する場合があり、99%tileのレスポンスタイムが1番最初に行った1プロセス4スレッドに4並列で負荷をかけた時の値に近づいてしまっているのです。
nginxを利用したプロセス間ロードバランス
この問題を解決するための方法として、ドキュメントの次のセクションで紹介されている方法がよさそうです。この方法とnginxのupstreamモジュールの機能を組み合わせてプロセス間ロードバランスを実現してみます。
Serializing accept(), AKA Thundering Herd, AKA the Zeeg Problem — uWSGI 2.0 documentation
次のように設定ファイルを書き換えて、uWSGIの各ワーカーがそれぞれ別のソケットをlistenし、nginxにそれらに対して least_conn でロードバランスさせます。
# uwsgi.ini # ... workers=4 threads=4 ; ソケットを4つ作る socket = 127.0.0.1:5000 socket = 127.0.0.1:5001 socket = 127.0.0.1:5002 socket = 127.0.0.1:5003 ; ソケットをワーカーにマッピングする。ソケット番号は0から、ワーカー番号は1から始まることに注意。 map-socket = 0:1 map-socket = 1:2 map-socket = 2:3 map-socket = 3:4
# nginx.conf # ... upstream myapp { server 127.0.0.1:5000; server 127.0.0.1:5001; server 127.0.0.1:5002; server 127.0.0.1:5003; least_conn; } # ...
uWSGI起動時に次のようなログが出力され、各ソケットがそれぞれ別のワーカーにマッピングされていることがわかります。
spawned uWSGI master process (pid: 42729) spawned uWSGI worker 1 (pid: 42730, cores: 4) spawned uWSGI worker 2 (pid: 42731, cores: 4) mapped socket 0 (127.0.0.1:5000) to worker 1 spawned uWSGI worker 3 (pid: 42732, cores: 4) mapped socket 1 (127.0.0.1:5001) to worker 2 spawned uWSGI worker 4 (pid: 42733, cores: 4) mapped socket 2 (127.0.0.1:5002) to worker 3 mapped socket 3 (127.0.0.1:5003) to worker 4
また4,8並列で負荷をかけてみます。
$ wrk -t1 -c4 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 4 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 108.78ms 2.52ms 122.69ms 84.51%
Req/Sec 36.88 6.84 40.00 78.89%
Latency Distribution
50% 107.95ms
75% 109.76ms
90% 112.23ms
99% 117.80ms
736 requests in 20.03s, 145.19KB read
Requests/sec: 36.74
Transfer/sec: 7.25KB
$ wrk -t1 -c8 --latency -d20s http://localhost:8000
Running 20s test @ http://localhost:8000
1 threads and 8 connections
Thread Stats Avg Stdev Max +/- Stdev
Latency 135.41ms 12.10ms 186.63ms 76.30%
Req/Sec 59.02 15.26 80.00 61.62%
Latency Distribution
50% 132.56ms
75% 141.02ms
90% 151.48ms
99% 176.16ms
1177 requests in 20.02s, 232.18KB read
Requests/sec: 58.79
Transfer/sec: 11.60KB
4並列ではマルチプロセスと同じ結果になり、8並列ではマルチプロセスの時よりもスループットも99%tileレスポンスタイムも大幅に改善しています。ロードバランスの問題が解決できていることがわかります。
一見理想的に見えますが、問題もあります。もし外部APIが詰まった場合はすべてのスレッドが詰まってしまうのですが、listenキューが1つの時は最初に空いたスレッドが次のリクエストを受け取れたのに、listenキューをバラバラにした場合は空いたスレッドが別のプロセスのlistenキューで待たされているリクエストを処理できないのです。
nginxのupstreamモジュールのドキュメントを読むと、 max_conns オプションで各upstreamサーバーへの同時接続数を制限して、 queue オプションで待ち行列を設定できるようです。これを使えば問題は解決できそうですが、残念ながら queue オプションはnginxの商用版でしか利用できないので今回は実験していません。
このオプションが利用できなくても、スレッド数にかなり余裕を持って設定しておき、かつCPU使用率が高くなったらコンテナやVMがスケールアウトするようにしておけば、リクエスト数が跳ね上がった場合にも外部APIが遅くなった場合にも十分に耐えられると思います。
さいごに
紹介した uWSGI ドキュメント内の記事では、thundering herd問題を解決するための方法として --thunder-lock オプションも紹介されています。しかしこのオプションではロードバランス問題は解決しません。プロセス間ロックがFIFOになっていても、最初に起動したワーカープロセスの全スレッドがそのロック待ち状態になってから次のワーカープロセスが起動してしまうので、4スレッドであれば最初の4リクエストが最初のワーカープロセスに集中してしまうのです。
今さらスレッド?と思われる方もいるかもしれませんが、asyncioを使ってもCPUのマルチコアを活かすためにはマルチプロセスが必要で同じようにロードバランス問題が発生するので、この記事の設定は参考になると思います。
また、フリースレッド版Pythonがあればシングルプロセスで済むという意見もあるかもしれません。完全にシングルスレッドにしてしまえば構成がシンプルになるのはいいのですが、GILが無くなっても代わりになる小粒度のロックは存在するので、アクセスが増えた時にロック競合が増えてしまいマルチスレッドだけではCPUコア数をフルに活用できない可能性があります。GILが無くなってもマルチプロセス構成のメリットが消えるわけではありません。
Pythonのもう一つのHTTPクライアント: Niquests
過去数回の記事で Requests や httpx の問題点や細かい挙動について触れてきました。これらのライブラリの代わりになるもう一つの有力なHTTPクライアントとして Niquests を紹介します。
NiquestsはRequestsのforkで、高い互換性を保ちながら、非同期処理やHTTP/2、HTTP/3のサポートなど、現代的な機能を追加しています。Requestsの低レイヤーを担う urllib3 もforkして urllib3-future として提供しており、Niquestsの現代的な機能は urllib3-future によって実装されています。
Niquestsもurllib3-futureも開発されているのは Ahmed Tahri (@Ousret) さんです。個人プロジェクトとなると不安もありますが、彼は Requests の依存ライブラリの一つである charset_normalizer のメンテナでもあるため、 Requests から niquests へ移行してもサプライチェーン・アタックに関するリスクはほとんど増えないと思います。
urllib3-futureのシャドーイング
Niquestsは普通のライブラリでRequestsと共存、併用できるのですが、urllib3-futureはurllib3を完全に置き換える形になっています。 urllib3-futureさえインストールすればNiquestsだけでなくRequestsもurllib3-futureを使うようになるため、自動的にHTTP/2やHTTP/3に対応します。
このシャドーイングの動作を説明しておきます。urllib3-futureは urllib3_future というパッケージとしてインストールされるのですが、 urllib3_future.pth というファイルを使ってPythonの起動時に urllib3_future/ を urlllib3/ に上書きします。
この上書き動作は初回起動時に実行されるので、たとえばコンテナをビルドする場合などでPythonを利用するより前にPythonの仮想環境構築を終わらせてしまいたい場合に注意が必要です。例えば uv sync の場合は環境変数 UV_COMPILE_BYTECODE=1 を設定しておけばバイトコードコンパイルのタイミングで上書きが発生するはずですが、一度 python -c 'import urllib3; print(urllib3.__version__)' を実行してログを確認しておくと良いでしょう。パッチバージョン部分が900以上であればurllib3-futureに置き換わっています。
pthファイルを使った上書きコピーはとてもお行儀が悪い動作になるのですが、そこまでして urllib4 等の別パッケージ名ではなく urllib3 というパッケージにこだわるのには理由があります。NiquestsはRequestsのエコシステムをなるべく壊さずにPythonのHTTPクライアントライブラリを進化させることを目的としているのですが、Requestsを拡張するライブラリの多くが urllib3 に依存しているのです。
どうしてもRequestsはそのままで使いたい場合は、 ドキュメントのcohabitation節にソースパッケージからpthファイル抜きでインストールする方法が書かれています。
urllib3-futureのHTTP実装について
urllib3のHTTP/1.1サポートは標準ライブラリの http.client をベースにしていますが、 urllib3-future はhttpxが使ってるhttpcoreと同じく h11を使っています。h11は http.client と同じくPure Python実装ですが、実用的な速度は出ています。
HTTP/2のサポートにはhttpcoreが使っているh2 (hyper-h2)をそのまま使うのではなく、細分化されすぎたh2の依存ライブラリをまとめて、さらに性能が必要な部分はRustを使った高速化モジュールも用意したjh2を使っているので、httpcoreよりもHTTP/2の性能が良い可能性があります。
また、aioquicをforkして開発している qh3 を使ってHTTP/3までサポートしています。urllib3やhttpxによるHTTP/2サポートがまだデフォルトで有効になっていないしHTTP/3には対応していないのに対して、urllib3-futureはデフォルトでHTTP/2とHTTP/3が有効になっているのが特徴です。
コネクションプールとkeep-aliveの設定
過去数回の記事でRequestsやhttpxのコネクションプールやkeep-aliveについて触れてきましたが、Niquestsについても調べてみました。
まず、Requestsに対して大きな進歩になるのが niquests.Session() が引数でコネクションプールの設定ができる点です。 requestsで長時間Sessionを使う場合はidle_timeoutに注意 では同一ホスト宛の接続を1つだけに制限することで長時間idleになるコネクションが生まれにくくする方法を紹介しましたが、その時はこのようなコードを書く必要がありました。
import requests from requests.adapters import HTTPAdapter session = requests.Session() session.mount("http://", HTTPAdapter(pool_maxsize=1)) session.mount("https://", HTTPAdapter(pool_maxsize=1))
Niquestsでは HTTPAdapter の引数をSessionに渡せるので、同じことが簡単に書けます。
import niquests session = niquests.Session(pool_maxsize=1)
設定できる引数からいくつか紹介します。
pool_connectionsは名前だけみると接続数に見えますが、実際にはコネクションプールを保持するホスト数の上限になります。実際のコネクションプールはホストごとに分けて作られています。デフォルトでは10です。pool_maxsizeは1ホストあたり(コネクションプールあたり)のコネクション数の上限です。これもデフォルトでは10です。pool_blockはコネクション数がいっぱいになったときに、既存の接続が空くのを待つかどうかです。デフォルトはFalseで、pool_maxsizeを超えて接続を作成します。(Falseであれば、pool_maxsizeのデフォルトの10は少し大きすぎる気がします。)keepalive_delayは、docstringにはHTTP/2でidle接続に対してPINGを送り始めるまでの秒数と書かれているのですが、HTTP/1.1では接続の寿命として振る舞います。デフォルトは3600秒(1時間)です。
上の3つはRequestsのHTTPAdapterと共通ですが、最後の keepalive_delay についてはNiquestsとurllib3-futureの独自拡張になります。
以前の記事で urllib3にidle_timeoutを追加するPRを紹介しましたが、それはidle状態が指定秒数続いたら再利用せずに捨てるものでした。接続開始からの経過秒数で廃棄する keepalive_delay は少し動作が異なりますが、問題だったサーバー側の idle_timeout によりリクエスト送信と同時に接続がサーバーから切断されるとリクエストが処理されたかどうだか分からなくなるという問題を避けるのに利用できるのは同じです。
例えば nginxの設定だと、クライアントとの接続は開始から最大1時間維持され(keepalive_time)、75秒idleだと切断されます(keepalive_timeout)。これに合わせるのであれば、今はidle_timeoutを避けるために keepalive_delay=60 くらいに設定しておき、将来 urllib3-future に idle_timeout が実装されたら idle_timeout=60 に、 keepalive_delay は1時間より少し短い値 (3500くらい)に設定するのが良いでしょう。
なお、この話題について語ったときに httpx の keepalive_expiry が idle_timeout と同じものとして紹介したのですが、HTTP/1.1ではうまく行くもののHTTP/2を有効にするとサーバー側から接続が切られる現象が報告されています。 (Issue)
HTTP/2を使いたいのであればhttpxよりもNiquestsの方がエラーが少ないかもしれません。
httpxのパフォーマンス問題について
前回の記事でhttpxの検討を進めた後にこんな気になる記事を見かけたので現状を調査しました。
結論から言うと、これは httpx を async で利用する時の問題で、現状ではまだ解決されていません。同期APIを使っていれば問題ありません。
原因は、httpxの低レイヤーライブラリであるhttpcoreがanyio.Lockを使っていたのですが、その実装がasyncio.Lockよりも大幅に遅いことです。httpcoreに提案されている解決策は3つあります。
- anyio依存排除 https://github.com/encode/httpcore/pull/922
- anyioに追加された fast_acquire を利用する https://github.com/encode/httpcore/pull/953
- コネクションプールの実装見直し https://github.com/encode/httpcore/pull/927
残念ながら a も b も、cの方が良いからという理由でマージされていません。 b は一度マージされたのですが revert されてしまいました。 https://github.com/encode/httpcore/pull/1002
cも開発が止まっているように見えますが、開発者の Kim Christie さんは2週間前から httpx のほとんどリライトとなる v1 ブランチをスタートしています。 https://github.com/encode/httpx/commits/v1/
ということで、開発が止まっているわけではないものの、asyncio利用時のパフォーマンス問題がいつ頃解決されるかは全く目処が立ちません。asyncioを使っていてパフォーマンスを無視できないのであれば aiohttp を使う他なさそうです。 httpxの機能が必要でaiohttpへの移行が大変な場合は、httpxの下位レイヤーをaiohttpにするためのコードがコメント欄で書かれていたので参考にしてください。 https://github.com/encode/httpx/issues/3215#issuecomment-2522013017
追記
httpxのパフォーマンス問題について - methaneのブログb.hatena.ne.jp“結論から言うと、これは httpx を async で利用する時の問題で、現状ではまだ解決されていません。同期APIを使っていれば問題ありません” / コネクションプーリングしない、でもいいと思います。
2025/09/29 12:29
そうですね。その場合はSSLContextの生成に時間がかかるのですが、次のようにSSLContextを使い回す方法があります。
requestsで長時間Sessionを使う場合はidle_timeoutに注意
Pythonで一番人気のあるHTTPクライアントライブラリはrequestsですが、requestsやその低レイヤーであるurllib3はidle_timeoutの設定を持っていないので、長時間アイドルが続いた接続を再利用した時に Connection Reset by Peer エラーが発生することがあります。
このエラーを避けるためにurllib3はリクエストを送信する前に0バイトのreadを行って接続が生きているか確認しているのですが、サーバー側が接続を切断するのと同時にリクエストを送信してしまう場合にはその確認をすり抜けるので、ごく低頻度にエラーが起こってしまいます。
意図的にこのエラーを再現させてみます。Goを使ってidle_timeoutが1秒のサーバーを作ります。
package main import ( "net/http" "time" "fmt" "log" ) func myHandler(w http.ResponseWriter, r *http.Request) { time.Sleep(100 * time.Millisecond) w.Header().Set("Content-Type", "text/plain; charset=utf-8") fmt.Fprintf(w, "Hello, world!") } func main() { s := &http.Server{ Addr: ":8080", Handler: http.HandlerFunc(myHandler), IdleTimeout: 1 * time.Second, } log.Fatal(s.ListenAndServe()) }
このサーバーに対して1秒弱の間隔でリクエストを送信します。
import requests import threading import time import random url = "http://127.0.0.1:8080" # app.go def get(): session = requests.Session() last_sleep = 0.0 for i in range(100): try: response = session.get(url) response.raise_for_status() except requests.exceptions.ConnectionError as e: print(e) print(f"{last_sleep=}sec") # keep-alive timeout が 1s のサーバーに対してギリギリのタイミングでリクエストを投げる last_sleep = random.uniform(0.99, 1.0) time.sleep(last_sleep) workers = [] for i in range(10): th = threading.Thread(target=get) th.start() workers.append(th) for worker in workers: worker.join()
実行結果:
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9935746861945842sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9940835216122245sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9905876213085897sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9944683230422835sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9906204586986777sec
('Connection aborted.', RemoteDisconnected('Remote end closed connection without response'))
last_sleep=0.9910259433567449sec
('Connection aborted.', ConnectionResetError(54, 'Connection reset by peer'))
last_sleep=0.9903877980940736sec
...
たとえば外部APIを利用するWebアプリケーションで、外部APIの呼び出しが低頻度だとかマルチスレッドを使っていると問題が発生しやすいです。低頻度の場合はSessionを使わないのが一番簡単な解決策ですが、アクセスが高頻度でもマルチスレッドを利用している場合は稀な頻度で起こる同時接続のためにkeep_aliveされる接続が増えて、一部の接続が長時間アイドルになることがあるので、同時接続数を減らすのが良いでしょう。
urllib3 では PoolManager の maxsize で同時接続数を制限できてデフォルトで1なのですが、 requests ではこれを10に置き換えてしまっており、これはほとんどのアプリケーションにとっては過剰でしょう。最大接続数を超えてもデフォルトの設定ではブロックせずに新規接続してくれるので、基本的には maxsize=1 を使い、それで足りないような場合にだけ増やすのがいいと思います。
requestsでSessionを作成する時にmaxsizeを指定できないので、カスタマイズするためにはこのようにします。
import requests from requests.adapters import HTTPAdapter session = requests.Session() session.mount("http://", HTTPAdapter(pool_maxsize=1)) session.mount("https://", HTTPAdapter(pool_maxsize=1))
urllib3にidle_timeoutを設定できるようにするPRがあるので、これがマージされればもっと良い解決ができるようになるでしょう。
または、 httpx への置き換えも検討してみてください。 httpx は高水準APIはrequestsとよく似ており、ほとんど Session を Client に置き換えるだけで使えます。 httpx.Limits.keepalive_expiry で idle_timeout を指定可能で、デフォルトでは5秒になっています。先ほどの再現コードで session 変数を作る部分を次のように書き換えるだけでエラーなしに動作するようになります。
# import requests の代わりに import httpx session = httpx.Client(limits=httpx.Limits(keepalive_expiry=0.5))
functools.cacheをメソッドに使う
functools.cache は便利ですが、メソッドに対して使う時には注意が必要です。
from functools import cache class A: @cache def f(self, x): return x * 2 for i in range(1000): a = A() a.f(42) print(A.f.cache_info()) # CacheInfo(hits=0, misses=1000, maxsize=None, currsize=1000)
このコードでは A.f() メソッドの第一引数 self がキャッシュキーに含まれるためキャッシュが効いていません。単に効かないどころか A のインスタンスが無限にキャッシュに残り続けるのでメモリリークになります。
この問題を回避するには f() を staticmethod にするか、Aの外で通常の関数として定義する必要があります。
from functools import cache class A: # デコレーターの順番に注意 @staticmethod @cache def f(x): return x * 2 for i in range(1000): a = A() a.f(42) print(A.f.cache_info()) # CacheInfo(hits=999, misses=1, maxsize=None, currsize=1)
これでちゃんとキャッシュヒットするようになり、メモリリークも防いでいます。 @staticmethod と @cache の順番は逆になってはいけません。 cache が返すラッパー関数が staticmethod でないので、 A.f により self がバインドされてしまいます。 (staticmethod, cache, staticmethod の順番で f 本体とラッパー関数両方を staticmethod にしても良いですが、冗長です。)
一方でインスタンス間でキャッシュをシェアしたくない場合は一工夫必要になります。デコレーターとして使ってしまうとcacheがクラスに所属してしまうので、インスタンスと生存期間が同じになるようにcacheを __init__ で生成してあげます。
from functools import cache class A: def __init__(self, x): self._x = x self.f = cache(self._f) def _f(self, y): return self._x * y for i in range(10): a = A(i) for j in range(10): a.f(42) print(i, a.f.cache_info()) # 0 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 1 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 2 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 3 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 4 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 5 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 6 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 7 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 8 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1) # 9 CacheInfo(hits=9, misses=1, maxsize=None, currsize=1)
インスタンスごとにキャッシュが分かれて、それぞれが9回ヒットしていることが判ります。