FastAPIで外部APIリクエストやI/O処理を行う際に気をつけること

に公開

はじめに

最近、業務でFastAPIを使って開発をしているのですが、初心者向けのフレームワークのFlask等と同じと思い(Flaskにイベントループが搭載されたのが2021年5月11日2.0.0のリリース時のため自分が使っていた時には存在しなかった...)、特に意識せずにAPIを書いていたら将来的に思わぬパフォーマンス低下を招く書き方になっていたため、その点についてまとめたいと思います。
自身の知識不足への戒めも込めて、いつでも思い出せるように記事として残しておきます。

FastAPIは、その名の通り非常に高速なパフォーマンスを誇るPythonのWebフレームワークです。しかし、そのパフォーマンスを最大限に引き出すためには、非同期処理と同期処理の違いを正しく理解し、適切に扱う必要があります。

当時(学生の頃)自分はよく分からず他のフレームワークと何が違うんだと思いながら、Python=遅い言語のイメージだったのでフレームワークでどうにかなるものなのかと首を傾げていたのですが、違いは処理の仕方にありました。
リクエストが来た際、2種類の処理方法がありました。1つはなじみの同期処理でもう一つがasync awaitを使用した非同期処理になります。
自分は「非同期処理」という言葉を「並列処理」や「並行処理」の総称のように捉えていましたが、正しくは、非同期プログラミングは「並行処理」を実現するための一つの手段であり、「並列処理」とは区別される概念でした。

用語の整理

  • 並列処理
    • 複数の処理が、複数のCPUコアなどで物理的に同時に実行されること。
  • 同期処理
  • 非同期処理
    • 並行処理(マルチスレッド方式)
      • 複数のスレッドを使用して処理がリクエスト待ちなどで手持ち無沙汰になった際、処理スレッドを切り替えて別の処理を行う。無駄な待ち時間を有効活用
      • メモリ空間を共有するため同じ変数などに処理を行う際に競合が発生する可能性があり思わぬ不具合を埋め込む可能性がある
    • 並行処理(シングルスレッド方式、イベントループと呼ばれるもの)
      • 並行処理を1つのスレッドで行う。スレッド切り替え時のコンテキストスイッチなど発生されない。メモリ空間は共有されるが単一スレッドのため同時に同じ変数を参照する事はないため競合が発生しない。ただし同じ変数を別々の処理で参照する際は処理の順番に気を付ける必要がある。

※スレッド処理の実行単位を指すイメージでいる

このシングルスレッドによる非同期処理を理解するのにイベントループの理解が必要でした。
イベントループと聞くと、JavaScriptの実行モデルを思い浮かべる方もいるかもしれませんが、まさにその仕組みです。
(例えば、標準的なRailsアプリケーションはマルチプロセス/マルチスレッドモデルで動作するため、このイベントループという概念はあまり意識せずに開発できます。)

このイベントループについて特に気にせず、外部APIへのリクエストやデータベースアクセスなどのI/Oバウンドな処理を実装すると、意図せずパフォーマンスを著しく低下させてしまう可能性があります。

この記事では、FastAPIでI/O処理を扱う際の注意点と、本番環境における挙動について解説します。

FastAPIのイベントループとブロッキング

FastAPIの非同期APIエンドポイント(async defで定義されたもの)は、すべてメインスレッドで動作する単一のイベントループによって処理されます。
通常のdefの場合は、1リクエストにつき1つのスレッドが割り当てられるマルチスレッド方式で処理が行われます。そのスレッドが処理中に別のリクエストが来た際は別のスレッドが対応します。
イベントループの場合は複数のリクエストに対して1つのスレッドで処理を行います。

@app.get("/async-endpoint")
async def my_async_endpoint():
    # この中の処理はイベントループで実行される
    return {"message": "Hello from async endpoint"}

このイベントループは、複数のリクエストを効率的に切り替えながら並行処理することで、高いスループット(一定時間内の作業量)を実現しています。
コスパよく時間を使えていると言う事です。自分たちがデプロイ時間待ちの間、他の作業を進めるみたいな感じです。最近だとClaude CodeなどでLLMに自律的にコードを書かせている時に他ごとをやるとかですね。

しかし、このイベントループ内でブロッキングする同期処理(例:requests.get()time.sleep())を実行してしまうと、イベントループ全体がその処理の完了を待つ間、完全に停止してしまいます。awaitキーワードは、イベントループに対して「この処理は時間がかかるので、その間に他のタスクを進めて良い」と伝える目印になります。これがない同期処理では、イベントループはその処理が終わるまで他のタスクに移れず、ただ待ち続けてしまいます。

やってはいけない例:async def内で同期I/O処理を行う

Pythonで古くから使われているrequestsライブラリは同期処理です。これをasync def内で直接使うと、イベントループをブロッキングします。

bad_example.py
import requests
import time
from fastapi import FastAPI

app = FastAPI()

@app.get("/bad-practice")
async def bad_practice():
    # requests.get()は同期的I/O処理であり、イベントループを10秒間ブロックする
    # この間、他のリクエストは一切処理されない
    response = requests.get("https://httpbin.org/delay/10")
    return response.json()

上記のエンドポイントにリクエストが来ると、requests.get()が完了するまでの10秒間、サーバーは他のすべてのリクエスト(他のエンドポイントへのアクセスも含む)を処理できなくなります。これは、単一のイベントループがブロックされているためです。

解決策:run_in_threadpool

では、async defの中で同期的な処理を呼び出したい場合はどうすればよいのでしょうか。FastAPIは、このようなケースのためにfastapi.concurrency.run_in_threadpoolというユーティリティ関数を提供しています。

これは、指定された同期関数を別のスレッド(スレッドプール)で実行し、その完了を待つ間、メインスレッドのイベントループをブロックしないようにしてくれます。

good_example.py
import requests
from fastapi import FastAPI
from fastapi.concurrency import run_in_threadpool

app = FastAPI()

@app.get("/good-practice")
async def good_practice():
    # 同期関数をrun_in_threadpoolでラップする
    # これにより、requests.get()は別スレッドで実行され、イベントループはブロックされない
    response = await run_in_threadpool(requests.get, "https://httpbin.org/delay/10")
    return response.json()

run_in_threadpoolを使うことで、イベントループはrequests.getの処理を別スレッドに委譲し、自身は他のリクエストを処理するために解放されます。そして、別スレッドでの処理が完了したら、その結果を受け取ってレスポンスを返します。

内部的には、run_in_threadpoolasyncioloop.run_in_executorをラップしたものです。FastAPIで開発している場合は、run_in_threadpoolを使うのがシンプルで良いでしょう。

より良い選択肢:非同期ライブラリを使う

そもそも、async defのエンドポイントでは、httpxのような非同期I/Oをサポートしたライブラリを使うのが最も理想的です。

best_example.py
import httpx
from fastapi import FastAPI

app = FastAPI()

@app.get("/best-practice")
async def best_practice():
    async with httpx.AsyncClient() as client:
        # httpxは非同期リクエストをサポートしているため、awaitするだけでOK
        response = await client.get("https://httpbin.org/delay/10")
    return response.json()

requestsなどの古いライブラリを使わざるを得ない場合にrun_in_threadpoolは有効ですが、新しいプロジェクトでは積極的にhttpxなどの非同期対応ライブラリの採用を検討しましょう。

同期エンドポイント(def)の場合は?

FastAPIでは、defを使って同期APIエンドポイントを定義することもできます。

@app.get("/sync-endpoint")
def my_sync_endpoint():
    # この処理はスレッドプールで実行される
    response = requests.get("https://httpbin.org/delay/10")
    return response.json()

この場合、FastAPIはasync defとは異なり、この処理をイベントループとは別のスレッドプールで実行します。そのため、requests.get()のようなブロッキング処理を直接記述しても、イベントループをブロックすることはありません。

ただし、このスレッドプールで利用できるスレッドの数には上限があります(デフォルトで40)。同時に40を超えるリクエストがこのエンドポイントに来た場合、41番目以降のリクエストはスレッドが空くまで待たされることになります。

仮に大量のスレッドを用意し1万ものプロセスやスレッドを生成したとしても、それを管理するオーバーヘッド(メモリ消費、コンテキストスイッチの負荷、ファイルディスクリプターの上限など)が非常に大きくなり、サーバーのパフォーマンスが著しく低下したり、応答不能になったりします。これは「C10K問題」として知られる課題の一側面です。ハードウェア性能が十分だとしても、プロセス数やスレッド数の上限によってパフォーマンスが低下するのです。

「スレッド」と「プロセス」の違い

  • プロセスはOSから独立したメモリ空間を割り当てられるため、互いに強く隔離されていますが、その分メモリ使用量が大きく、コンテキストスイッチのコストも高いです。
  • スレッドは同じプロセス内でメモリ空間を共有して動くため、プロセスに比べれば軽量ですが、それでも数が増えるとメモリ消費やコンテキストスイッチの負荷が大きくなります。

本番環境での挙動(Gunicornとの連携)

ここまでの話は、単一のプロセスでFastAPIを動かした場合の話です。しかし、実際のプロダクション環境では、GunicornやUvicornワーカーのようなプロセスマネージャを使って、複数のワーカープロセスを起動するのが一般的です。

Internet ↔ Nginx (Reverse Proxy) ↔ Gunicorn (ASGI Server) ↔ FastAPI App (Multiple Workers)

Gunicornは、リクエストを複数のワーカープロセスに分散します。各ワーカープロセスは、それぞれ独立したPythonインタプリタと、独立したイベントループを持っています。

この構成により、仮にあるワーカープロセス内のイベントループがブロッキング処理によって停止してしまっても、Gunicornは他の正常なワーカープロセスに新しいリクエストを振り分けることができます。これにより、サーバー全体が完全に停止する事態は避けられます。

ブロッキングは問題ない? → いいえ、非効率です

「複数のワーカーがいれば、多少ブロッキングしても問題ないのでは?」と思うかもしれません。しかし、それは効率の観点から見て良いプラクティスではありません。

  • リソースの無駄遣い: ブロックされたワーカーは、I/O処理が終わるまでCPU時間を消費せずにただ待っているだけの状態になります。その間、そのワーカーは他のリクエストを一切処理できず、リソースを無駄に占有します。
  • パフォーマンスの低下: ブロッキング処理が多いと、それを捌くためにより多くのワーカープロセスが必要になります。ワーカープロセスはメモリを消費するため、結果としてサーバーコストの増加に繋がります。
  • スケーリングへの影響: 例えばCloud Runのような環境では、インスタンス(ワーカー)が長時間リクエストを処理している(ブロックされている)と、プラットフォームがそのインスタンスを利用不可と判断し、新しいリクエストを処理するために別のインスタンスを起動(スケールアウト)することがあります。これは意図しないコスト増に繋がる可能性があります。

非同期処理を適切に実装すれば、1つのワーカーがより多くのリクエストを効率的に捌けるため、より少ないリソースで高いパフォーマンスを発揮できます。

まとめ

  • async def エンドポイント内では、ブロッキングI/O処理(例:requests.get())を直接実行してはいけない。イベントループがブロックされ、サーバー全体のパフォーマンスが低下する。
  • async def内で同期処理を行いたい場合は、fastapi.concurrency.run_in_threadpool を使って別スレッドで実行する。
  • 可能であれば、httpx のような非同期対応ライブラリを積極的に利用する。
  • def で定義された同期エンドポイントは、別スレッドプールで実行されるためイベントループをブロックしないが、スレッド数の上限に注意が必要。
  • 本番環境のGunicornなどのプロセスマネージャは、複数のワーカーで可用性を高めてくれるが、ブロッキング処理の非効率性を解決するものではない。

FastAPIのパフォーマンスを最大限に引き出すために、非同期処理の仕組みを正しく理解し、適切なコーディングを心がけましょう。

記事に関するコメント等は

🕊:Twitter
👨🏻‍💻:Github
😥:Stackoverflow

でも受け付けています。どこかにはいます。

Discussion