最初に押さえることFastAPIが呼び出す def のエンドポイントや依存関数は、スレッドプールで実行されます。一方、async def の内部から普通の同期関数を直接呼ぶと、その場のイベントループ上で動きます。ブロッキングI/Oを含むなら、await asyncio.to_thread(...) などで実行場所を明示します。

イベントループとスレッドは何が違うのか

スレッドは、OSが実行を管理する単位です。イベントループは、そのスレッド上で複数の非同期タスクを進める仕組みです。両者は同じ階層のものではなく、イベントループ自体も通常は1本のスレッドで動いています。

料理に例えるなら、イベントループは「1人の料理人が、パスタの茹で上がりを待つ間に別の注文へ取りかかる」進め方です。別スレッドへの移動は「別の料理人に作業を任せ、その完了を待つ」進め方です。ただし、料理人を増やしてもCPUでの計算が必ず同時に進むとは限りません。

観点イベントループワーカースレッド
実行の切り替えタスクが非同期の待機などで実行権を譲り、他のタスクが進むOSがスレッドの実行を切り替える
向いている処理非同期HTTPクライアントや非同期DBドライバを使うI/O同期HTTPクライアント、同期DBドライバなどのブロッキングI/O
待ち時間の扱い非同期に待っている間、同じスレッドで他のタスクを進める同期I/Oの待機中も、その処理がスレッドを使用する
注意すること長い同期処理が実行権を譲らないと、同じループ上のタスクが待たされる同時実行数、メモリ消費、共有データの競合に注意する

async def を付けるだけでは、関数の中身まで非同期になりません。また、await は必ず別のタスクへ切り替える命令でもありません。待ち先が実際に実行を中断して制御を返すことで、イベントループが他の仕事を進められます。Python公式のTaskの説明でも、この協調的な実行モデルを確認できます。

FastAPIとUvicornでリクエストが進む流れ

一般的なUvicorn構成では、1つのワーカープロセスが1つのイベントループを持ちます。UvicornがHTTP通信を扱い、ASGIアプリケーションとしてFastAPIを呼び出します。FastAPIはルーティングや依存関係の解決を行い、エンドポイントを実行します。

同期I/Oをスレッドへ移した場合、イベントループはその結果を非同期に待ちます。その間に別のリクエストを処理し、同期I/Oが終われば元のリクエストの続きを進めます。

sequenceDiagram
  autonumber
  participant A as クライアントA
  participant Loop as イベントループ
  participant Pool as ワーカースレッド
  participant B as クライアントB

  A->>Loop: 同期I/Oが必要なリクエスト
  Loop->>Pool: defエンドポイントなどを実行
  Note over Pool: 同期I/Oを実行中
  B->>Loop: 別のリクエスト
  Loop-->>B: 処理してレスポンスを返す
  Pool-->>Loop: 同期I/Oの結果
  Loop-->>A: 続きを処理してレスポンスを返す

ここで別スレッドへ移るのは同期関数の実行です。リクエスト全体が別スレッドへ引っ越すわけではありません。また、元のリクエストは処理結果が出るまで待つため、5秒かかる処理が瞬時に終わるわけでもありません。

スレッドプールはスレッドを再利用する仕組み

毎回スレッドを作って破棄すると、その生成や管理にコストがかかります。スレッドプールは、仕事が終わったスレッドを次の仕事に再利用します。「必要なスタッフを呼び、手が空いたら次の仕事を任せる窓口」と考えると理解しやすくなります。

  1. 同期処理を実行するための枠を確保する。上限に達していれば、枠が空くまで非同期に待つ。
  2. 空いているスレッドを再利用するか、必要に応じて作成し、関数を実行する。
  3. 完了したら結果を返し、実行枠を解放する。スレッドは次の処理に再利用できる。

起動時に上限までのスレッドをすべて作るとは限りません。FastAPIが利用するStarletteでは、同期処理をAnyIOの to_thread.run_sync で実行します。この仕組みは、同期エンドポイントだけでなく、同期のバックグラウンドタスクや一部のファイル処理にも使われます。Starlette公式のThread Poolの説明に、主な利用箇所が掲載されています。

自動で別スレッドへ移るのはどの処理か

書き方・呼び出し方実行場所
FastAPIが呼ぶ async def エンドポイントイベントループ
FastAPIが呼ぶ def エンドポイントAnyIOのワーカースレッド
Depends に登録した通常の def 依存関数AnyIOのワーカースレッド
async def 内から sync_task() を直接呼ぶ呼び出し元と同じイベントループ
await asyncio.to_thread(sync_task)asyncioの既定のexecutorが使うスレッド
await run_in_threadpool(sync_task)AnyIOのワーカースレッド

判断のポイントは、誰が関数を呼ぶかです。FastAPIが管理するエンドポイントや依存関数と、自分のコードから呼ぶ補助関数では扱いが異なります。def の補助関数を直接呼んだ場合は、呼び出し元のスレッドで実行されます。FastAPI公式の補助関数の説明でも、この区別が明記されています。

コードで比較する:直接呼ぶ場合とto_threadを使う場合

次のコードを main.py として保存すると、5つの実装と動作確認用の /ping を比較できます。time.sleep(5) は、応答に5秒かかる同期I/Oの代わりです。CPUを使い続ける重い計算の例ではありません。

import asyncio
import time

from fastapi import FastAPI
from starlette.concurrency import run_in_threadpool

app = FastAPI()


def blocking_io():
    time.sleep(5)
    return "done"


@app.get("/bad")
async def bad_endpoint():
    # この呼び出しはイベントループを5秒間ブロックする
    result = blocking_io()
    return {"result": result}


@app.get("/sync")
def sync_endpoint():
    # FastAPIがこのエンドポイントを別スレッドで実行する
    return {"result": blocking_io()}


@app.get("/to-thread")
async def to_thread_endpoint():
    # 同期関数を別スレッドで実行し、結果を非同期に待つ
    result = await asyncio.to_thread(blocking_io)
    return {"result": result}


@app.get("/threadpool")
async def threadpool_endpoint():
    result = await run_in_threadpool(blocking_io)
    return {"result": result}


@app.get("/async")
async def async_endpoint():
    # 非同期I/Oの待機を模擬する。スレッドへの移動は不要
    await asyncio.sleep(5)
    return {"result": "done"}


@app.get("/ping")
async def ping():
    return {"status": "ok"}

/bad の実行中は、同じイベントループで処理する /ping も待たされます。/sync、/to-thread、/threadpool では同期処理の完了を別スレッドで待つため、イベントループは /ping を進められます。/async も非同期に待機するため、同じように他のリクエストを進められます。

関数を呼んだ結果ではなく、関数自体を渡すawait asyncio.to_thread(blocking_io) が正しい渡し方です。await asyncio.to_thread(blocking_io()) と書くと、引数を評価する段階で同期処理を直接実行してしまい、返り値も呼び出し可能な関数ではないためエラーになります。引数が必要なら await asyncio.to_thread(sync_func, arg1, arg2) のように渡します。

別のリクエストが待たされる様子を確認する

ローカルの検証用環境で依存パッケージをインストールし、1ワーカーで起動します。複数ワーカーだと別のプロセスが応答し、ブロックの影響を見落とすことがあります。

python -m pip install fastapi uvicorn
python -m uvicorn main:app --workers 1

別のターミナルで次を実行します。最初のリクエストを開始してから少し待ち、/ping の応答時間を計測します。

curl -s http://127.0.0.1:8000/bad > /dev/null &
sleep 0.5
curl -s -w '\n応答時間: %{time_total}s\n' http://127.0.0.1:8000/ping
wait

/bad が先に処理を開始していれば、/ping は残りの約4.5秒を待ちます。最初のURLを /to-thread に変えて試すと、/ping は5秒の処理が終わる前に応答できます。時間は環境やリクエストの開始タイミングで変わります。

ブロックされる範囲は同じワーカーのイベントループです。別のワーカープロセスやOSのネットワーク処理まで、すべてが停止するという意味ではありません。

run_in_threadpoolとasyncio.to_threadの違い

どちらも同期関数を別スレッドで実行し、結果を await できます。違いは、使用するスレッドの管理方法です。

項目run_in_threadpoolasyncio.to_thread
提供元StarlettePython標準ライブラリ(3.9以降)
実行の仕組みAnyIOの to_thread.run_syncイベントループの既定のexecutor。通常は ThreadPoolExecutor
同時実行数の管理AnyIOの既定のcapacity limiterexecutorの max_workers などの設定
使う場面FastAPIの同期エンドポイントと同じ仕組みで同期処理を管理したいasyncioを使うコードから標準ライブラリで同期処理を呼びたい

AnyIOの既定の制限を変えても、asyncio側のexecutorには反映されません。混在させる場合、アプリケーション全体の同時実行数は一つの設定では管理できない点に注意します。AnyIO公式の上限設定と、Python公式のexecutorの説明が参考になります。

BackgroundTasksはレスポンス送信後の処理

await asyncio.to_thread(...) は、処理結果を受け取ってからレスポンスを返します。対して BackgroundTasks は、レスポンス送信後に同じプロセス内で後処理を行う仕組みです。次は、先ほどの main.py に追加できる例です。

from fastapi import BackgroundTasks


def write_log(message: str):
    with open("events.log", "a", encoding="utf-8") as file:
        file.write(message + "\n")


@app.post("/accepted", status_code=202)
async def accepted(background_tasks: BackgroundTasks):
    background_tasks.add_task(write_log, "request accepted")
    return {"status": "accepted"}

登録した write_log は通常の def なので、送信後にAnyIOのワーカースレッドで実行されます。async def のタスクならイベントループ上で実行されるため、その内部で同期I/Oを直接呼ぶと同じブロックが起こります。実行タイミングはStarlette公式のBackground Tasksで確認できます。

この202レスポンスは、ログ保存の完了を保証しません。プロセスの異常終了に備えた永続化や自動再試行が必要な仕事には、外部のタスクキューなどを検討します。

スレッド数・CPU処理・複数ワーカーの注意点

既定の40は、常駐スレッド数ではなく同時実行の枠

Starletteが使うAnyIOの既定のlimiterは40トークンです。これは、そのlimiterを利用する同期処理の同時実行枠で、アプリケーション全体の接続数の上限でも、起動時から40本のスレッドが存在するという意味でもありません。同期エンドポイントや同期依存関数、同期のバックグラウンドタスクなどが、この枠を共有します。

長い処理ですべての枠が埋まると、後続の同期処理は枠が空くまで待ちます。上限だけを増やす前に、外部通信のタイムアウト、DBの接続数、待ち時間、メモリ消費を確認します。値を変更する場合は、利用中のバージョンのStarlette公式の制限の説明を確認してください。

また、待っている非同期タスクをキャンセルしても、実行中の同期関数を強制停止できるとは限りません。同期HTTPクライアントやDBドライバにもタイムアウトを設定し、処理そのものが戻るように設計します。共有するクライアントやデータがスレッドセーフかどうかも確認が必要です。

CPUを使い続ける処理は別に考える

スレッドへの移動は、特に同期I/Oの待ち時間が長い処理に向いています。GILが有効な通常のCPythonでは、純粋なPythonによるCPU負荷の高い計算をスレッドに分けても、複数コアでの並列実行による高速化は期待しにくくなります。こうした処理では ProcessPoolExecutor や別プロセスのワーカーを検討します。

ただし、画像処理や数値計算のライブラリにはGILを解放するものもあります。処理名だけで決めず、利用する実装と実測で判断します。この例外はPython公式のto_threadの説明にも記載されています。

Uvicornのworkersはプロセスを増やす

python -m uvicorn main:app --workers 4

これは4本のスレッドを作る設定ではなく、4つのワーカープロセスを起動する設定です。各ワーカーがイベントループとスレッドの実行枠を持ち、メモリやDB接続もプロセスごとに消費します。Pythonのグローバル変数も、ワーカー間で自動共有されません。

ワーカー数はCPUコア数だけで固定せず、CPU使用率、メモリ、外部サービスの接続上限などを見て決めます。FastAPI公式の複数ワーカーの説明に、起動方法とプロセス構成が掲載されています。

処理に合わせて実行場所を選ぶ

  • 非同期I/Oが中心なら、async def と非同期対応ライブラリを使う。
  • 同期I/Oが中心のエンドポイントなら、通常の def にする。
  • 非同期処理の途中に同期I/Oがあるなら、その部分を to_thread や run_in_threadpool で実行する。
  • レスポンス送信後に行う短い後処理なら、BackgroundTasks を使う。
  • CPUを使い続ける処理や、永続化・再試行が必要な仕事なら、別プロセスやタスクキューを検討する。

「今この関数はどのスレッドで動き、待ち時間にイベントループへ制御を返せるか」を確認すると、FastAPIの並行処理を追いやすくなります。軽い計算や値の整形まで、すべて別スレッドに移す必要はありません。