背景
FastAPIでは以下のようにデコレータ関数を使うことでHTTPサーバのpathを設定することができ、これをPath Operationと呼びます。
from fastapi import FastAPI app = FastAPI() @app.get("/") async def read_root(): return {"Hello": "World"} @app.get("/items/{item_id}") async def read_item(item_id: int, q: str = None): return {"item_id": item_id, "q": q}
このPath Operationでdefを使うべきかasync defを使うべきかの方針を説明します。
環境
- Python 3.9.11
- FastAPI 0.74.1
方針
結論から言うと以下の方針で実装しましょう。
awaitがあるならasync defを使う- それ以外は
defを使う
理由
理由は以下になります。
- CPUバウンドな処理だったら
async defもdefも大きく変わらない - 同期処理でIOバウンドの場合は外部スレッドプールを使う
defが優れている awaitがある非同期処理はasync defが必須
1つ1つ説明していきます。
FastAPIはメインasync loopと外部スレッドプールがある
まず前提知識としてFastAPIにはメインasync loopと外部スレッドプールがあり、
def→外部スレッドプールasync def→メインasync loop
で実行されます。

外部スレッドプールはマルチスレッドに見えるが実質シングルスレッド
上図では外部スレッドプールはマルチスレッドのように見えます。
しかしPythonではGIL(グローバルインタプリタロック)を使っており、これはスレッドセーフでないコード(C言語ライブラリなど)を他のスレッドと共有してしまうことを防ぐための排他ロックであるため、同時に実行できるスレッドは1つに制限されます。
なのでCPUバウンドな処理では外部スレッドプールであろうと、メインasync loopのスレッドであろうと違いはありません。

外部スレッドプールでは同期IO中に他のスレッドに切り替えできる
しかしIOバウンドな同期処理の場合は、CPUは使わず単に待ち時間でブロックされるため、複数のスレッドを持っている方が有利です。

メインasync loopでは処理がブロックされるため3つしか処理が実行できないのに対して、複数のスレッドがある外部スレッドプールでは待つ間にスレッドを切り替えて多くのリクエストを処理できます。
awaitがある非同期処理ではasync def
awaitがある非同期処理はそもそもasync def内でしか使えません。
You can only use await inside of functions created with async def.
ref: Concurrency and async / await - FastAPI
同期・非同期の区別については以下の過去記事が参考になると思います。
先程ブロックされていたメインasync loopは非同期IOなのでブロックされず、マルチスレッドの同期IOのように準備中は次のイベントに、準備が完了したらそのイベントの処理を完了する形になります。
ベンチマーク
以下は実際のベンチマーク結果です。左から
- 同期処理を
defで実行 - 非同期処理を
async defで実行 - 同期処理を
async defで実行
となっています。

IOバウンドな同期処理をasync def内で実行すると前述のようにブロックされてパフォーマンスが低いことが分かります。
一方以下のベンチマークではCPUバウンドな処理はasync defの方がパフォーマンスが良かったとあります。
スレッドの切り替えはコンテキストスイッチが発生するので、その分パフォーマンスに影響したのではと思われます。
かといってどの関数がCPUバウンドか、を常に意識するのは実装やレビューでコストになるため、基本的には同程度のパフォーマンスと考えてdef優先にするのが良いです。
まとめ
- CPUバウンドな処理だったらasync defもdefも大きく変わらない
- 同期処理でIOバウンドの場合は外部スレッドプールを使うdefが優れている
- awaitがある非同期処理はasync defが必須
といったことから
- awaitがあるならasync defを使う
- それ以外はdefを使う
と判断すれば良いでしょう。