(なぜ Web 開発で並行処理が重要なのか)= # なぜ Web 開発で並行処理が重要なのか ## 複数リクエスト Web サーバは本質的に複数のクライアントから同時にリクエストを受け取るシステムです。 ブラウザが1つのページを表示するだけでも、HTML、CSS、JavaScript、画像、API エンドポイントへのリクエストが並行して送信されます。 10人のユーザーが同時にサイトを閲覧すれば、数十から数百のリクエストがほぼ同時にサーバに到着します。 もしサーバがリクエストを完全に逐次処理するなら、1リクエストあたり100msかかる処理で10リクエストが同時に到着した場合、最後のリクエストは900ms待たされた上で100msの処理が行われ、合計1000ms後にレスポンスを受け取ります。 最初のリクエストは100msで完了しますが、到着順が後になるほどレスポンスタイムが線形に悪化します。 ``` 逐次処理(並行処理なし): リクエスト1 [████████] → 100ms リクエスト2 [████████] → 200ms リクエスト3 [████████] → 300ms ... リクエスト10 [████████] → 1000ms 並行処理(4ワーカー): ワーカー1 [████████] [████████] [████████] → 100ms, 200ms, 300ms ワーカー2 [████████] [████████] [████████] → 100ms, 200ms, 300ms ワーカー3 [████████] [████████] → 100ms, 200ms ワーカー4 [████████] [████████] → 100ms, 200ms → 最悪ケースでも 300ms で全リクエスト完了 ``` {numref}`「Webサーバ」という言葉の混乱を解く`({ref}`「Webサーバ」という言葉の混乱を解く`)で見た Gunicorn のマルチワーカー構成は、まさにこの問題を解決するためのものです。 4ワーカーであれば4リクエストを同時に処理でき、10リクエストは3ラウンドで完了します。 ```{note} 「ワーカーを増やせばいくらでもスケールする」わけではありません。 {numref}`ch11-トラブルシューティングの観点`({ref}`ch11-トラブルシューティングの観点`)で見た通り、ワーカーごとにメモリを消費し、CPU コア数を超えるワーカーはコンテキストスイッチのオーバーヘッドを増やします。 並行処理の戦略を理解することが、限られたリソースで最大のスループットを得るために不可欠です。 ``` ## 待ち時間の重なり Web アプリケーションの処理時間の大部分は「待ち」です。 ```{mermaid} flowchart LR subgraph sync["同期モデル(1ワーカー)"] direction LR S1["DB クエリ待ち
(CPU空き)"] --> S2["外部 API 待ち
(CPU空き)"] --> S3["Python 処理
(CPU使用)"] end subgraph async_["非同期モデル(1ワーカー)"] direction LR A1["リクエストA
DB待ち中に→"] --> A2["リクエストB
API待ち中に→"] --> A3["リクエストC
処理"] end ``` ビュー関数が実行している100msのうち、CPU が実際に計算しているのは数ms程度で、残りはデータベースの応答待ち、外部 API の応答待ち、ファイルシステムの I/O 待ちに費やされています。 ```python def dashboard(request): # CPU: クエリ構築 ~0.1ms users = User.objects.filter(active=True).count() # DB 待ち: ~5ms # CPU: クエリ構築 ~0.1ms orders = Order.objects.filter(status="pending").count() # DB 待ち: ~8ms # CPU: HTTP リクエスト構築 ~0.1ms weather = requests.get("https://api.weather.com/today") # 外部 API 待ち: ~200ms # CPU: レスポンス構築 ~0.5ms return JsonResponse({...}) # 合計: ~214ms # うち CPU 使用: ~1ms # うち I/O 待ち: ~213ms(全体の 99.5%) ``` 同期モデルでは、この213msの I/O 待ち時間の間、ワーカー(プロセスまたはスレッド)は何もせずに待機しています。 他のリクエストを処理する能力があるにもかかわらず、I/O の完了を待つためだけにリソースが占有されています。 非同期モデルはこの「待ち時間の重なり」を活用します。1つのリクエストが DB の応答を待っている間に、別のリクエストの処理を進められます。 Vol.2「なぜ ASGI が必要になったのか」で ASGI が必要な理由として述べた「I/O 待ち中のリソースの無駄」は、この具体的な状況を指しています。 ```python async def dashboard(request): async with httpx.AsyncClient() as client: # 3つの I/O を並行実行 users_task = User.objects.filter(active=True).acount() orders_task = Order.objects.filter(status="pending").acount() weather_task = client.get("https://api.weather.com/today") users, orders, weather = await asyncio.gather( users_task, orders_task, weather_task ) return JsonResponse({...}) # 合計: ~200ms(最も遅い外部 API 待ちに律速) # 同期版の 214ms → 非同期版の 200ms(この例では差は小さいが、 # 他のリクエストも同時に処理できることが本質的な利点) ``` ```{important} 非同期化の利点が発揮されるのは **I/O バウンドな処理に限られます**。 CPU バウンドな処理(画像変換、暗号計算、大量のデータ集計など)では、CPU が実際に稼働しているため、待ち時間を他の処理に充てることができません。 この区別が並行処理の戦略選択の出発点です。 ``` ## スループットとレイテンシ 並行処理を議論する際、しばしば混同される2つの指標があります。 - **スループット**: 単位時間あたりに処理できるリクエスト数です。「このサーバは秒間1000リクエストを処理できる」というのがスループットの表現です。 - **レイテンシ**: 1つのリクエストが到着してからレスポンスが返るまでの時間です。「このエンドポイントのP95レイテンシは50msである」というのがレイテンシの表現です。 並行処理はスループットを改善しますが、個々のリクエストのレイテンシを改善するとは限りません。 ``` シナリオ: 各リクエストの処理に 50ms かかる 1ワーカー: スループット: 20 req/s(1000ms ÷ 50ms) レイテンシ(無負荷): 50ms レイテンシ(20 req/s の負荷): 50ms〜1000ms(キュー待ち) 4ワーカー: スループット: 80 req/s レイテンシ(無負荷): 50ms(変わらない) レイテンシ(80 req/s の負荷): 50ms〜250ms(キュー待ちが短縮) ``` ワーカーを増やすとスループットはほぼ線形に向上しますが、個々のリクエストの処理時間(50ms)は変わりません。 改善されるのは、高負荷時にキューで待たされる時間です。 レイテンシ自体を改善するには、ビュー関数の処理を高速化する(SQL の最適化、キャッシュの導入、不要な処理の削除)必要があります。 非同期処理が特にスループットの改善に効くのは、I/O 待ちが処理時間の大部分を占める場合です。 1ワーカーでも、I/O 待ちの間に他のリクエストを処理できるため、実質的なスループットが同期モデルの数倍から数十倍になります。 Vol.2「なぜ ASGI が必要になったのか」で「100同時接続でも1ワーカープロセスで対応可能」と述べたのは、各接続の I/O 待ちが重なることで、少ないリソースで高いスループットを実現できるためです。 ``` 同期モデル(I/O 待ちが 90% の処理): 1ワーカーのスループット: 10 req/s → 100 req/s に必要なワーカー数: 10 非同期モデル(同じ処理): 1ワーカーのスループット: ~100 req/s → I/O 待ちの間に他のリクエストを処理 → 少ないワーカーで高スループットを実現 ``` 一方で、CPU バウンドな処理では非同期モデルの利点はほとんどありません。 CPU が100%稼働している間は他の処理に切り替えられないため、スループットの改善にはプロセスの並列実行(マルチプロセス)が必要です。 この区別を踏まえて、次節以降では Python が提供する並行処理の3つの手段(マルチプロセス、マルチスレッド、非同期 I/O)の仕組みと、それぞれが Web 開発のどの場面で有効かを掘り下げていきます。 (process, thread, coroutine の違い)= ## process, thread, coroutine の違い ### OS プロセス プロセスは OS が管理する独立した実行単位です。 各プロセスは独自のメモリ空間(仮想アドレス空間)を持ち、他のプロセスのメモリに直接アクセスできません。 ```python import os import multiprocessing def worker(): print(f"Worker process PID: {os.getpid()}, Parent PID: {os.getppid()}") if __name__ == "__main__": print(f"Main process PID: {os.getpid()}") p = multiprocessing.Process(target=worker) p.start() p.join() ``` プロセスが独立したメモリ空間を持つことは、安全性と制約の両面をもたらします。 **安全性の面**では、1つのワーカープロセスがクラッシュ(セグメンテーションフォルト、未捕捉例外など)しても、他のワーカープロセスやマスタープロセスには影響しません。 {numref}`Gunicorn`({ref}`Gunicorn`)で Gunicorn のマスタープロセスがクラッシュしたワーカーを再生成できるのは、プロセスが隔離されているからです。 グローバル変数の変更、メモリ破壊、ライブラリの内部状態の汚染が他のプロセスに波及しないため、堅牢なサーバ運用が可能になります。 **制約の面**では、プロセス間でデータを共有するためにプロセス間通信(IPC)が必要です。 パイプ、ソケット、共有メモリ、メッセージキューなどの仕組みを使って明示的にデータをやり取りします。 Django のビュー関数でグローバル変数に値を保存しても、他のワーカープロセスからはその変更が見えません。 {numref}`Gunicorn`({ref}`Gunicorn`)で各ワーカーが独立してリクエストを処理すると述べた背景には、このプロセス隔離の仕組みがあります。 ```python # プロセス間ではメモリが共有されない counter = 0 def increment(): global counter counter += 1 print(f"Worker: counter = {counter}") # → 1(自分のコピーしか見えない) if __name__ == "__main__": p = multiprocessing.Process(target=increment) p.start() p.join() print(f"Main: counter = {counter}") # → 0(変更されていない) ``` プロセスの生成コストは3つの並行処理手段の中で最も高く、`fork` システムコールでメモリ空間のコピー(copy-on-write)が行われます。 これが Gunicorn がプリフォーク(pre-fork)モデル、つまりリクエスト到着前にワーカーを事前に生成する設計を採用する理由です。 リクエストのたびにプロセスを生成していては、オーバーヘッドが処理時間を大きく上回ってしまいます。 ### スレッド スレッドは同一プロセス内で動作する軽量な実行単位です。 同じプロセスに属するスレッドはメモリ空間を共有するため、グローバル変数やオブジェクトに直接アクセスできます。 ```python import threading counter = 0 lock = threading.Lock() def increment(): global counter with lock: counter += 1 print(f"Thread {threading.current_thread().name}: counter = {counter}") threads = [threading.Thread(target=increment) for _ in range(4)] for t in threads: t.start() for t in threads: t.join() print(f"Final: counter = {counter}") # → 4(メモリを共有している) ``` メモリを共有することで、プロセス間通信の仕組みを使わずにデータを受け渡せます。 しかし同時に、複数のスレッドが同じデータを同時に読み書きする競合状態(race condition)のリスクが生じます。 ```{warning} 上記の例で `lock` を使わなければ、`counter += 1` の操作(読み取り → 加算 → 書き戻し)の途中で別のスレッドが割り込み、カウントが正しくなくなる可能性があります。 スレッドを使う際は共有データへのアクセスを慎重に設計してください。 ``` Python には GIL(Global Interpreter Lock)という特有の制約があります。 CPython インタプリタは、同時に1つのスレッドだけが Python バイトコードを実行できるようにグローバルロックをかけています。 ``` Python の GIL: スレッド1 [Python実行] [I/O待ち ] [Python実行] スレッド2 [Python実行 ] [Python実行] スレッド3 [I/O待ち ] → I/O 待ちの間は GIL が解放され、他のスレッドが実行できる → CPU バウンドの処理では、スレッドを増やしても1コアしか使えない ``` GIL はI/O 操作(ソケットの `recv`/`send`、ファイルの `read`/`write`、`time.sleep` など)の実行中に解放されます。 つまり、スレッド1が DB の応答を待っている間に GIL が解放され、スレッド2が Python コードを実行できます。 Web アプリケーションの処理時間の大部分が I/O 待ちであるという{numref}`なぜ Web 開発で並行処理が重要なのか`({ref}`なぜ Web 開発で並行処理が重要なのか`)の議論を踏まえると、GIL があってもマルチスレッドは Web サーバの同時接続処理に有効です。 Gunicorn の `--threads` オプション({numref}`Gunicorn`({ref}`Gunicorn`))は、各ワーカープロセス内にスレッドを作成します。 4ワーカー × 4スレッドであれば16リクエストを同時に処理でき、各スレッドが I/O 待ちの間に他のスレッドが実行されます。 ただし CPU バウンドな処理では GIL がボトルネックとなり、スレッドを増やしてもスループットは向上しません。 その場合はプロセスを増やす必要があります。 ### coroutine / task コルーチンは Python の `async def` で定義される関数で、`await` で実行を中断・再開できる軽量な実行単位です。 OS ではなく Python のイベントループがコルーチンのスケジューリングを管理します。 ```python import asyncio async def fetch_data(name, delay): print(f"{name}: 開始") await asyncio.sleep(delay) # ここで実行を中断し、他のコルーチンに制御を渡す print(f"{name}: 完了") return f"{name} の結果" async def main(): # 3つのコルーチンを同時にスケジュール results = await asyncio.gather( fetch_data("A", 2), fetch_data("B", 1), fetch_data("C", 3), ) print(results) # 合計 3秒で完了(逐次なら 6秒) asyncio.run(main()) ``` コルーチンは OS のスレッドやプロセスではありません。 単一のスレッド上で動作し、`await` の地点でイベントループに制御を返す「協調的マルチタスク」です。 OS がタイムスライスで強制的にスレッドを切り替えるプリエンプティブマルチタスクとは異なり、コルーチンは自発的に制御を譲ります。 ```python # コルーチンの実行の流れ(単一スレッド上) async def handler_a(): data = await db.fetch("SELECT ...") # ← ここでイベントループに戻る return process(data) # ← DB の応答が来たら再開 async def handler_b(): resp = await httpx.get("https://...") # ← ここでイベントループに戻る return resp.json() # ← HTTP の応答が来たら再開 # イベントループの視点: # 1. handler_a を開始 → await db.fetch → 中断、DB に I/O リクエスト送信 # 2. handler_b を開始 → await httpx.get → 中断、HTTP リクエスト送信 # 3. DB の応答到着 → handler_a を再開 → process(data) → 完了 # 4. HTTP の応答到着 → handler_b を再開 → resp.json() → 完了 ``` `asyncio.Task` はコルーチンをイベントループにスケジュールするラッパーです。 `asyncio.create_task(coro)` でタスクが生成され、イベントループが適切なタイミングでコルーチンを実行します。 Vol.2「最小の ASGI HTTP アプリ」で `uvicorn` がリクエストごとに `asyncio.create_task(app(scope, receive, send))` を呼んでいたのは、各リクエストの処理をタスクとしてスケジュールしていたためです。 コルーチンの最大の特徴は、コンテキストスイッチのコストがほぼゼロである点です。 OS のプロセスやスレッドの切り替えはカーネルモードへの遷移、レジスタの保存・復元、TLB のフラッシュなどを伴いますが、コルーチンの切り替えは Python のフレームオブジェクトの参照を変更するだけです。 数万のコルーチンを同時に動かしても、メモリ消費とスケジューリングのオーバーヘッドは最小限です。 ```{caution} 「協調的」であることは、`await` を書かないコルーチンは他のコルーチンに制御を渡さないことを意味します。 Vol.2「ch09-トラブルシューティングの観点」とVol.2「ch10-トラブルシューティングの観点」で繰り返し述べた「`async def` 内での同期ブロッキング呼び出し」の問題は、コルーチンが `await` なしに長時間実行され続け、イベントループが他のタスクをスケジュールできなくなる状況です。 ``` ### それぞれのコストと特性 3つの並行処理手段のコストと特性は次の通りです。 ```{mermaid} flowchart LR subgraph Process["プロセス (Gunicorn workers)"] P1["プロセス1
独立メモリ"] P2["プロセス2
独立メモリ"] end subgraph Thread["スレッド (--threads)"] T1["スレッド1"] T2["スレッド2"] T3["スレッド3"] Mem["共有メモリ"] T1 --- Mem T2 --- Mem T3 --- Mem end subgraph Coroutine["コルーチン (Uvicorn event loop)"] EL["イベントループ (1スレッド)"] C1["コルーチン1"] C2["コルーチン2"] C3["コルーチン3"] EL --> C1 EL --> C2 EL --> C3 end ``` | 特性 | プロセス | スレッド | コルーチン | |------|----------|----------|------------| | 生成コスト | 高い(fork, メモリ複製) | 中程度(スタック確保) | 極めて低い(フレームオブジェクト) | | メモリ消費 / 単位 | 大きい(~150-300MB) | 中程度(~8MB スタック) | 極めて小さい(~数KB) | | 同時数の目安 | 数〜数十 | 数十〜数百 | 数千〜数万 | | メモリ共有 | なし(IPC が必要) | あり(要ロック) | あり(単一スレッド) | | スケジューリング | OS(プリエンプ) | OS(プリエンプ) | イベントループ(協調的) | | CPU 並列実行 | 可能 | GIL で制限 | 不可(単一スレッド) | | I/O 並行処理 | 可能 | 可能 | 可能(最も効率的) | | クラッシュの影響 | 他プロセスに波及しない | プロセス全体が影響を受ける | タスクの例外が他タスクに波及しない | | デバッグ | 比較的容易(独立実行) | 競合状態のデバッグが困難 | await の流れを追跡する必要 | Web サーバにおける使い分けを、本書で見てきた構成に対応させると次のようになります。 - **Gunicorn のマルチワーカー**({numref}`Gunicorn`({ref}`Gunicorn`))はプロセスレベルの並行処理です。各ワーカーが独立したプロセスとして動作し、1つのワーカーのクラッシュが他に影響しません。CPU バウンドな処理でも複数コアを活用でき、GIL の制約を受けません。 - **Gunicorn の `--threads`**({numref}`Gunicorn`({ref}`Gunicorn`))はスレッドレベルの並行処理です。同一ワーカー内でスレッドがメモリを共有し、I/O 待ちの間に他のスレッドがリクエストを処理します。プロセスを増やすよりメモリ効率がよく、I/O バウンドな処理に有効です。 - **Uvicorn のイベントループ**({numref}`Uvicorn`({ref}`Uvicorn`))はコルーチンレベルの並行処理です。単一のスレッド上で数千の同時接続を処理でき、SSE やWebSocket のような長時間接続に最適です。FastAPI や Django の `async def` ビューはこの上で動作します。 ```{tip} 実際の本番環境では、これらが組み合わされます。 {numref}`Gunicorn + Uvicorn Worker`({ref}`Gunicorn + Uvicorn Worker`)の Gunicorn + Uvicorn Worker 構成は、プロセスレベルの隔離(Gunicorn のマルチワーカー)の中で、コルーチンレベルの並行処理(Uvicorn のイベントループ)が動作する二段構えです。 Django の `sync_to_async`(Vol.2「sync / async 境界の橋渡し」)は、コルーチンの中からスレッドプールを使ってスレッドレベルの並行処理に橋渡しします。 3つの手段は排他的な選択肢ではなく、階層的に組み合わせて使うものです。 ``` 次節では GIL の仕組みをより詳しく掘り下げ、Python の並行処理における実際の制約を理解します。 (GIL の基本)= ## GIL の基本 ### 何を制限するのか GIL(Global Interpreter Lock)は CPython インタプリタの内部に存在するミューテックス(排他ロック)で、同時に1つのスレッドだけが Python バイトコードを実行できるように制限します。 「Python バイトコードの実行」とは何かを明確にしておきましょう。 Python のソースコードは実行前にバイトコードにコンパイルされ、CPython の仮想マシンがそのバイトコードを1命令ずつ実行します。 ```python # このソースコード: x = a + b # は以下のようなバイトコードに変換される: # LOAD_NAME a # LOAD_NAME b # BINARY_ADD # STORE_NAME x ``` GIL はこのバイトコードの実行を保護します。 スレッド1が `LOAD_NAME a` を実行している間、スレッド2は Python バイトコードを実行できません。 CPython は一定のバイトコード命令数(デフォルトでは `sys.getswitchinterval()` で設定される5msごと)を実行した後、GIL を一時的に解放して他のスレッドに実行機会を与えます。 GIL が存在する理由は、CPython の参照カウント方式のメモリ管理にあります。 Python のオブジェクトは参照カウンタを持ち、参照がゼロになるとメモリが解放されます。 複数のスレッドが同時に参照カウンタを操作すると、カウンタが破損してメモリリークやクラッシュが発生します。 GIL はこの問題を最もシンプルに解決する方法として導入されました。 ```python import sys a = [1, 2, 3] print(sys.getrefcount(a)) # → 2(変数 a + getrefcount の引数で一時的に参照) # 複数スレッドが同時にこのリストを参照・解放すると # 参照カウンタの操作が競合する → GIL がこれを防ぐ ``` ```{important} GIL が制限するのは「CPython インタプリタ内での Python バイトコードの同時実行」であり、「プログラム全体の並行動作」ではありません。この区別が GIL に関する多くの誤解を解くカギです。 ``` ### 誤解しやすい点 GIL に関する最も広く浸透している誤解は「Python ではマルチスレッドが無意味である」というものです。 これは正確ではありません。 **第一の誤解**は、「GIL があるからスレッドは1つずつしか動けない」という単純化です。 確かに Python バイトコードの実行は1スレッドに限られますが、GIL はI/O 操作中に解放されます(次項で詳述)。 Web アプリケーションの処理時間の大部分が I/O 待ちであるため({numref}`なぜ Web 開発で並行処理が重要なのか`({ref}`なぜ Web 開発で並行処理が重要なのか`))、GIL が解放されている時間の方がはるかに長く、マルチスレッドの恩恵を十分に受けられます。 **第二の誤解**は、「GIL があるからマルチプロセスを使うべき」という一般化です。 マルチプロセスは GIL の制約を完全に回避しますが、{numref}`process, thread, coroutine の違い`({ref}`process, thread, coroutine の違い`)で見た通りメモリ消費が大きく、プロセス間通信のコストもかかります。 I/O バウンドなWebアプリケーションであれば、マルチスレッドの方がメモリ効率がよく、Gunicorn の `--threads` オプションで十分な並行処理が得られます。 マルチプロセスが必要なのは CPU バウンドな処理が主体の場合です。 **第三の誤解**は、「async/await は GIL を回避する」というものです。 コルーチンは単一のスレッド上で動作するため、そもそも GIL の制約が問題になる場面(複数スレッドが同時に Python コードを実行しようとする場面)が発生しません。 コルーチンは GIL を「回避」しているのではなく、GIL が問題にならない設計を選んでいるだけです。 CPU バウンドな処理をコルーチンで書いても、単一スレッドで逐次実行されるため速くなりません。 ```python import asyncio import time async def cpu_heavy(n): """CPU バウンドな処理""" total = 0 for i in range(n): total += i * i return total async def main(): start = time.time() # 4つのCPUバウンドタスクを「並行」実行 await asyncio.gather( cpu_heavy(10_000_000), cpu_heavy(10_000_000), cpu_heavy(10_000_000), cpu_heavy(10_000_000), ) elapsed = time.time() - start print(f"async: {elapsed:.2f}s") # → 逐次実行と同じ時間がかかる # → コルーチン内に await がないため、制御がイベントループに戻らない asyncio.run(main()) ``` この例では4つのタスクが `asyncio.gather` でスケジュールされますが、各タスクに `await` がないため、最初のタスクが完了するまで他のタスクは開始されません。 コルーチンの「並行性」は `await` での中断・再開に依存しており、CPU を使い続ける処理には効果がありません。 **第四の誤解**は、「GIL は Python の根本的な欠陥で将来なくなる」というものです。 GIL の除去は長年にわたって議論されてきました。 Python 3.13 では実験的に GIL を無効化できるビルドオプション(`--disable-gil`、PEP 703)が導入されましたが、これは多くのC拡張ライブラリとの互換性に影響するため、すぐに標準になるわけではありません。 現時点では GIL の存在を前提として設計するのが現実的です。 ### I/O 待ちとの関係 GIL と Web 開発の関係を理解する上で最も重要なのは、GIL が I/O 操作中に解放されるという点です。 ```{mermaid} sequenceDiagram participant T1 as スレッド1 participant GIL as GIL participant T2 as スレッド2 T1->>GIL: GIL 取得 Note over T1: Python コード実行 T1->>GIL: I/O 開始 → GIL 解放 GIL-->>T2: GIL 取得可能 Note over T2: Python コード実行 Note over T1: DB/API 応答待ち T2->>GIL: I/O 開始 → GIL 解放 GIL-->>T1: GIL 取得 Note over T1: I/O 完了 → 処理再開 ``` CPython は、ブロッキング I/O を実行する際に GIL を解放します。 具体的には次の操作で GIL が解放されます。 - ソケットの `recv()`/`send()` - ファイルの `read()`/`write()` - `time.sleep()` - データベースドライバの C 拡張による通信 - `subprocess` による外部プロセスの待機 ```python import threading import requests import time def fetch_url(url): response = requests.get(url, timeout=10) # GIL は解放される return response.status_code start = time.time() threads = [] for url in ["https://api.example.com/a", "https://api.example.com/b", "https://api.example.com/c", "https://api.example.com/d"]: t = threading.Thread(target=fetch_url, args=(url,)) threads.append(t) t.start() for t in threads: t.join() elapsed = time.time() - start print(f"4スレッド並行: {elapsed:.2f}s") # → 各リクエストが 200ms かかるとして、約 200ms で完了 # → 逐次実行なら 800ms かかる ``` `requests.get()` の内部では、ソケットの `connect()`、`send()`、`recv()` が呼ばれます。 これらのシステムコールは C レベルで実行され、GIL は C コードの実行前に解放されます。 HTTP の応答を待っている間、GIL は他のスレッドが取得でき、別の `requests.get()` を実行できます。 ``` スレッド1: [Python] [──── recv() 待ち(GIL 解放)────] [Python] スレッド2: [Python] [── recv() 待ち(GIL 解放)──] [Python] スレッド3: [Python] [─ recv() 待ち ─] [Python] スレッド4: [Python] [recv()待ち] [Python] → 各スレッドの Python 実行部分は短い(リクエスト構築/レスポンスパース) → I/O 待ちの間は GIL が解放され、他スレッドが Python を実行可能 → 結果的に4リクエストがほぼ同時に完了 ``` これが Gunicorn の `--threads` や `gthread` ワーカーが Web アプリケーションで有効に機能する理由です。 Django のビュー関数がデータベースクエリを実行すると、データベースドライバ(`psycopg2` や `mysqlclient` は C 拡張)が GIL を解放します。 クエリの応答待ちの間に別のスレッドが別のリクエストを処理し、その Python コード実行中にまた別のスレッドの I/O が完了する、という形で効率的に並行処理されます。 GIL が問題になるのは、Python バイトコードの実行自体が長時間続く場合に限られます。 ```python # GIL が問題になるケース: CPU バウンドな処理 def compute_heavy(data): # この処理の間、GIL は保持され続ける result = 0 for item in data: # Python バイトコード result += item ** 2 # Python バイトコード result = result % (10 ** 9 + 7) # Python バイトコード return result # → 他のスレッドは、このスレッドが 5ms ごとに GIL を手放す瞬間しか実行できない # → 実質的に逐次実行に近くなる ``` このケースでは `multiprocessing` を使ってプロセスを分離するか、`concurrent.futures.ProcessPoolExecutor` に処理をオフロードする必要があります。 ```{note} Web アプリケーションの実務で GIL が真のボトルネックになるケースは限定的です。 大半のビュー関数は「少量の Python コード実行 + 多量の I/O 待ち」というパターンであり、GIL の影響は最小限です。 GIL が問題になるのは、ビュー関数内で画像処理、PDF 生成、大量のデータ集計、機械学習モデルの推論といった CPU バウンドな処理を直接行う場合です。 これらの処理は Celery などのバックグラウンドタスクに移すか、別プロセスで実行するのが定石です。 ``` 次節では `asyncio` のイベントループの仕組みを掘り下げ、コルーチンのスケジューリングがどのように行われるかを追います。 (CPU bound と I/O bound)= ## CPU bound と I/O bound ### DB 待ち Web アプリケーションにおける最も一般的な I/O 待ちはデータベースクエリです。 ```python def user_list(request): users = list(User.objects.filter(active=True).select_related("department")) return JsonResponse({"users": [{"id": u.id, "name": u.name} for u in users]}) ``` このビュー関数の実行時間を分解すると、Python コードの実行(QuerySet の構築、SQL の生成、結果のイテレーション、辞書の構築、JSON シリアライズ)は数ms以下で、残りの大部分はデータベースサーバとの通信に費やされます。 SQL がデータベースに送信され、データベースがクエリを実行し、結果がネットワークを通じて返ってくるまでの時間です。 ``` ビュー関数の実行タイムライン: [QuerySet構築 0.1ms][SQL送信→DB実行→結果受信 5-50ms][結果処理 0.5ms] ↑ この間、CPU は何もしていない ``` データベースクエリの応答時間はクエリの複雑さ、データ量、インデックスの有無、データベースサーバの負荷、ネットワーク遅延によって大きく変動します。 単純な主キー検索は1ms未満で完了しますが、数百万行のテーブルに対するインデックスなしの全件スキャンは数秒かかることもあります。 いずれの場合も、待ち時間の間は CPU が遊んでいるという点は共通です。 ```{tip} Django のビュー関数が1リクエストあたり3回のDBクエリを発行し、各クエリが平均5msかかるとすると、15msの I/O 待ちが発生します。 Gunicorn の `--threads 4` 設定であれば、1ワーカー内の4スレッドがそれぞれ DB 待ちの間に他のスレッドにCPU を明け渡し、見かけ上4リクエストが同時に処理されます。 {numref}`GIL の基本`({ref}`GIL の基本`)で述べた通り、データベースドライバの C 拡張が I/O 実行中に GIL を解放するため、この並行処理は効率的に機能します。 ``` ### HTTP API 待ち 外部 API の呼び出しは、DB クエリよりもさらに長い I/O 待ちを伴う場合があります。 ```python def payment_process(request): order = Order.objects.get(id=request.POST["order_id"]) # DB: ~5ms payment = requests.post("https://payment.example.com/charge", # 外部 API: ~200-500ms json={"amount": order.total, "token": request.POST["token"]}, timeout=10, ) if payment.status_code == 200: order.status = "paid" order.save() # DB: ~3ms return JsonResponse({"status": order.status}) ``` 外部 API の応答時間は自分のコントロール外であり、ネットワーク遅延、相手サーバの負荷、地理的距離によって数十msから数秒まで変動します。 決済 API のように信頼性が要求されるサービスでも、P95 レイテンシが500msを超えることは珍しくありません。 ```{warning} この500msの間、同期ワーカーは完全にブロックされます。Gunicorn が4ワーカー × 2スレッドの構成なら、8つの決済リクエストが同時に来ただけですべてのスレッドが占有され、他のリクエスト(DB 問い合わせだけの高速なエンドポイントを含む)が待たされます。 ``` 非同期処理が最も効果を発揮するのがこの場面です。 ```python import httpx async def payment_process(request): order = await Order.objects.aget(id=request.POST["order_id"]) async with httpx.AsyncClient() as client: payment = await client.post("https://payment.example.com/charge", json={"amount": order.total, "token": request.POST["token"]}, timeout=10, ) if payment.status_code == 200: order.status = "paid" await order.asave() return JsonResponse({"status": order.status}) ``` `await client.post(...)` の500msの間、イベントループは他のリクエストのコルーチンを実行します。 1ワーカープロセスの1スレッドで数百の決済リクエストを並行処理できる可能性があり、スレッドやプロセスを増やすよりもはるかにメモリ効率がよいです。 複数の独立した外部 API を呼び出す場合、非同期の効果はさらに顕著になります。 ```python async def aggregate_dashboard(request): async with httpx.AsyncClient() as client: inventory, shipping, analytics = await asyncio.gather( client.get("https://inventory.example.com/summary", timeout=5), client.get("https://shipping.example.com/status", timeout=5), client.get("https://analytics.example.com/today", timeout=5), ) return JsonResponse({ "inventory": inventory.json(), "shipping": shipping.json(), "analytics": analytics.json(), }) # 同期版: 最大 15秒(5秒 × 3 逐次) # 非同期版: 最大 5秒(最も遅い API に律速) ``` ### 重い計算 CPU バウンドな処理は、I/O 待ちとはまったく異なる特性を持ちます。 ```python from PIL import Image import io def generate_thumbnail(request): uploaded = request.FILES["image"] img = Image.open(uploaded) img = img.resize((300, 300), Image.LANCZOS) # 画像の全ピクセルに対してフィルタ処理 pixels = img.load() for y in range(img.height): for x in range(img.width): r, g, b = pixels[x, y] gray = int(0.299 * r + 0.587 * g + 0.114 * b) pixels[x, y] = (gray, gray, gray) buffer = io.BytesIO() img.save(buffer, format="JPEG", quality=85) return HttpResponse(buffer.getvalue(), content_type="image/jpeg") ``` この処理では `for` ループ内のピクセル操作がすべて Python バイトコードとして実行されます。 300×300ピクセルなら90,000回のイテレーションで、各イテレーションで数個のバイトコード命令が実行されます。 この間 GIL は保持され続け(5msごとの一時解放を除く)、他のスレッドはほとんど実行機会を得られません。 ```{danger} `async def` に書き換えても状況は改善しません。 ピクセル操作の `for` ループに `await` を挿入する場所がなく、コルーチンは完了まで制御を返しません。 イベントループ上で実行すると、この処理が完了するまで同じワーカー上のすべてのリクエストが停止します。 ``` ```python # これは意味がない(むしろ危険) async def generate_thumbnail(request): # ... for y in range(img.height): for x in range(img.width): # CPU バウンドな計算 → await がないため制御を返さない r, g, b = pixels[x, y] gray = int(0.299 * r + 0.587 * g + 0.114 * b) pixels[x, y] = (gray, gray, gray) # → イベントループが数百msブロックされる ``` CSV からの大量データインポート、PDF 生成、統計計算、機械学習モデルの推論なども同じカテゴリに属します。 いずれも Python コードの実行が処理時間の大部分を占め、I/O 待ちの割合が小さい処理です。 ### どの方式が向くか 処理の特性に応じた並行処理の選択は、次の3つの観点から考えます。 ```{mermaid} flowchart TD Q{"処理の特性は?"} Q -->|"DB クエリ中心
(I/O 待ち 短)"| MT["マルチスレッド
Gunicorn --threads / gthread"] Q -->|"外部 API 呼び出し多
(I/O 待ち 長)"| Async["非同期 I/O
FastAPI / Django async view"] Q -->|"CPU バウンド
(画像処理・集計)"| MP["マルチプロセス
Celery / ProcessPoolExecutor"] Q -->|"長時間接続
(WebSocket / SSE)"| AsyncWS["非同期 I/O
Uvicorn + FastAPI / Channels"] ``` - **DB クエリ中心の処理**は、Web アプリケーションの最も典型的なパターンです。各クエリの I/O 待ちは短く(1-50ms)、GIL はドライバの C 拡張内で解放されます。マルチスレッド(Gunicorn `--threads`)が最もバランスの良い選択で、プロセス数を増やすよりメモリ効率がよく、非同期化するほどの I/O 待ち時間でもありません。Django WSGI + `gthread` ワーカーが実務上の標準です。 - **外部 API 呼び出しが多い処理**は、I/O 待ち時間が長く(100ms-数秒)、待ち時間の間に他の処理を行う余地が大きい場面です。非同期 I/O(`async def` + `await`)が最も効率的で、`asyncio.gather` による並行呼び出しで個々のリクエストのレイテンシも改善できます。FastAPI、または Django ASGI の `async def` ビューが適しています。マルチスレッドでも動作しますが、スレッド数の上限がそのまま同時接続数の上限になるため、スケーラビリティに差が出ます。 - **CPU バウンドな処理**は、マルチプロセスでなければ真の並列実行ができません。GIL によりマルチスレッドは効果がなく、非同期 I/O はそもそも I/O 待ちがないため利点がありません。ただし Web アプリケーションのビュー関数内で CPU バウンドな処理を直接行うのは避けるべきです。 ```python # 推奨: CPU バウンドな処理はバックグラウンドタスクに委譲 from celery import shared_task @shared_task def process_image(image_path): # 別プロセスで実行される img = Image.open(image_path) # 重い画像処理... img.save(output_path) def upload_image(request): path = save_uploaded_file(request.FILES["image"]) task = process_image.delay(path) # Celery タスクとしてキューに投入 return JsonResponse({"task_id": task.id, "status": "processing"}) ``` ビュー関数内でどうしても CPU バウンドな処理を行う必要がある場合は、`concurrent.futures.ProcessPoolExecutor` でプロセスプールにオフロードします。 ```python import asyncio from concurrent.futures import ProcessPoolExecutor executor = ProcessPoolExecutor(max_workers=2) def heavy_computation(data): # 別プロセスで実行される(GIL の制約を受けない) return sum(x ** 2 for x in data) async def compute_endpoint(request): data = json.loads(request.body)["values"] loop = asyncio.get_event_loop() result = await loop.run_in_executor(executor, heavy_computation, data) return JsonResponse({"result": result}) ``` `run_in_executor` でプロセスプールに委譲すると、`await` の地点でイベントループに制御が戻り、別プロセスで計算が完了するまで他のリクエストを処理できます。 | 処理の特性 | 最適な並行方式 | Web での典型的な構成 | |-----------|--------------|-------------------| | DB クエリ中心 | マルチスレッド | Gunicorn --threads / gthread | | 外部 API 呼び出し | 非同期 I/O (async/await) | FastAPI / Django async view | | 長時間接続 (WS/SSE) | 非同期 I/O | Uvicorn + FastAPI / Channels | | CPU バウンド | マルチプロセス | Celery / ProcessPoolExecutor | | 混在 | 組み合わせ | Gunicorn + Uvicorn Worker + Celery | ```{note} この分類は排他的ではなく、実際のアプリケーションでは複数の特性が混在します。 同じプロジェクト内で、CRUD エンドポイントは同期ビュー + マルチスレッド、外部API 連携エンドポイントは非同期ビュー、画像処理は Celery タスクという使い分けが現実的です。 各エンドポイントの処理特性を把握し、その特性に合った並行処理の方式を選択することが、限られたリソースで最大のスループットを引き出すための出発点です。 ``` 次節では `asyncio` のイベントループの仕組みをさらに掘り下げます。 (sync アプリと async アプリの性能特性)= ## sync アプリと async アプリの性能特性 ### 何が速くなるのか 「async にすれば速くなる」という漠然とした期待は、本書で繰り返し注意を促してきた誤解です。 ここでは sync と async の性能差が実際にどこに現れるかを見ていきます。 同時接続数のスケーラビリティが、非同期アプリケーションの最も本質的な利点です。 ```{mermaid} flowchart LR subgraph sync_model["同期モデル (4ワーカー × 2スレッド = 8スロット)"] Req9["リクエスト9以降 → キューで待機"] end subgraph async_model["非同期モデル (1ワーカー, 1スレッド)"] EL["イベントループ
(await で切り替え)"] EL --> RA["リクエストA
DB待ち中"] EL --> RB["リクエストB
API待ち中"] EL --> RC["リクエストC
処理中"] end ``` 同期モデルでは、同時に処理できるリクエスト数がワーカー数(またはワーカー × スレッド数)に制約されます。 各ワーカー/スレッドは1つのリクエストを処理している間、他のリクエストを受け付けられません。 I/O 待ちの間も占有され続けます。 ``` 同期モデル(4ワーカー × 2スレッド = 8スロット): 同時接続 8 → すべてのスロットが埋まる 同時接続 9 → 1リクエストがキューで待機 同時接続 100 → 92リクエストがキューで待機 各スロットの時間の使い方: [Python 1ms][DB待ち 20ms][Python 0.5ms][API待ち 200ms][Python 0.5ms] ↑ CPU使用 ↑ 何もしていない ↑ 何もしていない 合計 222ms のうち CPU 使用は 2ms(0.9%) ``` 非同期モデルでは、`await` で I/O 待ちに入るたびに他のリクエストの処理に切り替わるため、同時接続数が物理的なスレッド数に制約されません。 ``` 非同期モデル(1ワーカー、1スレッド、イベントループ): リクエストA: [Python 1ms] await DB [Python 0.5ms] await API [Python 0.5ms] リクエストB: [Python 1ms] await DB [Python 0.5ms] await API... リクエストC: [Python 1ms] await DB... ... → DB待ち・API待ちの間に他のリクエストのPythonコードを実行 → 同時接続 100 でも、CPUが使われている時間が重ならなければ処理可能 ``` この差は、長時間の I/O 待ちを伴うエンドポイントで劇的に現れます。 SSE(Server-Sent Events)で5秒ごとにデータを送信するエンドポイントがあるとします。 ```python # 同期版 def sse_sync(request): def event_stream(): while True: data = get_latest_data() yield f"data: {json.dumps(data)}\n\n" time.sleep(5) # ← スレッド占有 return StreamingHttpResponse(event_stream(), content_type="text/event-stream") # 非同期版 async def sse_async(request): async def event_stream(): while True: data = await get_latest_data_async() yield f"data: {json.dumps(data)}\n\n" await asyncio.sleep(5) # ← イベントループに制御を返す return StreamingHttpResponse(event_stream(), content_type="text/event-stream") ``` ```{warning} 同期版では50人のユーザーが SSE に接続すると50スレッドが `time.sleep(5)` で占有されます。 Gunicorn が4ワーカー × 4スレッドなら、16接続でサーバの処理能力が飽和し、通常の API リクエストも受け付けられなくなります。 非同期版では `await asyncio.sleep(5)` がイベントループに制御を返すため、1ワーカーで数千の SSE 接続を維持しつつ、通常のリクエストも並行して処理できます。 ``` 複数の独立した I/O を並行実行できる点も、非同期が明確に高速になるケースです。 {numref}`CPU bound と I/O bound`({ref}`CPU bound と I/O bound`)で見た `asyncio.gather` による複数 API の並行呼び出しは、個々のリクエストのレイテンシ自体を改善します。 ```python # 同期版: 3つの API を逐次呼び出し def dashboard_sync(request): a = requests.get("https://api-a.example.com/data", timeout=5) # 200ms b = requests.get("https://api-b.example.com/data", timeout=5) # 150ms c = requests.get("https://api-c.example.com/data", timeout=5) # 300ms return JsonResponse({...}) # 合計: 650ms # 非同期版: 3つの API を並行呼び出し async def dashboard_async(request): async with httpx.AsyncClient() as client: a, b, c = await asyncio.gather( client.get("https://api-a.example.com/data", timeout=5), client.get("https://api-b.example.com/data", timeout=5), client.get("https://api-c.example.com/data", timeout=5), ) return JsonResponse({...}) # 合計: 300ms(最も遅い API に律速) ``` この例では非同期版は同期版の半分以下のレイテンシで完了します。 これはスループットの改善ではなく、個々のリクエストの応答速度の改善です。 同期版でも `concurrent.futures.ThreadPoolExecutor` を使えば同様の並行呼び出しは可能ですが、スレッドの生成・管理コストがかかり、コードも冗長になります。 メモリ効率も非同期の利点です。 {numref}`process, thread, coroutine の違い`({ref}`process, thread, coroutine の違い`)で見た通り、スレッド1つあたり約8MBのスタックメモリが確保されるのに対し、コルーチン1つあたりのメモリ消費は数KBです。 1000の同時接続を処理するために、スレッドモデルでは約8GBのスタックメモリが必要ですが、コルーチンモデルでは数MBで済みます。 ### 何は速くならないのか 非同期化しても改善されない、あるいは逆に悪化するケースを明確にしておくことが、実務上はむしろ重要です。 **個々のデータベースクエリの速度は変わりません。** `User.objects.get(id=1)` が5msかかる処理は、`await User.objects.aget(id=1)` にしても5msのままです。 データベースサーバがクエリを実行する時間はアプリケーション側の並行処理モデルとは無関係です。 Vol.2「Django ASGI の限界と誤解しやすい点」で述べた通り、Django の非同期 ORM は内部的に `sync_to_async` でラップされているため、むしろスレッドプールへのオフロードのオーバーヘッドがわずかに加わります。 非同期化で速くなるのは「DB クエリの実行」ではなく「DB の応答を待っている間に他のリクエストを処理できること」です。 これはスループットの改善であり、個々のクエリのレイテンシの改善ではありません。 **CPU バウンドな処理は非同期化では速くなりません。** {numref}`CPU bound と I/O bound`({ref}`CPU bound と I/O bound`)で詳しく述べた通り、画像処理、PDF 生成、大量データの集計、暗号計算などは Python バイトコードの実行が処理時間の大部分を占めます。 `async def` に書き換えても、`await` を挟む場所がなければ単一スレッド上で逐次実行されるだけです。 ```python # この処理を async にしても速くならない async def generate_report(request): data = await get_raw_data() # ← ここまでは非同期の恩恵あり # 以下は CPU バウンド → await がなくイベントループをブロック result = [] for row in data: processed = complex_calculation(row) # 純粋な Python 計算 result.append(processed) return JsonResponse({"report": result}) ``` ```{danger} さらに悪いことに、この CPU バウンドな処理がイベントループ上で実行されると、処理中は同じワーカー上の他のすべてのリクエストが停止します。 同期ビューであれば `sync_to_async` でスレッドプールにオフロードされ、イベントループはブロックされません。 CPU バウンドな処理を含むエンドポイントを安易に `async def` にすると、同期版より悪化する可能性があります。 ``` **単一の逐次 I/O チェーンも非同期化の恩恵が薄い領域です。** ```python async def sequential_process(request): user = await User.objects.aget(id=request.user.id) # 5ms order = await Order.objects.filter(user=user).alatest() # 8ms payment = await Payment.objects.filter(order=order).aget() # 3ms return JsonResponse({...}) # 合計: 16ms(同期版と同じ) ``` 3つのクエリは結果が次のクエリの条件になっているため、並行実行できません。 逐次実行せざるを得ず、合計の I/O 待ち時間は同期版と同じです。 このエンドポイント単体のレイテンシは改善されません。 改善されるのは、このリクエストの I/O 待ちの間に他のリクエストを処理できるというスループット面だけです。 ```{note} 低負荷環境では同期と非同期の差がほとんど現れません。 同時接続数が少なく、ワーカー/スレッドに余裕がある状態では、I/O 待ちの間に処理すべき他のリクエストがそもそも存在しません。 開発環境のベンチマークで「async にしても速くならなかった」という結果になるのは、多くの場合この理由です。 非同期の利点は同時接続数が増えたときに初めて顕在化します。 ``` フレームワークのオーバーヘッドも考慮すべきです。 Django ASGI では `sync_to_async` によるブリッジ、ミドルウェアの同期・非同期変換(Vol.2「middleware は async にどう対応するか」)、ORM の非同期ラッパーなどに追加のオーバーヘッドが発生します。 すべてのビューが同期で、サードパーティパッケージも同期前提のプロジェクトを ASGI に移行すると、このオーバーヘッドだけが加わり、単純なベンチマークでは WSGI より遅くなる場合があります。 sync と async の性能特性を対照すると、次のようになります。 | 観点 | 同期(WSGI) | 非同期(ASGI) | |------|-------------|--------------| | 個々のリクエストの速度 | 変わらない | 変わらない(または微増のオーバーヘッド) | | DB クエリの速度 | 変わらない | 変わらない | | CPU バウンド処理 | 変わらない | 変わらない(悪化の可能性) | | 同時接続のスケーラビリティ | ワーカー×スレッド数に制限 | 数千〜数万(I/Oバウンド時) | | 長時間接続(SSE/WS) | スレッド占有で非効率 | 効率的に多数を維持 | | 複数 I/O の並行実行 | ThreadPool で可能 | gather で自然に記述 | | メモリ効率(/同時接続) | スレッドスタック 8MB | コルーチン 数KB | | 同期ライブラリとの互換性 | 問題なし | sync_to_async が必要 | | デバッグの容易さ | 直線的なフロー | await の追跡が必要 | | フレームワークの成熟度 | 十分に成熟 | Django は移行途中 | ```{important} この表から導かれる実務上の判断基準は、「現在のプロジェクトで同時接続数がボトルネックになっているか、または将来なる見込みがあるか」です。 答えが Yes であれば非同期化の投資に見合うリターンがあります。 答えが No であれば、同期モデルのシンプルさとデバッグの容易さを維持する方が、チーム全体の生産性にとって有益です。 ``` 次節では、ここまでの並行処理の知識をトラブルシューティングの観点から掘り下げます。 (worker 設計の考え方)= ## worker 設計の考え方 前節では、sync アプリと async アプリの性能特性を比較し、ワークロードの種類によって適切な並行処理モデルが変わることを確認しました。 しかし、並行処理モデルを選んだだけでは本番環境の設計は終わりません。 Gunicorn のプロセス数をいくつにするのか、gthread ワーカーを使うならスレッド数はいくつにするのか、Uvicorn の async ワーカーで同時に走るコルーチンはどの程度を想定するのか――こうした「数」の決め方が、次の問いになります。 以降では、プロセス・スレッド・コルーチンそれぞれの「数」をどう考えるかを見ていき、最後にそれらすべてを束ねるメモリ消費とのトレードオフを解説します。 ```{mermaid} flowchart TD subgraph A["構成A: sync 9プロセス
メモリ ~1080MB / 同時処理 9"] PA1["Worker 1"] PA2["Worker 2"] PA3["..."] PA4["Worker 9"] end subgraph B["構成B: gthread 4プロセス×4スレッド
メモリ ~500MB / 同時処理 16"] PB1["Worker 1
(4スレッド)"] PB2["Worker 2
(4スレッド)"] PB3["Worker 3
(4スレッド)"] PB4["Worker 4
(4スレッド)"] end subgraph C["構成C: async 4プロセス
メモリ ~400MB / 同時処理 数千"] PC1["Worker 1
(イベントループ)"] PC2["Worker 2
(イベントループ)"] PC3["Worker 3
(イベントループ)"] PC4["Worker 4
(イベントループ)"] end ``` {numref}`「Webサーバ」という言葉の混乱を解く`({ref}`「Webサーバ」という言葉の混乱を解く`)で見たサーバの種類と、12-1 から 12-5 で学んだ並行処理の基礎知識が、ここでひとつの設計判断としてつながります。 ### プロセス数 まず、もっとも粒度の大きな単位であるプロセスの数から考えましょう。 Gunicorn の公式ドキュメントには、有名な「出発点の公式」が掲載されています。 ``` workers = (2 × CPUコア数) + 1 ``` たとえば 4 コアのマシンであれば `(2 × 4) + 1 = 9` ワーカーが出発点になります。 この公式の背後にある考え方はシンプルです。 あるワーカーがネットワーク I/O(ソケットの読み書き)で待機している間に、別のワーカーが CPU を使ってリクエストを処理できるようにするため、CPU コア数よりも多めのワーカーを立てておく、というものです。 `+ 1` は、すべてのワーカーが同時に I/O 待ちに入る瞬間にも 1 つは処理を進められるようにするための余裕分です。 ```{note} この公式はあくまで出発点であって、最終回答ではありません。 Gunicorn の公式ドキュメント自身も「通常 4〜12 ワーカーで十分であり、それで数百から数千のリクエストを毎秒処理できる」と述べています。 ワーカーを増やしすぎると、プロセス間のコンテキストスイッチ、メモリ消費、OS のプロセス管理コストが増大し、むしろスループットが低下します。 ``` では、sync ワーカー(Gunicorn のデフォルト)と async ワーカー(Uvicorn ワーカー)でプロセス数の考え方は変わるのでしょうか。 変わります。sync ワーカーは 1 プロセスにつき同時に 1 リクエストしか処理できないため、並行性はプロセス数そのものに依存します。 一方、async ワーカーは 1 プロセスの中でイベントループが多数のリクエストを並行処理するため、プロセスを多数立てる必要性が下がります。 この違いを具体的に見てみましょう。 4 コアのサーバで Django(sync WSGI)を動かす場合と、FastAPI(async ASGI)を動かす場合の起動コマンドを比較します。 ```bash # Django(sync WSGI): プロセスで並行性を確保する gunicorn myproject.wsgi:application --workers 9 # FastAPI(async ASGI): 各プロセス内でイベントループが並行処理する gunicorn myapp:app -k uvicorn.workers.UvicornWorker --workers 4 ``` Django 側は `(2 × 4) + 1 = 9` という公式に従っています。 FastAPI 側はコア数と同じ 4 にしています。 async ワーカーでは、1 プロセス内のイベントループが I/O 待ちの間に別のリクエストを処理するため、I/O 待ち分のワーカーを余分に立てる必要がないのです。 もうひとつ、コンテナ環境(Docker や Kubernetes)での考え方にも触れておきます。 Kubernetes では、1 コンテナにつき 1 ワーカープロセスだけを動かし、レプリカ数(Pod 数)で水平スケールさせるパターンが主流です。 プロセス管理をアプリケーションサーバに任せるのではなく、Kubernetes のオーケストレーションに委ねるわけです。 FastAPI の公式ドキュメントでも、Kubernetes 上では `--workers 1` で起動し、Pod 数で並行性を確保する方法が推奨されています。 この場合、先ほどの公式は「Pod 数をいくつにするか」という問いに形を変えます。 ```{tip} プロセス数の設計は「公式を適用して終わり」ではなく、実際の負荷をかけて計測し、調整するプロセスです。 Gunicorn は `TTIN` シグナル(ワーカー追加)と `TTOU` シグナル(ワーカー削減)を使って、再起動なしにワーカー数を増減できます。 本番環境でまず控えめな数から始め、レスポンスタイムやCPU使用率を監視しながら調整していくのが現実的なアプローチです。 ``` ### スレッド数 次に、プロセスの中のスレッドについて考えます。 Gunicorn の `gthread` ワーカーは、1 つのワーカープロセスの中にスレッドプールを持ちます。 たとえば `--workers 4 --threads 4` と指定すると、4 プロセス × 4 スレッド = 最大 16 の同時リクエスト処理が可能になります。 ```bash # gthread ワーカー: プロセスとスレッドの組み合わせ gunicorn myproject.wsgi:application -k gthread --workers 4 --threads 4 ``` スレッドとプロセスの最大の違いは、メモリ空間を共有するかどうかです。同一プロセス内のスレッドは同じメモリ空間を使います。 つまり、Python インタープリタやアプリケーションコード、読み込んだライブラリのメモリが共有されるため、プロセスを 16 個立てるよりも 4 プロセス × 4 スレッドのほうが、合計メモリ消費は大幅に少なくなります。 ```{warning} 12-3 で解説した GIL(Global Interpreter Lock)の存在を忘れてはいけません。 CPython では、同一プロセス内の複数スレッドが同時に Python バイトコードを実行することはできません。 したがって、CPU バウンドな処理が中心のアプリケーションでは、スレッドを増やしても並列度は向上しません。 スレッドが効果を発揮するのは、I/O 待ちの間に別のスレッドがリクエストを処理できる場面、つまり I/O バウンドなワークロードです。 ``` では、スレッド数はいくつにすべきでしょうか。 Gunicorn の公式ドキュメントは「ワーカーとスレッドの最適な組み合わせは、ランタイム(CPython か PyPy か)とワークロードに依存する」と述べるにとどめています。 実務的な出発点としては、スレッド数を 2〜4 程度にして、合計の同時処理数(ワーカー数 × スレッド数)がCPUコア数の 2〜4 倍になるように調整するのがよいでしょう。 ここでもうひとつ重要な点があります。 スレッドを使うと keep-alive 接続をサポートできるようになります。 sync ワーカー(デフォルト)は keep-alive をサポートしないため、リクエストごとに TCP 接続が閉じられます。 gthread ワーカーでは、スレッドがアイドル接続を保持しつつ他のスレッドがリクエストを処理できるため、keep-alive が機能します。 nginx などのリバースプロキシを前段に置く構成では sync ワーカーでも問題になりませんが、リバースプロキシなしで直接クライアントに公開する場面(開発環境や内部 API など)では、gthread ワーカーのほうが接続効率がよくなります。 Django アプリケーションを例にとると、典型的なシナリオは「ビューの中でデータベースクエリや外部 API 呼び出しを行い、その結果をテンプレートでレンダリングして返す」というものです。 データベースクエリや外部 API 呼び出しは I/O バウンドであり、テンプレートレンダリングは CPU バウンドです。 この混合ワークロードに対して、gthread ワーカーは「プロセスによる故障分離」と「スレッドによる I/O 待ち時間の有効活用」の両方を得られるバランスの取れた選択肢です。 ### コルーチン数 async の世界に移りましょう。FastAPI を Uvicorn で動かしている場合、1 つのワーカープロセスの中で動くのはスレッドではなくイベントループとコルーチンです。 sync の世界では「プロセス数 × スレッド数 = 同時処理数」という式が明確でした。 async の世界では事情が異なります。 イベントループ上のコルーチンは、I/O 待ちになった瞬間に制御を手放し、別のコルーチンが走り始めます。 このため、理論上は 1 つのワーカープロセスで数千から数万の同時接続を扱うことが可能です。 プロセスやスレッドのように OS レベルのリソースを消費しないため、コルーチンの「数」は OS のスケジューリングコストではなく、アプリケーションが保持するメモリ量とイベントループのタスク管理コストによって制約されます。 では「コルーチン数はいくつまで大丈夫なのか」を考える必要はないのでしょうか。実は、あります。 Uvicorn には `--limit-concurrency` というオプションがあり、同時接続数の上限を設定できます。 これを設定しない場合、接続が際限なく増え、各接続が保持するリクエストコンテキスト(scope 辞書、リクエストボディのバッファなど)がメモリを消費し続けます。 ```{danger} 急激なトラフィック増加時にメモリが枯渇してプロセスが OOM(Out of Memory)で kill される、というのは async サーバでも起こりうる障害パターンです。 `--limit-concurrency` で上限を設定し、超過した接続には 503 を返すように設計しておくことが重要です。 ``` もうひとつ注意すべきは、async エンドポイントの中にブロッキング処理が混在するケースです。 FastAPI では、`def` で定義された同期エンドポイントは内部的にスレッドプール(デフォルト 40 スレッド)で実行されます。 つまり、同期エンドポイントが多いアプリケーションでは、コルーチンの並行性がスレッドプールのサイズに律速されます。 ```python from fastapi import FastAPI import time app = FastAPI() # async def: イベントループ上で直接実行される # I/O 待ちの間に他のコルーチンが走れる @app.get("/async-endpoint") async def async_endpoint(): # asyncio 対応のライブラリで I/O を行う return {"message": "async"} # def: スレッドプールで実行される # 同時実行数はスレッドプールのサイズに制限される @app.get("/sync-endpoint") def sync_endpoint(): time.sleep(1) # ブロッキング I/O return {"message": "sync"} ``` このコードでは、`/async-endpoint` はイベントループ上で数千の同時リクエストを処理できる可能性がありますが、`/sync-endpoint` はデフォルトでは同時に 40 リクエストまでしか処理できません。 41 番目以降のリクエストはスレッドプールの空きを待つことになります。 ```{note} async アプリケーションのコルーチン数を考えるときは、「すべてのエンドポイントが本当に async で I/O を行っているか」を確認する必要があります。 ORM のクエリ、ファイルの読み書き、外部 API 呼び出し――これらが async 対応のライブラリを使っていなければ、コルーチンの並行性という利点は発揮されません。 Vol.2「Django は ASGI にどう対応しているか」で解説した Django の sync/async 境界の問題(`sync_to_async` による橋渡し)も、まさにこの文脈の話です。 ``` ### メモリ消費とのトレードオフ ここまで、プロセス・スレッド・コルーチンの「数」を個別に見てきました。 最後に、これらすべてを束ねるもっとも現実的な制約――メモリ――について考えましょう。 Python の Web アプリケーションは、1 ワーカープロセスあたり数十 MB から数百 MB のメモリを消費します。 アプリケーションのコード量、読み込むライブラリの数、リクエスト処理中に生成するデータ構造によって大きく変わりますが、Django アプリケーションであれば 1 ワーカーあたり 50〜150 MB 程度、大規模なアプリケーションではそれ以上になることも珍しくありません。 この数字をもとに、簡単な計算をしてみましょう。 ``` サーバの利用可能メモリ: 2 GB OS やその他プロセスの予約: 512 MB アプリケーションに使えるメモリ: 1,536 MB 1 ワーカーあたりのメモリ消費: 120 MB 最大ワーカー数 = 1,536 ÷ 120 ≒ 12 ``` 先ほどの `(2 × CPUコア数) + 1` の公式が 9 ワーカーを推奨していても、メモリが足りなければ実現できません。 逆に、メモリに余裕があっても CPU コアが 2 つしかなければ、9 ワーカーを立てても CPU が飽和してコンテキストスイッチのオーバーヘッドが支配的になります。 プロセス数の設計は、CPU コア数とメモリの両方の制約の中で最適な点を見つける作業です。 gthread ワーカーを使うと、この計算が変わります。 同一プロセス内のスレッドはメモリ空間を共有するため、「4 プロセス × 4 スレッド = 同時処理数 16」の構成は、メモリ消費では「4 プロセス分 + スレッドごとのスタック領域」で済みます。 スレッドのスタックサイズはデフォルトで 8 MB(Linux の場合)ですが、Python のスレッドが実際に使うスタック領域はそれよりずっと少なく、物理メモリの消費はプロセスを 16 個立てる場合に比べて大幅に少なくなります。 async ワーカーではさらに事情が変わります。 コルーチンはスレッドのようなスタック領域を持たず、1 つあたり数 KB 程度のメモリで済みます。 そのため、少ないプロセス数でも高い並行性を実現でき、全体のメモリ消費を抑えられます。 先ほど紹介した「コンテナ環境で 1 Pod = 1 ワーカー」というパターンが成立するのは、async ワーカーの場合に 1 プロセスで十分な並行性を確保できるからです。 以下に、同じ 4 コア・2 GB メモリのサーバで、3 つの構成のメモリ消費を概算で比較します。 | 構成 | メモリ消費 | 同時処理数 | |------|-----------|-----------| | 構成A: sync ワーカー 9 プロセス | 120 MB × 9 ≒ 1,080 MB | 9 | | 構成B: gthread ワーカー 4 プロセス × 4 スレッド | 120 MB × 4 + α ≒ 500 MB | 16 | | 構成C: async ワーカー(Uvicorn)4 プロセス | 100 MB × 4 ≒ 400 MB | 数千(I/O バウンドの場合) | 構成 B は構成 A の半分以下のメモリで、より高い同時処理数を実現しています。 構成 C はさらに少ないメモリで桁違いの同時処理数を実現できますが、これはすべてのエンドポイントが async で I/O を行っている理想的なケースの話です。 ```{warning} ワーカープロセスのメモリ消費は時間とともに増加する傾向があります。 Python のガベージコレクションは参照カウントと世代別 GC の組み合わせですが、長時間稼働するプロセスではメモリフラグメンテーションが進み、実質的な使用量が増えていくことがあります。 Gunicorn の `--max-requests` オプションは、指定した回数のリクエストを処理したワーカーを再起動することで、メモリリークやフラグメンテーションの影響を緩和する仕組みです。 ``` ```bash # 1000 リクエストごとにワーカーを再起動(±50 のジッターを追加) gunicorn myproject.wsgi:application \ --workers 4 \ --max-requests 1000 \ --max-requests-jitter 50 ``` `--max-requests-jitter` を組み合わせると、全ワーカーが同時に再起動する「サンダリングハード」問題を避けられます。 ```{important} 結局のところ、worker 設計は次の 4 つの軸で決まります。 1. **CPU コア数** — プロセスの並列実行の上限 2. **利用可能メモリ** — プロセス数の実質的な上限 3. **ワークロードの特性** — CPU バウンドか I/O バウンドかによって最適な構成が変わる 4. **デプロイ基盤** — VM か、コンテナ(Kubernetes)かによって設計パターンが変わる 公式や定石はあくまで出発点であり、最終的な値は本番トラフィックに近い負荷をかけて計測しながら決めるものです。 ``` 次節では、並行処理にまつわる「現場でよくある誤解」を取り上げます。 「async にすれば常に速くなる」「ワーカーを増やせば増やすほどスループットが上がる」といった、ここまでの知識があれば見抜ける誤解を確認していきます。 (現場でよくある誤解)= ## 現場でよくある誤解 前節では、プロセス・スレッド・コルーチンの数をどう決めるかを考え、CPU コア数とメモリの制約の中で最適な点を見つける作業であることを確認しました。 12-1 から 12-6 にかけて並行処理の基礎から worker 設計まで学んできましたが、知識が増えるほど陥りやすくなる落とし穴があります。 それは、正しい知識の一部だけを切り取って、誤った結論に飛びつくことです。 以降では、現場で実際に耳にする 3 つの誤解を確認します。 いずれも本章でここまでに解説した内容を正しく組み合わせれば見抜ける誤解ですが、忙しい開発の現場では驚くほど広まっています。 LLM にコードの書き方を聞いて得られる回答にも、これらの誤解に基づいたアドバイスが混じっていることがあります。 ### async = 常に高速 もっとも広く流布している誤解から始めましょう。 「Flask を FastAPI に書き換えれば速くなる」「`async def` にすれば速くなる」――チームの技術選定やリファクタリングの議論で、こうした主張を聞いたことがある方は多いのではないでしょうか。 12-5 で見たように、async が効果を発揮するのは I/O バウンドなワークロードです。 リクエスト処理の大部分がデータベースクエリや外部 API の応答を「待つ」時間であるとき、async はその待ち時間を他のリクエストの処理に充てることでスループットを向上させます。 しかし、これは「async にすると処理そのものが速くなる」という意味ではありません。 1 つのリクエストのレイテンシ(応答にかかる時間)が短くなるわけではなく、同じ時間内に処理できるリクエストの総数が増えるという話です。 この違いは重要です。 たとえば、画像のリサイズ処理やレポートの集計処理のように CPU を使い続ける処理が中心のエンドポイントでは、async にしてもスループットはほとんど変わりません。 イベントループは I/O 待ちの瞬間に他のタスクに切り替えることで並行性を実現しますが、CPU が計算に占有されている間は切り替えのタイミングが訪れないからです。 さらに厄介なのは、async にすることでかえって遅くなるケースがあることです。 次のコードを見てください。 ```python from fastapi import FastAPI import time app = FastAPI() @app.get("/report") async def generate_report(): # 重い集計処理(CPU バウンド) time.sleep(3) # 実際には pandas や numpy での計算を想定 return {"status": "done"} ``` このエンドポイントは `async def` で定義されているため、イベントループ上で直接実行されます。 `time.sleep(3)` はブロッキング呼び出しであり、この 3 秒間イベントループ全体が止まります。 他のリクエストがすべてこの 3 秒を待たされるのです。 ```{warning} 同じコードを `def`(sync)で定義していれば、FastAPI が自動的にスレッドプールで実行するため、他のリクエストへの影響は限定的です。 ```python @app.get("/report") def generate_report(): # sync def → スレッドプールで実行される # イベントループはブロックされない time.sleep(3) return {"status": "done"} ``` つまり、`async def` と書くことは「この関数は I/O 待ちの間にイベントループを明け渡す準備ができている」と宣言することに等しく、実際に `await` で制御を手放さないブロッキング処理が含まれていれば、宣言と実態が矛盾した状態になります。 この矛盾は、sync アプリよりも悪い性能をもたらします。 ``` ```{admonition} 「async = 常に高速」の正しい言い換え async は、I/O 待ちが多いワークロードにおいて、待ち時間を有効活用することでスループットを向上させます。 ただし、そのためにはエンドポイント内のすべての I/O が `await` 可能な非同期ライブラリを使っていなければなりません。 CPU バウンドな処理やブロッキング I/O が混在するなら、sync エンドポイントとして定義するか、`run_in_executor` でスレッドプールに逃がす設計が必要です。 ``` ### worker を増やせば無限に捌ける 2 つ目の誤解は、並行処理を「数の力」で解決しようとする発想です。 ```{mermaid} flowchart TD Inc["ワーカー数を増やす"] Inc --> Phase1["CPU コア数以下
→ スループット線形向上"] Phase1 --> Phase2["CPU コア数超え
→ 向上は緩やか"] Phase2 --> Phase3["メモリ/DB 接続が上限に
→ スループット頭打ち"] Phase3 --> Phase4["さらに増やす
→ OOM / DB 接続拒否で障害"] ``` 「レスポンスが遅い? ワーカーを増やせばいい」「トラフィックが増えた? ワーカー数を倍にしよう」――直感的にはもっともらしく聞こえますが、12-6 で見たように、ワーカー数にはCPUとメモリという物理的な上限があります。 この誤解のもう少し根深い部分を掘り下げましょう。ワーカーを増やすと何が起きるかを段階的に考えてみます。 1. **最初の段階**: ワーカー数が CPU コア数より少ない状態から増やし始めます。各ワーカーが独立した CPU コアを使えるため、スループットはほぼ線形に向上します。これが「ワーカーを増やせば速くなる」という経験が正しい範囲です。 2. **次の段階**: ワーカー数が CPU コア数を超え始めます。I/O 待ちの時間を別のワーカーが使えるため、まだスループットの向上は続きます。ただし、向上の度合いは緩やかになっていきます。 3. **飽和の段階**: OS のコンテキストスイッチのコスト、メモリ消費の増大、そしてデータベースの接続数圧迫が効いてきます。 特にデータベース接続は見落とされがちな制約です。 PostgreSQL のデフォルト最大接続数は 100 です。 Gunicorn のワーカーが 9 つあり、Django の `CONN_MAX_AGE` でコネクションプーリングを使っていると、1 ワーカーあたり 1 接続で 9 接続を消費します。 gthread で `--threads 4` を加えると、最大で 36 接続まで増える可能性があります。 ここにバックグラウンドジョブの Celery ワーカーやマイグレーション用の接続を加えると、100 という上限は案外すぐに見えてきます。 ``` ワーカー数の増加がもたらす効果の変化: ワーカー数: 2 4 8 12 16 24 32 │ │ │ │ │ │ │ スループット: ─────────────────────┐ │ ← ここから頭打ち └────────────── メモリ消費: ↗ 急増 DB 接続数: ↗ 上限に到達 ``` ```{danger} 飽和点を超えてワーカーを増やすと、スループットは横ばいのまま、メモリ消費とデータベース接続数だけが増えていきます。 最悪の場合、メモリ不足で OS がプロセスを kill したり、データベースが接続を拒否して 500 エラーが連発したりします。 ワーカーを増やすことが原因で障害が起きるというのは、皮肉な結末です。 ``` 正しいアプローチは、まずボトルネックがどこにあるかを特定することです。 - CPU 使用率が高いなら → ワーカーを増やすのは有効かもしれません - データベースクエリが遅いなら → インデックスの追加やクエリの最適化が先です - 外部 API の応答が遅いなら → キャッシュの導入や async 化を検討すべきです - メモリが足りないなら → gthread ワーカーに切り替えてメモリ効率を上げるか、async ワーカーに移行するかを検討します ワーカー数の調整は、ボトルネックの特定と対策のあとに行う「最後の微調整」であって、最初に回すダイヤルではありません。 ### GIL があるから async は意味がない 3 つ目の誤解は、12-3 で学んだ GIL の知識が裏目に出るケースです。 「CPython には GIL があるから、1 プロセスで同時に走れるスレッドは 1 つだけ。 async もスレッドも結局シングルスレッドと同じだから意味がない」――この推論は、前提は正しいのに結論が間違っています。 GIL が制約するのは「同時に Python バイトコードを実行できるスレッドは 1 つだけ」ということです。 しかし、async のイベントループはそもそもスレッドの並列実行に依存していません。 イベントループは 1 つのスレッドの中で動作し、I/O 待ちの間にタスクを切り替える協調的マルチタスクです。 GIL が禁止しているのは「複数スレッドによる Python コードの同時実行」であり、「1 つのスレッド内での協調的なタスク切り替え」は GIL の制約とは無関係です。 この違いを理解するために、レストランの比喩で考えてみましょう。 ```{admonition} レストランの比喩で理解する GIL と async GIL は「キッチンには同時に 1 人のシェフしか入れない」というルールです。 - **sync のマルチスレッドモデル**: 複数のシェフを雇って交代でキッチンに入らせます。交代のたびに道具を片付けて引き継ぐ(コンテキストスイッチ)コストがかかり、結局キッチンで料理をしているのは常に 1 人です。 - **async モデル**: 1 人のシェフが複数の料理を同時に進めます。パスタを茹でている間(I/O 待ち)にソースを準備し(別のタスク)、ソースを煮込んでいる間(I/O 待ち)にサラダを盛り付ける(さらに別のタスク)。キッチンのルール(GIL)は「同時に 1 人のシェフ」ですが、そもそも 1 人しかいないので制約にはなりません。 ``` GIL が本当に問題になるのは、すべての料理が焼く・切る・混ぜるといった手を動かし続ける作業(CPU バウンド処理)だけで構成されているときです。 この場合、待ち時間がないためタスクを切り替えるタイミングがなく、1 人のシェフは 1 つの料理にかかりきりになります。 しかし、現実の Web アプリケーションは、大部分の時間をデータベースの応答待ちやネットワーク I/O に費やしています。 この状況では、GIL の存在にかかわらず、async は大きな効果を発揮します。 もう 1 つ補足しておくと、GIL は Python のバイトコード実行を制約しますが、C 拡張で実装された I/O 操作(ソケットの読み書き、ファイルの読み書きなど)は GIL を解放して実行されます。 つまり、`socket.recv()` や `os.read()` が実行されている間、GIL は他のスレッドに明け渡されています。 async の場合はこの仕組みをさらに一歩進めて、GIL の解放・再取得というコストすら発生させずに、イベントループのレベルでタスクを切り替えます。 ```{admonition} 「GIL があるから async は意味がない」の正しい言い換え GIL があるため、CPU バウンドな処理を複数スレッドで並列に実行することはできません。 しかし async は並列実行(パラレリズム)ではなく並行処理(コンカレンシー)の仕組みであり、I/O 待ちの時間を有効活用します。 Web アプリケーションのように I/O 待ちが支配的なワークロードでは、GIL の制約下でも async は大きな効果があります。 CPU バウンドな処理を本当に並列に実行したいなら、マルチプロセス(12-2 で解説したプロセスの分離)を使うのが正解であり、これは async かどうかとは別の設計判断です。 ``` --- 3 つの誤解に共通しているのは、ある技術や概念の一面だけを見て全体を判断している、という点です。 - 「async は速い」は I/O バウンドの文脈では正しい - 「ワーカーを増やせばスループットが上がる」は飽和点の手前までは正しい - 「GIL があると並行処理が制限される」は CPU バウンドの文脈では正しい 問題は、これらの部分的に正しい知識を、文脈を無視して普遍的な法則のように適用してしまうことです。 本書を通じて繰り返し強調しているのは「どの層の責務か」を見極めることの重要性ですが、並行処理についても同じことが言えます。 「どのワークロードに対して、どの並行処理モデルが、どの制約のもとで有効か」――この問いを省略せずに考える習慣が、誤解に振り回されない開発者の土台になります。 次節では、本章の締めくくりとして、並行処理にまつわるトラブルシューティングの観点を取り上げます。 ここまでの知識を、実際に問題が起きたときにどう使うかという実践的な視点で確認します。 (ch12-トラブルシューティングの観点)= ## トラブルシューティングの観点 前節では、並行処理にまつわる 3 つのよくある誤解を取り上げました。 誤解を見抜く力があれば、設計段階で誤った判断を避けられます。 しかし、本番環境では設計時には想定しなかった問題が必ず起きます。 「なぜかレスポンスが遅い」「特定の時間帯だけタイムアウトが頻発する」「メモリが徐々に増えてプロセスが再起動される」――こうした症状に直面したとき、本章で学んだ並行処理の知識をどのように使って原因を切り分けるかが、本節のテーマです。 以降では「詰まっているのは CPU か I/O か」「イベントループが止まっていないか」「データベースコネクション数との整合」という 3 つの切り口を見ていきます。 ```{mermaid} flowchart TD Slow["レスポンスが遅い"] Slow --> Q1{"CPU 使用率が高いか?"} Q1 -->|"Yes (CPU バウンド)"| Fix1["処理を最適化
キャッシュ導入
Celery に切り出し"] Q1 -->|"No (I/O 待ち)"| Q2{"どこで待っているか?"} Q2 -->|"DB クエリ"| Fix2["スロークエリ確認
インデックス追加"] Q2 -->|"外部 API"| Fix3["タイムアウト設定
非同期化・キャッシュ"] Q2 -->|"async アプリで全体遅延"| Fix4["PYTHONASYNCIODEBUG=1
ブロッキング呼び出しを特定"] ``` いずれも問題の症状から原因の層を絞り込むための思考の型であり、具体的なコマンドやコードと組み合わせて解説します。 ### 詰まっているのは CPU か I/O か レスポンスが遅いとき、最初に答えるべき問いは「ボトルネックは CPU か I/O か」です。 12-4 で解説した CPU バウンドと I/O バウンドの区別は、理論上の分類ではなく、トラブルシューティングの出発点として使うものです。 まず、サーバの CPU 使用率を確認しましょう。 Linux であれば `top` コマンドや `htop` コマンドで、各プロセスの CPU 使用率をリアルタイムに確認できます。 ```bash # Gunicorn ワーカーの CPU 使用率を確認する top -c -p $(pgrep -d',' -f gunicorn) ``` ここで注目するのは、ワーカープロセスの CPU 使用率が高いか低いかです。 - **CPU 使用率が高い場合**: 処理が CPU バウンドである可能性が高く、ワーカー内で重い計算が走っていることが疑われます。画像処理、大量データの集計、複雑な正規表現のマッチングなどが典型的な原因です。 - **CPU 使用率が低いのにレスポンスが遅い場合**: ワーカーが何かを「待っている」ことを意味します。データベースクエリの応答待ち、外部 API の応答待ち、ファイルシステムの I/O 待ち、あるいはロックの取得待ちなどです。つまり I/O バウンド、もしくはリソース競合が原因です。 この切り分けによって、次に取るべきアクションが変わります。 CPU バウンドが原因なら、処理そのものの最適化(アルゴリズムの改善、キャッシュの導入、重い処理のバックグラウンドジョブへの切り出し)が先です。 ワーカー数を増やしても、CPU コア数以上の並列処理はできないことを 12-6 で確認しました。 I/O バウンドが原因なら、待ち先の特定が次のステップです。 待ち先を特定するには、アプリケーション側のログやメトリクスが役立ちます。 Django であれば、データベースクエリの実行時間を `django.db.connection.queries` で確認できます。 ```python from django.db import connection def debug_slow_view(request): # ビューの処理 result = expensive_query() # 開発環境でクエリの実行時間を確認する for query in connection.queries: print(f"{query['time']}s: {query['sql'][:80]}") return result ``` FastAPI であれば、ミドルウェアでリクエスト全体の処理時間を計測し、遅いリクエストをログに残す方法が有効です。 ```python import time import logging from fastapi import FastAPI, Request app = FastAPI() logger = logging.getLogger("slow_request") @app.middleware("http") async def log_slow_requests(request: Request, call_next): start = time.perf_counter() response = await call_next(request) duration = time.perf_counter() - start if duration > 1.0: logger.warning( f"Slow request: {request.method} {request.url.path} " f"took {duration:.2f}s" ) return response ``` この計測結果と CPU 使用率を突き合わせることで、「リクエストに 3 秒かかっているが CPU 使用率は 5% しかない。 待っている先はデータベースだ」といった具合に原因の層を絞り込めます。 12-7 で見たように、この段階でいきなり「ワーカーを増やそう」と飛びつくのではなく、まずボトルネックの正体を見極めることが重要です。 ### event loop が止まっていないか async アプリケーション特有のトラブルとして、「イベントループが止まっている」問題があります。 12-7 の最初の誤解で触れた、`async def` の中にブロッキング呼び出しが紛れ込むケースです。 ```{warning} この問題の厄介なところは、症状が「全体的に遅い」という曖昧な形で現れることです。 特定のエンドポイントだけが遅いのではなく、ある瞬間にすべてのリクエストが一斉に遅くなり、しばらくすると回復する。 そしてまた遅くなる。 この間欠的な遅延パターンが見られたら、イベントループのブロッキングを疑うべきです。 ``` 原因の特定には、Python 標準ライブラリの asyncio が提供するデバッグモードが役立ちます。 ```python import asyncio import logging # asyncio のデバッグモードを有効にする # 100ms 以上イベントループをブロックした呼び出しを警告する logging.basicConfig(level=logging.DEBUG) loop = asyncio.get_event_loop() loop.set_debug(True) loop.slow_callback_duration = 0.1 # 秒 ``` Uvicorn を使っている場合は、環境変数 `PYTHONASYNCIODEBUG=1` を設定することで同等の効果が得られます。 ```bash PYTHONASYNCIODEBUG=1 uvicorn myapp:app --log-level debug ``` デバッグモードが有効な状態でアプリケーションを動かすと、イベントループを長時間ブロックしたコルーチンが警告として出力されます。 たとえば次のような警告が表示されたら、そのコルーチンの中にブロッキング呼び出しが含まれています。 ``` WARNING:asyncio:Executing took 2.150 seconds ``` よくある原因は次の通りです。 - ORM のクエリ(async 対応していない場合) - ファイルの読み書き - `time.sleep()` - 同期的な HTTP クライアント(`requests` ライブラリなど)の呼び出し これらが `async def` の中に書かれていると、`await` を使っていないためイベントループに制御が戻りません。 対処には主に 3 つのアプローチがあります。 1. **ブロッキング呼び出しを async 対応のライブラリに置き換える**: `requests` の代わりに `httpx` の async クライアントを使う、同期 ORM の代わりに async 対応のデータベースドライバを使う、といった対応です。 2. **`async def` を `def` に変更する**: FastAPI は `def` で定義されたエンドポイントを自動的にスレッドプールで実行します。 3. **`asyncio.to_thread()` や `loop.run_in_executor()` を使う**: ブロッキング呼び出しだけをスレッドプールに逃がします。 ```python import asyncio from fastapi import FastAPI app = FastAPI() def blocking_io_operation(): """async 対応版が存在しないライブラリの呼び出し""" import requests resp = requests.get("https://api.example.com/data") return resp.json() @app.get("/data") async def get_data(): # ブロッキング呼び出しをスレッドプールに逃がす result = await asyncio.to_thread(blocking_io_operation) return result ``` このコードでは、`blocking_io_operation` の実行中もイベントループは他のリクエストを処理し続けることができます。 Vol.2「Django は ASGI にどう対応しているか」で解説した Django の `sync_to_async` も、内部的にはこれと同じ仕組みでブロッキング呼び出しをスレッドプールに委譲しています。 ```{note} イベントループのブロッキング問題は開発環境では再現しにくいことがあります。 開発環境では同時リクエスト数が少ないため、イベントループが 1 秒ブロックされても他に処理すべきリクエストがなく、体感上の問題が表面化しないのです。 本番環境で同時接続数が増えて初めて「全体が遅い」という症状が顕在化します。 だからこそ、本番デプロイの前に負荷テストを行い、同時リクエスト数を上げた状態でレスポンスタイムの変動を確認することが大切です。 ``` ### DB コネクション数との整合 3 つ目の切り口は、12-7 の「worker を増やせば無限に捌ける」の誤解でも触れたデータベースコネクション数の問題です。 ここではより実践的な観点から、ワーカー設計とデータベース設定の整合をどう取るかを解説します。 典型的な症状は「デプロイ直後やトラフィック増加時に、突然 500 エラーが多発する」というものです。 アプリケーションのログを見ると、`OperationalError: FATAL: too many connections for role "myapp"` や `connection pool exhausted` といったエラーメッセージが出ています。 この問題の根本原因は、アプリケーション側のワーカー構成が要求するコネクション数の合計が、データベース側の最大接続数を超えていることです。計算してみましょう。 ``` Gunicorn: --workers 8 --threads 4 (gthread ワーカー) → 8 プロセス × 4 スレッド = 32 の同時リクエスト処理 Django の CONN_MAX_AGE = None (持続的接続) → 各スレッドがデータベース接続を 1 つ保持 → 最大 32 接続 Celery ワーカー: --concurrency 8 → 最大 8 接続 マイグレーション、管理コマンド、監視ツールなど → 数接続 合計: 32 + 8 + 数 ≒ 42 接続 ``` PostgreSQL のデフォルト最大接続数は 100 ですから、この構成なら問題ありません。 しかし、本番環境でワーカー数を増やしたり、Celery の concurrency を上げたり、ステージング環境が同じデータベースに接続していたりすると、100 という上限にあっさり到達します。 Django の場合、`CONN_MAX_AGE` の設定がコネクション管理に大きく影響します。 - `CONN_MAX_AGE = 0`(デフォルト): リクエストごとにコネクションを開いて閉じます。接続の確立にはコストがかかりますが、アイドル状態のコネクションがデータベース側を圧迫することはありません。 - `CONN_MAX_AGE = None`: コネクションを永続的に保持します。接続確立のコストはなくなりますが、各スレッドが 1 つずつコネクションを握り続けるため、同時接続数が増えやすくなります。 ```python # Django settings.py DATABASES = { "default": { "ENGINE": "django.db.backends.postgresql", "NAME": "myapp", # ... "CONN_MAX_AGE": 600, # 600 秒間コネクションを再利用する } } ``` `CONN_MAX_AGE = 600` のように秒数を指定すると、一定時間コネクションを再利用しつつ、長時間アイドルのコネクションは閉じるという中間的な戦略が取れます。 FastAPI の場合は、データベースドライバやライブラリ側のコネクションプール設定を意識する必要があります。 たとえば SQLAlchemy の async エンジンを使うなら `pool_size` と `max_overflow` がコネクション数を決めます。 ```python from sqlalchemy.ext.asyncio import create_async_engine engine = create_async_engine( "postgresql+asyncpg://user:pass@localhost/myapp", pool_size=5, # プールに保持する接続数 max_overflow=10, # プールが満杯のときに追加で作れる接続数 ) # このワーカーの最大接続数 = pool_size + max_overflow = 15 ``` Uvicorn ワーカーが 4 つなら、最大接続数は `15 × 4 = 60` です。 この数字がデータベースの上限に収まっているかを確認します。 ```{tip} 整合を取る手順は次の通りです。 1. PostgreSQL の `SHOW max_connections;` でデータベース側の上限を確認する 2. ワーカー構成から最大コネクション数を計算する 3. Celery・管理コマンド・監視ツールといったアプリケーション以外のコネクション消費を加算する 4. 合計がデータベースの最大接続数の **80% 程度** に収まるようにワーカー数やコネクションプールのサイズを調整する 余裕を持たせるのは、一時的なスパイクやメンテナンス時の追加接続に備えるためです。 ``` 本番環境で PgBouncer のようなコネクションプーラーを導入するのも有効な対策です。 PgBouncer はアプリケーションとデータベースの間に立ち、多数のアプリケーション接続を少数のデータベース接続に多重化します。 しかし、PgBouncer を導入するかどうかは{numref}`開発環境と本番環境は何が違うのか`({ref}`開発環境と本番環境は何が違うのか`)のデプロイ構成の話題になりますので、ここではワーカー設計との整合を取るという観点に留めておきます。 --- 本章では、並行処理という大きなテーマを、なぜ重要か(12-1)から始めて、基本単位の違い(12-2)、GIL の制約(12-3)、ワークロードの分類(12-4)、sync と async の性能特性(12-5)、worker 設計の実際(12-6)、よくある誤解(12-7)、そしてトラブルシューティング(本節)まで一通り歩いてきました。 並行処理は、それ単体で完結するテーマではありません。 {numref}`「Webサーバ」という言葉の混乱を解く`({ref}`「Webサーバ」という言葉の混乱を解く`)で見たサーバの選択肢、本章で整理した並行処理モデル、そして次の{numref}`開発環境と本番環境は何が違うのか`({ref}`開発環境と本番環境は何が違うのか`)で扱う本番デプロイの構成――これらは三位一体の設計判断です。 サーバの種類を選び、並行処理モデルを理解し、本番環境の制約の中で構成を決める。