記事一覧に戻る
FastAPIでasync defとdefをちゃんと使い分ける

FastAPIでasync defとdefをちゃんと使い分ける

FastAPIでパスオペレーション関数を定義する際のdefとasync defの違いを、実際の負荷テストで検証しました。スレッドプールとイベントループの動作の違い、ブロッキングの挙動、パフォーマンス差を具体的な数値で示します。

FastAPIでパスオペレーション関数を定義するとき、defasync defのどちらを使うべきか。公式ドキュメントの解説と実際の動作検証を通じて整理します。

公式ガイドラインのポイント

FastAPI公式ドキュメントでは以下のように記載されています。

  1. awaitに対応したライブラリを使用する場合 → async defを使用
  2. awaitに非対応なライブラリ(ほとんどのDBライブラリ)を使用する場合 → defを使用
  3. CPU Boundな処理のみの場合 → async defを使用
  4. 判断に迷う場合 → defを使用(推奨)

defとasync defの動作の違い

defの場合

メインプロセス起動時にスレッドプールを作成し、リクエストごとにスレッドプール内でパスオペレーション関数を実行します。IO Bound処理中も他のリクエストに影響を及ぼしません。

async defの場合

スレッドプールを使用せず、シングルスレッドのイベントループで実行します。awaitをつけていない処理の実行中は他のリクエストがブロックされます。

検証1:asyncでのブロッキング

from asyncio import sleep as async_sleep
from time import sleep
from fastapi import FastAPI

app = FastAPI()

@app.get("/async/async_sleep")
async def async_async_sleep_endpoint():
    await async_sleep(5)
    return {"message": "Hello World"}

@app.get("/async/sleep")
async def async_sleep_endpoint():
    sleep(5)
    return {"message": "Hello World"}

@app.get("/sync/sleep")
def sync_sleep_endpoint():
    sleep(5)
    return {"message": "Hello World"}

同時に2リクエストを送った結果:

  • /async/async_sleep:両方5秒で応答
  • /async/sleep:1つが5秒、もう1つが10秒(ブロッキング発生)
  • /sync/sleep:両方5秒で応答

async def内でawait非対応の処理を実行すると、他のリクエストがブロックされることが確認できました。

検証2:awaitに対応したIO Boundでの性能比較

localhostへのping処理をasync def/defで実装し、k6を使用した負荷テストを実施しました。

import http from "k6/http";

export const options = {
  stages: [{ duration: "1m", target: 10000 }],
};

export default function () {
  http.get("http://localhost:8000/async_ping");
}

結果:

  • async def:エラー率0%、スループット4,578.8 req/s
  • def:エラー率43.32%、スループット704.7 req/s

負荷テスト結果の比較

awaitに対応したIO Bound処理ではasync defが圧倒的に優れています。

検証3:run_in_threadpoolの効果

@app.get("/async/sleep_threadpool")
async def async_sleep_threadpool_endpoint():
    await async_sleep(0.1)
    await run_in_threadpool(sleep, 0.1)
    await async_sleep(0.1)
    return {"message": "Hello World"}

@app.get("/sync/sleep_without_async")
def sync_sleep_without_async_endpoint():
    sleep(0.1)
    sleep(0.1)
    sleep(0.1)
    return {"message": "Hello World"}

/async/sleep_threadpoolの方が約2倍のスループットを達成しました。

run_in_threadpoolの効果

処理時間の大部分がawait対応なら、awaitできない部分をrun_in_threadpoolでラップすることで大幅な改善が可能です。

検証4:CPU Bound処理での性能比較

@app.get("/async/cpu_bound")
async def async_cpu_bound_endpoint():
    x = 0
    for i in range(10000):
        x += i
    return {"message": x}

@app.get("/sync/cpu_bound")
def sync_cpu_bound_endpoint():
    x = 0
    for i in range(10000):
        x += i
    return {"message": x}

async defがdefより約20%高性能で、CPU使用率もasync def 93%対def 107%でした。

CPU Bound処理の性能比較

コンテキストスイッチのオーバーヘッド削減により、CPU Bound処理でもasync defが優れています。

結論

基本的にはdefを使いましょう。async defを使うのは以下の条件を満たす場合のみです。

  • async/awaitを完全に理解している
  • awaitに対応したライブラリのみを使用している
  • 高トラフィック環境で実際にパフォーマンス差が生じている

用法用量を守ってasync defを使いましょう。