ASGI ミドルウェアとスレッドプール制御
FastAPI v0.162.2 は、すべての HTTP リクエストおよび WebSocket 接続を決定論的な 7 層の ASGI ミドルウェアオニオン構造で処理しつつ、async def コルーチンはシングルスレッドの AnyIO イベントループ上で直接実行し、通常の def 関数は上限付きワーカースレッドプールへ安全にオフロードします。
Source Code
from drawlib.canvas import save, setup
from drawlib.icons import phosphor
from drawlib.lines import line
from drawlib.shapes import rectangle
from drawlib.styles import Colors, Styles
from drawlib.text import text
setup(width=176, height=98)
text(
(88.0, 93.0),
"FastAPI v0.162.2 ASGI Middleware Onion (FastAPI.__call__ + build_middleware_stack)",
style=Styles.DarkBold.patch(text_size=13.0),
)
# 7 Concentric ASGI Layers (outermost L1 -> innermost L7 core)
layers = [
(88.0, 45.0, 132.0, 80.0, Styles.Neutral.patch(line_style="dashed", shape_r=2.5),
81.0, "1. NativeTelemetry (FastAPI.__call__) • W3C Context, Root Span & Metrics", Styles.DarkBold),
(88.0, 42.2, 122.0, 69.0, Styles.White.patch(shape_r=2.2, shape_line_color=Colors.Gray5),
72.5, "2. ServerErrorMiddleware • Catches Unhandled Exception / 500 Fallback", Styles.DarkBold),
(88.0, 39.4, 112.0, 58.0, Styles.PrimaryNeutral.patch(shape_r=2.0),
64.0, "3. ExceptionTelemetryMiddleware • Emits OTel Error Logs Before 500", Styles.PrimaryBold),
(88.0, 36.6, 102.0, 47.0, Styles.SecondaryNeutral.patch(shape_r=2.0),
55.5, "4. User Middleware (LIFO) • TrustedHost, CORS & Pure ASGI Wrappers", Styles.SecondaryBold),
(88.0, 33.8, 92.0, 36.0, Styles.White.patch(shape_r=1.8, shape_line_color=Colors.Gray5),
47.0, "5. ExceptionMiddleware • HTTPException, 422 Validation & Custom", Styles.DarkBold),
(88.0, 31.0, 82.0, 25.0, Styles.PrimaryNeutral.patch(shape_r=1.8),
38.5, "6. AsyncExitStackMiddleware • Closes Form & UploadFile Handles", Styles.PrimaryBold),
]
for cx, cy, w, h, box_style, label_y, label, txt_style in layers:
rectangle((cx, cy), width=w, height=h, style=box_style)
text((cx, label_y), label, style=txt_style.patch(text_size=10.2))
# Core Layer 7: APIRouter & APIRoute
rectangle((88.0, 26.5), width=66.0, height=13.0, style=Styles.PrimaryFlat.patch(shape_r=1.8))
phosphor.cpu((60.0, 26.5), width=5.2, style=Styles.WhiteBold)
text(
(91.0, 26.5),
"7. Router / APIRoute\nDI DAG • Endpoint • Serialization",
style=Styles.WhiteBold.patch(text_size=10.5),
)
# Inbound ASGI Request & Outbound ASGI Response arrows piercing into the core
line((3.5, 26.5), (54.5, 26.5), arrow_head="->", style=Styles.PrimaryBold.patch(line_width=2.0))
rectangle((21.0, 34.0), width=33.0, height=9.0, style=Styles.White.patch(shape_r=1.5, shape_line_color=Colors.Gray5))
text((21.0, 34.0), "Inbound ASGI\nscope + receive()", style=Styles.DarkBold.patch(text_size=10.0))
line((121.5, 26.5), (172.5, 26.5), arrow_head="->", style=Styles.SecondaryBold.patch(line_width=2.0))
rectangle((155.0, 34.0), width=33.0, height=9.0, style=Styles.White.patch(shape_r=1.5, shape_line_color=Colors.Gray5))
text((155.0, 34.0), "Outbound ASGI\nsend(start / body)", style=Styles.DarkBold.patch(text_size=10.0))
save()
1. 7 層から成る ASGI ミドルウェアオニオン
Uvicorn などの ASGI サーバーが await app(scope, receive, send) を呼び出すと、FastAPI は最外殻の FastAPI.__call__ から遅延構築された build_middleware_stack() パイプラインへと順番に処理を渡します。この正確なネスト順序を把握しておくことで、500 エラー時の CORS ヘッダー欠落、OpenTelemetry の例外ログ相関、contextvars の伝播境界にまつわるバグを未然に防げます。
| レイヤー(外側から内側) | クラス / ステージ | v0.162.2 における主な責務 |
|---|---|---|
| 1. テレメトリ入口 | NativeTelemetry (FastAPI.__call__) |
W3C traceparent/baggage の抽出、ルート HTTP/WebSocket スパンの開始、アクティブ接続数の計測、および _RequestTelemetry のコンテキスト登録を行います。 |
| 2. 最終 500 防衛線 | ServerErrorMiddleware |
@app.exception_handler(500) や @app.exception_handler(Exception) を実行し、内側の層から漏れた未処理例外に対して安全な 500 Internal Server Error 応答を保証します。 |
| 3. 未処理例外の観測 | ExceptionTelemetryMiddleware |
ServerErrorMiddleware が 500 応答へ変換する直前に未処理例外を観測し、構造化された OTel ERROR ログイベントを送出して再送出します。 |
| 4. ユーザーミドルウェア群 | self.user_middleware |
app.add_middleware(...) で登録されたミドルウェアを LIFO(後入れ先出し)順 で実行します(最後に追加したミドルウェアが最も外側を包みます)。 |
| 5. 制御された例外処理 | ExceptionMiddleware |
HTTPException、RequestValidationError(422)、WebSocketRequestValidationError(1008)、およびカスタムドメイン例外ハンドラーを捕捉します。 |
| 6. 一時ファイル解放 | AsyncExitStackMiddleware |
すべてのユーザーミドルウェアの内側で fastapi_middleware_astack を保持し、同一の contextvars スコープ内で Form や UploadFile の一時ファイルハンドルを確実にクローズします。 |
| 7. ルーティング & DI 実行 | APIRouter / APIRoute |
ボディ読取、fastapi_inner_astack 上での Dependant DAG 解決、エンドポイント実行、レスポンス直列化、および BackgroundTasks の紐付けを行います。 |
純粋な ASGI ミドルウェア vs BaseHTTPMiddleware
@app.middleware("http")(内部的に Starlette の BaseHTTPMiddleware を使用)は、リクエストごとに追加の AnyIO タスクグループを生成して contextvars のコピー境界を作るため、高スループットな SSE や JSONL ストリーミング ではオーバーヘッドや制約が生じやすくなります。処理時間計測、相関 ID 付与、テナント検証、セキュリティヘッダー注入などの横断的関心事には、レスポンスボディを一切バッファリングせず http.response.start を直接フックする 純粋な ASGI ミドルウェアクラス の採用が推奨されます。
from time import perf_counter
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.trustedhost import TrustedHostMiddleware
from fastapi.testclient import TestClient
from starlette.types import ASGIApp, Message, Receive, Scope, Send
class ProcessTimeMiddleware:
"""Zero-buffering pure ASGI middleware that injects X-Process-Time-Ms."""
def __init__(self, app: ASGIApp) -> None:
self.app = app
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http":
await self.app(scope, receive, send)
return
started = perf_counter()
async def send_with_timing(message: Message) -> None:
if message["type"] == "http.response.start":
elapsed_ms = (perf_counter() - started) * 1000.0
raw_headers = list(message.get("headers", []))
raw_headers.append(
(b"x-process-time-ms", f"{elapsed_ms:.2f}".encode("latin-1"))
)
message = {**message, "headers": raw_headers}
await send(message)
await self.app(scope, receive, send_with_timing)
app = FastAPI()
# LIFO wrapping order: TrustedHost (outer) -> CORS -> ProcessTime (inner)
app.add_middleware(ProcessTimeMiddleware)
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"],
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "Content-Type"],
)
app.add_middleware(
TrustedHostMiddleware,
allowed_hosts=["api.example.com", "testserver"],
)
@app.get("/ping")
async def ping() -> dict[str, str]:
return {"status": "ok"}
client = TestClient(app)
res = client.get("/ping", headers={"Origin": "https://app.example.com"})
assert res.status_code == 200
assert "x-process-time-ms" in res.headers
未処理 500 エラー時の CORS ヘッダー:
ServerErrorMiddleware(第 2 層)はself.user_middleware(第 4 層)の外側に位置するため、第 2 層で生成されるデフォルトの500レスポンスはCORSMiddlewareを通過しません。ブラウザ向けに一貫したオリジン制御が必要な場合は、@app.exception_handler(Exception)を定義するかリバースプロキシ側で許可オリジンを明示し、認証付き API でallow_origins=["*"]を絶対に使用しないでください。
2. イベントループ vs ワーカースレッドプールと BackgroundTasks
FastAPI はルート登録時に関数シグネチャ(_is_coroutine_callable)を検査し、イベントループ上での直接実行と AnyIO ワーカースレッドプールへの委譲を自動的に振り分けます。
Source Code
from drawlib.canvas import save, setup
from drawlib.diagrams.flow import Decision, End, FlowDiagram, Process, Start
from drawlib.styles import Styles
from drawlib.text import text
setup(width=166, height=94)
text(
(83.0, 89.5),
"FastAPI Concurrency Routing: async def vs plain def & BackgroundTasks",
style=Styles.DarkBold.patch(text_size=13.0),
)
node_txt = Styles.DarkBold.patch(text_size=10.2)
flow = FlowDiagram(
node_style=Styles.Neutral.patch(text_size=10.2),
edge_style=Styles.DarkBold,
edge_text_style=Styles.DarkBold.patch(text_size=10.0),
width=158,
height=80,
lane_orientation="vertical",
)
# 3 Vertical Swimlanes: widths 54 + 54 + 50 = 158 (centerlines X = 27, 81, 133)
flow.add_lane(
"AnyIO Event Loop (1 Thread)",
width=54,
style=Styles.Neutral,
header_style=Styles.PrimaryNeutral,
text_style=Styles.PrimaryBold.patch(text_size=10.5),
header_size=7.0,
)
flow.add_lane(
"Worker Threadpool (40 Tokens)",
width=54,
style=Styles.Neutral,
header_style=Styles.SecondaryNeutral,
text_style=Styles.SecondaryBold.patch(text_size=10.5),
header_size=7.0,
)
flow.add_lane(
"Post-Response Lifecycle",
width=50,
style=Styles.Neutral,
header_style=Styles.PrimaryNeutral,
text_style=Styles.PrimaryBold.patch(text_size=10.5),
header_size=7.0,
)
start = flow.add(
Start("Incoming ASGI Request", width=38, height=8.5, style=Styles.PrimaryFlat, text_style=Styles.WhiteBold.patch(text_size=10.2)),
xy=(27, 66),
)
dispatch = flow.add(
Decision("Callable\nSignature?", width=34, height=13.5, style=Styles.PrimaryNeutral, text_style=node_txt),
xy=(27, 49),
)
async_exec = flow.add(
Process("Await Coroutine Directly\n(Non-Blocking Async I/O)", width=44, height=11.5, style=Styles.White, text_style=node_txt),
xy=(27, 30.5),
)
pool_enter = flow.add(
Process("run_in_threadpool /\ncontextmanager_in_threadpool", width=43, height=11.5, style=Styles.SecondaryNeutral, text_style=node_txt),
xy=(81, 49),
)
sync_exec = flow.add(
Process("Run Sync DB Driver /\nBlocking File or CPU Helper", width=43, height=11.5, style=Styles.White, text_style=node_txt),
xy=(81, 30.5),
)
merge = flow.junction((27, 19.5))
send_resp = flow.add(
Process("Serialize & Send Response\n(http.response.body Done)", width=44, height=11.0, style=Styles.PrimaryNeutral, text_style=node_txt),
xy=(27, 9.5),
)
bg_tasks = flow.add(
Process("Run BackgroundTasks\n(Traced Sequentially)", width=41, height=11.5, style=Styles.White, text_style=node_txt),
xy=(133, 30.5),
)
done = flow.add(
End("ASGI Scope Closed", width=36, height=9.0, style=Styles.SecondaryFlat, text_style=Styles.WhiteBold.patch(text_size=10.2)),
xy=(133, 9.5),
)
start.connect(dispatch, padding=0.8)
dispatch.connect(async_exec, label="async def", start_side="bottom", end_side="top", padding=0.8)
dispatch.connect(pool_enter, label="plain def", start_side="right", end_side="left", padding=0.8)
pool_enter.connect(sync_exec, padding=0.8)
async_exec.connect(merge, start_side="bottom", padding=(0.8, 0.0))
sync_exec.connect(merge, start_side="bottom", padding=(0.8, 0.0)).via((81, 19.5))
merge.connect(send_resp, end_side="top", padding=(0.0, 0.8))
send_resp.connect(bg_tasks, label="After send()", start_side="right", end_side="left", padding=0.8).via((106.5, 9.5), (106.5, 30.5))
bg_tasks.connect(done, padding=0.8)
flow.draw(xy=(4.0, 4.0))
save()
FastAPI 並行実行の 3 原則
async defはイベントループ上で直接動作する: 非同期ネイティブドライバ(httpx.AsyncClient、asyncpg、redis.asyncio、anyioなど)をawaitする場合はasync defを使用します。async defの内部で同期ブロッキング関数(time.sleep()、requests.get()、同期 ORM クエリ、重い CPU ループ)を直接呼び出してはなりません。呼び出すとワーカープロセス全体のイベントループが停止し、他の同時接続リクエストがすべてフリーズします。- 通常の
defと同期yield依存関係はスレッドプールで実行される: 通常のdefエンドポイントや依存関数は、AnyIO のデフォルト40トークン(CapacityLimiter)で制御されたワーカースレッドプール上でrun_in_threadpool(同期yield依存関係ではcontextmanager_in_threadpool)を通じて安全に実行されます。async defエンドポイント内から一部の同期ヘルパーだけを呼びたい場合は、await run_in_threadpool(fn, *args)で明示的にオフロードします。 BackgroundTasksはレスポンス送信完了後に順次実行される:BackgroundTasksに登録された処理は、クライアントへ HTTP レスポンスを返し終えた後にfastapi.background_taskの OpenTelemetry スパン 付きで順次実行されます。監査ログ記録やキャッシュ更新などの軽量なベストエフォート処理に限定し、再試行保証や長時間ジョブには外部の永続ジョブキューを併用してください。
from collections.abc import Iterator
from typing import Annotated
import anyio
from fastapi import BackgroundTasks, Depends, FastAPI, status
from fastapi.testclient import TestClient
from starlette.concurrency import run_in_threadpool
app = FastAPI()
audit_log: list[str] = []
def compute_digest_sync(payload: str) -> str:
return f"len:{len(payload)}"
def get_sync_session() -> Iterator[str]:
# Entered & exited safely via contextmanager_in_threadpool
audit_log.append("session:open")
try:
yield "tx-101"
finally:
audit_log.append("session:close")
@app.get("/reports/sync")
def read_sync_report(
tx_id: Annotated[str, Depends(get_sync_session)],
) -> dict[str, str]:
return {"tx_id": tx_id, "summary": compute_digest_sync("quarterly-ledger")}
@app.post("/reports/async", status_code=status.HTTP_202_ACCEPTED)
async def submit_async_report(
payload: str,
tasks: BackgroundTasks,
) -> dict[str, str]:
digest = await run_in_threadpool(compute_digest_sync, payload)
await anyio.sleep(0)
tasks.add_task(audit_log.append, f"audit:{digest}")
return {"accepted": digest}
client = TestClient(app)
assert client.get("/reports/sync").json() == {"tx_id": "tx-101", "summary": "len:16"}
assert client.post("/reports/async", params={"payload": "hello"}).status_code == 202
assert audit_log == ["session:open", "session:close", "audit:len:5"]
次節の ネイティブ SSE・JSONL・WebSocket では、この非同期実行基盤の上に構築された 3 つのリアルタイム配信・双方向通信プリミティブを詳しく解説します。