第2章 なぜ Web 開発で並行処理が重要なのか
2.1 複数リクエスト
Web サーバは本質的に複数のクライアントから同時にリクエストを受け取るシステムです。 ブラウザが1つのページを表示するだけでも、HTML、CSS、JavaScript、画像、API エンドポイントへのリクエストが並行して送信されます。 10人のユーザーが同時にサイトを閲覧すれば、数十から数百のリクエストがほぼ同時にサーバに到着します。
もしサーバがリクエストを完全に逐次処理するとしましょう。1リクエストあたり100 msかかる処理で10リクエストが同時に到着すると、最後のリクエストは900 ms待たされた上で100 msの処理が行われ、合計1000 ms後にレスポンスを受け取ります。 最初のリクエストは100 msで完了しますが、到着順が後になるほどレスポンスタイムが線形に悪化します。
図2-1 逐次処理と並行処理(4ワーカー)の完了時間の比較
第1章(「Web サーバ」という言葉の混乱を解く)で見た Gunicorn のマルチワーカー構成は、まさにこの問題を解決するためのものです。 4ワーカーであれば4リクエストを同時に処理でき、10リクエストは3ラウンドで完了します。
注釈
「ワーカーを増やせばいくらでもスケールする」わけではありません。 1.11 節(トラブルシューティングの観点)で見た通り、ワーカーごとにメモリを消費し、CPU コア数を超えるワーカーはコンテキストスイッチのオーバーヘッドを増やします。 並行処理の戦略を理解することが、限られたリソースで最大のスループットを得るために不可欠です。
2.2 待ち時間の重なり
Web アプリケーションの処理時間の大部分は「待ち」です。
図2-2 同期モデルと非同期モデルの I/O 待ち時間の重なり
ビュー関数が実行している100 msのうち、CPU が実際に計算しているのは数ms程度で、残りはデータベースの応答待ち、外部 API の応答待ち、ファイルシステムの I/O 待ちに費やされています。
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%)
同期モデルでは、この213 msの I/O 待ち時間の間、ワーカー(プロセスまたはスレッド)は何もせずに待機しています。 他のリクエストを処理する能力があるにもかかわらず、I/O の完了を待つためだけにリソースが占有されています。
非同期モデルはこの「待ち時間の重なり」を活用します。1つのリクエストが DB の応答を待っている間に、別のリクエストの処理を進められます。 Vol.2「なぜ ASGI が必要になったのか」で ASGI が必要な理由として述べた「I/O 待ち中のリソースの無駄」は、この具体的な状況を指しています。
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(この例では差は小さいが、
# 他のリクエストも同時に処理できることが本質的な利点)
重要
非同期化の利点が発揮されるのは I/O バウンドな処理に限られます。 CPU バウンドな処理(画像変換、暗号計算、大量のデータ集計など)では、CPU が実際に稼働しているため、待ち時間を他の処理に充てることができません。 この区別が並行処理の戦略選択の出発点です。
2.3 スループットとレイテンシ
並行処理を議論する際、しばしば混同される2つの指標があります。
スループット: 単位時間あたりに処理できるリクエスト数です。「このサーバは秒間1000リクエストを処理できる」というのがスループットの表現です。
レイテンシ: 1つのリクエストが到着してからレスポンスが返るまでの時間です。「このエンドポイントのP95レイテンシは50 msである」というのがレイテンシの表現です。
注釈
パーセンタイル(P95)とは
「P95 レイテンシが 50 ms」とは、リクエストを応答時間の速い順に並べたとき、95% が 50 ms 以内に収まるという意味です。100 件のリクエストなら 95 件は 50 ms 以内に返り、残りの 5 件はそれより時間がかかります。
平均値を使わないのは、少数の極端に遅いリクエストに引きずられたり、逆に大多数が速いと遅い一部が埋もれたりして、実際のユーザー体験を表しにくいためです。パーセンタイルなら「ほとんどの利用者が体感する遅さ」を示せます。中央値である P50(半分がこの値以内)や、上位の P99(99% がこの値以内で、最も遅い 1% の手前)もよく併記され、位が上がるほど最悪に近いケースの利用者体験を表します。
並行処理はスループットを改善しますが、個々のリクエストのレイテンシを改善するとは限りません。
シナリオ: 各リクエストの処理に 50ms かかる
1ワーカー:
スループット: 20 req/s(1000ms ÷ 50ms)
レイテンシ(無負荷): 50ms
レイテンシ(20 req/s の負荷): 50ms〜1000ms(キュー待ち)
4ワーカー:
スループット: 80 req/s
レイテンシ(無負荷): 50ms(変わらない)
レイテンシ(80 req/s の負荷): 50ms〜250ms(キュー待ちが短縮)
ワーカーを増やすとスループットはほぼ線形に向上しますが、個々のリクエストの処理時間(50 ms)は変わりません。 改善されるのは、高負荷時にキューで待たされる時間です。 レイテンシ自体を改善するには、ビュー関数の処理を高速化します(SQL の最適化、キャッシュの導入、不要な処理の削除)。
非同期処理が特にスループットの改善に効くのは、I/O 待ちが処理時間の大部分を占める場合です。 1ワーカーでも、I/O 待ちの間に他のリクエストを処理できるため、実質的なスループットが同期モデルの数倍から数十倍になります。 Vol.2「なぜ ASGI が必要になったのか」で「100同時接続でも1ワーカープロセスで対応可能」と述べたのは、各接続の I/O 待ちが重なることで、少ないリソースで高いスループットを実現できるためです。
モデル |
1ワーカーのスループット |
100 req/s の達成 |
|---|---|---|
同期モデル(I/O 待ちが 90% の処理) |
10 req/s |
10 ワーカーが必要 |
非同期モデル(同じ処理) |
約 100 req/s |
I/O 待ちの間に他のリクエストを処理し、少ないワーカーで高スループットを実現 |
一方で、CPU バウンドな処理では非同期モデルの利点はほとんどありません。 CPU が100%稼働している間は他の処理に切り替えられないため、スループットの改善にはプロセスの並列実行(マルチプロセス)が必要です。
この区別を踏まえて、次節以降では Python が提供する並行処理の3つの手段(マルチプロセス、マルチスレッド、非同期 I/O)の仕組みと、それぞれが Web 開発のどの場面で有効かを掘り下げていきます。
2.4 process, thread, coroutine の違い
2.4.1 OS プロセス
プロセスは OS が管理する独立した実行単位です。 各プロセスは独自のメモリ空間(仮想アドレス空間)を持ち、他のプロセスのメモリに直接アクセスできません。
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つのワーカープロセスがクラッシュ(セグメンテーションフォルト、未捕捉例外など)しても、他のワーカープロセスやマスタープロセスには影響しません。 1.6 節(Gunicorn)で Gunicorn のマスタープロセスがクラッシュしたワーカーを再生成できるのは、プロセスが隔離されているからです。 グローバル変数の変更、メモリ破壊、ライブラリの内部状態の汚染が他のプロセスに波及しないため、堅牢なサーバ運用が可能になります。
制約の面では、プロセス間でデータを共有するためにプロセス間通信(IPC)が必要です。 パイプ、ソケット、共有メモリ、メッセージキューなどの仕組みを使って明示的にデータをやり取りします。 Django のビュー関数でグローバル変数に値を保存しても、他のワーカープロセスからはその変更が見えません。 1.6 節(Gunicorn)で各ワーカーが独立してリクエストを処理すると述べた背景には、このプロセス隔離の仕組みがあります。
# プロセス間ではメモリが共有されない
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)モデル、つまりリクエスト到着前にワーカーを事前に生成する設計を採用する理由です。
リクエストのたびにプロセスを生成していては、オーバーヘッドが処理時間を大きく上回ってしまいます。
2.4.2 スレッド
スレッドは同一プロセス内で動作する軽量な実行単位です。 同じプロセスに属するスレッドはメモリ空間を共有するため、グローバル変数やオブジェクトに直接アクセスできます。
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)のリスクが生じます。
警告
上記の例で lock を使わなければ、counter += 1 の操作(読み取り → 加算 → 書き戻し)の途中で別のスレッドが割り込み、カウントが正しくなくなる可能性があります。
スレッドを使う際は共有データへのアクセスを慎重に設計してください。
Python には GIL(Global Interpreter Lock)という特有の制約があります。 CPython インタプリタは、同時に1つのスレッドだけが Python バイトコードを実行できるようにグローバルロックをかけています。
図2-3 Python の GIL によるスレッド実行の排他
GIL はI/O 操作(ソケットの recv/send、ファイルの read/write、time.sleep など)の実行中に解放されます。
つまり、スレッド1が DB の応答を待っている間に GIL が解放され、スレッド2が Python コードを実行できます。
Web アプリケーションの処理時間の大部分が I/O 待ちであるという第2章(なぜ Web 開発で並行処理が重要なのか)の議論を踏まえると、GIL があってもマルチスレッドは Web サーバの同時接続処理に有効です。
Gunicorn の --threads オプション(1.6 節(Gunicorn))は、各ワーカープロセス内にスレッドを作成します。
4ワーカー × 4スレッドであれば16リクエストを同時に処理でき、各スレッドが I/O 待ちの間に他のスレッドが実行されます。
ただし CPU バウンドな処理では GIL がボトルネックとなり、スレッドを増やしてもスループットは向上しません。
その場合はプロセスを増やします。
2.4.3 coroutine / task
コルーチンは Python の async def で定義される関数で、await で実行を中断と再開できる軽量な実行単位です。
OS ではなく 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 がタイムスライスで強制的にスレッドを切り替えるプリエンプティブマルチタスクとは異なり、コルーチンは自発的に制御を譲ります。
# コルーチンの実行の流れ(単一スレッド上)
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 のフレームオブジェクトの参照を変更するだけです。 数万のコルーチンを同時に動かしても、メモリ消費とスケジューリングのオーバーヘッドは最小限です。
注意
「協調的」であることは、await を書かないコルーチンは他のコルーチンに制御を渡さないことを意味します。
Vol.2「ch09-トラブルシューティングの観点」と Vol.2「ch10-トラブルシューティングの観点」で繰り返し述べた「async def 内での同期ブロッキング呼び出し」の問題は、コルーチンが await なしに長時間実行され続け、イベントループが他のタスクをスケジュールできなくなる状況です。
2.4.4 それぞれのコストと特性
3つの並行処理手段のコストと特性は次の通りです。
図2-4 プロセス・スレッド・コルーチンの構造比較
特性 |
プロセス |
スレッド |
コルーチン |
|---|---|---|---|
生成コスト |
高い(fork, メモリ複製) |
中程度(スタック確保) |
極めて低い(フレームオブジェクト) |
メモリ消費 / 単位 |
大きい(~50-150 MB) |
中程度(~8 MB スタック) |
極めて小さい(~数KB) |
同時数の目安 |
数〜数十 |
数十〜数百 |
数千〜数万 |
メモリ共有 |
なし(IPC が必要) |
あり(要ロック) |
あり(単一スレッド) |
スケジューリング |
OS(プリエンプ) |
OS(プリエンプ) |
イベントループ(協調的) |
CPU 並列実行 |
可能 |
GIL で制限 |
不可(単一スレッド) |
I/O 並行処理 |
可能 |
可能 |
可能(最も効率的) |
クラッシュの影響 |
他プロセスに波及しない |
プロセス全体が影響を受ける |
タスクの例外が他タスクに波及しない |
デバッグ |
比較的容易(独立実行) |
競合状態のデバッグが困難 |
await の流れを追跡する必要 |
Web サーバにおける使い分けを、本書で見てきた構成に対応させると次のようになります。
Gunicorn のマルチワーカー(1.6 節(Gunicorn))はプロセスレベルの並行処理です。各ワーカーが独立したプロセスとして動作し、1つのワーカーのクラッシュが他に影響しません。CPU バウンドな処理でも複数コアを活用でき、GIL の制約を受けません。
Gunicorn の
--threads(1.6 節(Gunicorn))はスレッドレベルの並行処理です。同一ワーカー内でスレッドがメモリを共有し、I/O 待ちの間に他のスレッドがリクエストを処理します。プロセスを増やすよりメモリ効率がよく、I/O バウンドな処理に有効です。Uvicorn のイベントループ(1.8 節(Uvicorn))はコルーチンレベルの並行処理です。単一のスレッド上で数千の同時接続を処理でき、SSE やWebSocket のような長時間接続に最適です。FastAPI や Django の
async defビューはこの上で動作します。
Tip
実際の本番環境では、これらが組み合わされます。
1.9 節(Gunicorn + Uvicorn Worker)の Gunicorn + Uvicorn Worker 構成は、プロセスレベルの隔離(Gunicorn のマルチワーカー)の中で、コルーチンレベルの並行処理(Uvicorn のイベントループ)が動作する二段構えです。
Django の sync_to_async(Vol.2「sync / async 境界の橋渡し」)は、コルーチンの中からスレッドプールを使ってスレッドレベルの並行処理に橋渡しします。
3つの手段は排他的な選択肢ではなく、階層的に組み合わせて使うものです。
次節では GIL の仕組みをより詳しく掘り下げ、Python の並行処理における実際の制約を理解します。
2.5 GIL の基本
2.5.1 何を制限するのか
GIL(Global Interpreter Lock)は CPython インタプリタの内部に存在するミューテックス(排他ロック)で、同時に1つのスレッドだけが Python バイトコードを実行できるように制限します。
「Python バイトコードの実行」とは何かを明確にしておきましょう。 Python のソースコードは実行前にバイトコードにコンパイルされ、CPython の仮想マシンがそのバイトコードを1命令ずつ実行します。
# このソースコード:
x = a + b
# は以下のようなバイトコードに変換される:
# LOAD_NAME a
# LOAD_NAME b
# BINARY_ADD
# STORE_NAME x