Carpe Diem

備忘録

FastAPIでPath Operationに def と async def どちらを使うべきか

背景

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 defdefも大きく変わらない
  • 同期処理で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

同期・非同期の区別については以下の過去記事が参考になると思います。

christina04.hatenablog.com

先程ブロックされていたメインasync loopは非同期IOなのでブロックされず、マルチスレッドの同期IOのように準備中は次のイベントに、準備が完了したらそのイベントの処理を完了する形になります。

ベンチマーク

以下は実際のベンチマーク結果です。左から

  1. 同期処理をdefで実行
  2. 非同期処理をasync defで実行
  3. 同期処理をasync defで実行

となっています。

ref: Are async path operations supposed to be exclusively single-threaded? · Issue #4265 · tiangolo/fastapi · GitHub

IOバウンドな同期処理をasync def内で実行すると前述のようにブロックされてパフォーマンスが低いことが分かります。

一方以下のベンチマークではCPUバウンドな処理はasync defの方がパフォーマンスが良かったとあります。

ysk24ok.github.io

スレッドの切り替えはコンテキストスイッチが発生するので、その分パフォーマンスに影響したのではと思われます。
かといってどの関数がCPUバウンドか、を常に意識するのは実装やレビューでコストになるため、基本的には同程度のパフォーマンスと考えてdef優先にするのが良いです。

まとめ

  • CPUバウンドな処理だったらasync defもdefも大きく変わらない
  • 同期処理でIOバウンドの場合は外部スレッドプールを使うdefが優れている
  • awaitがある非同期処理はasync defが必須

といったことから

  • awaitがあるならasync defを使う
  • それ以外はdefを使う

と判断すれば良いでしょう。

参考