FastAPIでdefとasync devのどちらを使うべきか悩むことはありますか?私は昔からよく悩んでいました。今回はどういうときにdefを使い、どういうときにasync defにすべきかについてまとめたいと思います。
公式の説明
🔗FastAPIの公式ドキュメントでは、下記のように記載されています。
- await を使用して呼び出すべきサードパーティライブラリを使用している場合: async def を使用してpath operation 関数を宣言します。
- データベース、API、ファイルシステムなどと通信し、await の使用をサポートしていないサードパーティライブラリ (現在のほとんどのデータベースライブラリに当てはまります) を使用している場合、次の様に、単に def を使用して通常通り path operation 関数 を宣言してください
- アプリケーションが (どういうわけか) 他の何とも通信せず、応答を待つ必要がない場合は、async def を使用して下さい。
- よく分からない場合は、通常の def を使用して下さい。
FastAPIの動作の違い
🔗公式の見解は上記の通りですが、この見解をFastAPIの動作から紐解きたいと思います。
defの場合
FastAPIはメインプロセス起動時にメインスレッド以外にスレッドプールを作成します。defで定義されたパスに対してリクエストが来た場合、FastAPIはリクエストごとにスレッドプールからスレッドを取得して、そのスレッド上でPath Operation関数を実行します。
そのためdefの場合には、常に別スレッドが発行されているため、関数内でIO Boundな処理が呼ばれていても他のリクエストの処理に影響を及ぼさないようになっています。
async defの場合
async defの場合、FastAPIはスレッドプールは利用せずにシングルスレッドで動作するイベントループ内で関数を実行します。そのため、awaitがつけられていない処理を行っている間は他のリクエストの処理をブロックしてしまうことになります。
解釈
上記の動作の違いを踏まえて、公式見解を紐解いていきます。
await を使用して呼び出すべきサードパーティライブラリを使用している場合: async def を使用してpath operation 関数を宣言します。
的確にawaitが利用できている場合はシングルスレッドでイベント駆動にリクエストが処理でき、defを使った場合よりパフォーマンスが高くなるので、このような記載になっていると考えられます。ただし、現状はawaitに対応したライブラリはそこまで多くないため、「すべての処理をawaitできている」ケースは中々発生しないです。
データベース、API、ファイルシステムなどと通信し、await の使用をサポートしていないサードパーティライブラリ (現在のほとんどのデータベースライブラリに当てはまります) を使用している場合、次の様に、単に def を使用して通常通り path operation 関数 を宣言してください
こちらについては、async def内でawaitに対応していないIO Boundな処理を行うと、不必要に他のリクエストをブロックしてしまうから、defでちゃんと別スレッドに逃がしてブロックしないようにしましょう、ということを言っています。
アプリケーションが (どういうわけか) 他の何とも通信せず、応答を待つ必要がない場合は、async def を使用して下さい。
PythonはGILがあるので、同一プロセス内ではスレッドを複数発行しても実際にCPU時間が割り当てられるのは常に1スレッドになります。そのため、CPU Boundな処理しかないAPIを提供している場合は、コンテキストスイッチが不要になる分だけasync defの方がパフォーマンスが高くなる(ここらへんはどれくらい差が出るのか直感的にはわかりませんでした。処理がよっぽど軽量なものでない限り、どちらを使ってもCPU起因でリクエストがキューされていくと思うので、基本的にパフォーマンスに差が出るケースはそこまで多くない気がします。)
よく分からない場合は、通常の def を使用して下さい。
ここまで議論してきた通り、async/awaitを理解して的確にasync defとdefを使い分けることができて、初めてasync defを使う意味が出てきます。async defの方がパフォーマンスがよくなるケースが現状だと少ないことも考慮すると、基本的にはdefを使うほうが無難であり、このような記載になっているのだと思います。
結論
🔗基本的にdefを使おう!async/awaitをちゃんと理解し、かつパフォーマンスに差が出るほどトラフィックが多い、という条件を満たしたときだけasync defを使おう!
検証してみた
🔗ここまで、FastAPIの仕様から動作を解説してきましたが、実際にそのような動作が見られるのか色々と検証してみました。
検証1: asyncがホントにブロックしてしまうのか
async defの場合はメインスレッドで複数のリクエストが走るため、他のリクエストをブロックしてしまう、という結論になりましたが、実際にそのような振る舞いが見られるのか検証してみました。
検証内容
検証用にシンプルなFastAPIのアプリを作成してみました。リクエストごとに5秒待機したあとにレスポンスを返すようなAPIをasyncありなし・await対応ありなしの組み合わせで3通りのパスが提供されます。(asyncなしのasync_sleepはawaitが使えないため、除外しています。ちなみに、awaitをつけずにasync_sleepを実行した場合は、処理はブロックされずにレスポンスがすぐに来てしまいます。)
main.py1from asyncio import sleep as async_sleep 2from time import sleep 3 4from fastapi import FastAPI 5 6app = FastAPI() 7 8@app.get("/async/async_sleep") 9async def async_async_sleep_endpoint(): 10 await async_sleep(5) 11 return {"message": "Hello World"} 12 13@app.get("/async/sleep") 14async def async_sleep_endpoint(): 15 sleep(5) 16 return {"message": "Hello World"} 17 18@app.get("/sync/sleep") 19def sync_sleep_endpoint(): 20 sleep(5) 21 return {"message": "Hello World"} 22
このAPIのそれぞれのパスに対して、同時にリクエストを発行した際にレスポンス速度に差が出るかどうかを検証してみます。
結果
結果は下記のようになりました。
Header | レスポンス1 | レスポンス2 |
---|---|---|
/async/async_sleep | 5秒 | 5秒 |
/async/sleep | 5秒 | 10秒 |
/sync/sleep | 5秒 | 5秒 |
想定通り、asyncでawait非対応な処理を行うと他のリクエストがブロックされてしまっていることがわかると思います。この結果を見ても、安易にasyncを使うのはあまり推奨されないのが実感できますね。
検証2
awaitに対応しているIO Boundな処理でasync defの方がパフォーマンスがよいのか検証してみます。
検証内容
シンプルなIO Boundな処理として、localhostに対するpingを行うAPIをasync defとdefでそれぞれ作成しました。
1@app.get("/async_ping") 2async def async_ping_endpoint(): 3 async_ping("localhost") 4 return {"message": "Hello World"} 5 6@app.get("/sync_ping") 7def sync_ping_endpoint(): 8 sync_ping("localhost") 9 return {"message": "Hello World"}
このAPIに対してk6という負荷テストツールで負荷をかけてエラーが発生しないかなどを確認します。k6のスクリプトは下記の様な感じです。
1import http from "k6/http"; 2 3// 1分間、徐々にリクエスト数を増やしていき、最大で秒間1万リクエストを飛ばす 4export const options = { 5 stages: [{ duration: "1m", target: 10000 }], 6}; 7 8export default function () { 9 http.get("http://localhost:8000/async_ping"); 10}
結果
async defの場合の結果が下記です。こちらではエラーは1件も起きておらず、まだまだ余裕な様子が見られます。
1 scenarios: (100.00%) 1 scenario, 10000 max VUs, 1m30s max duration (incl. graceful stop): 2 * default: Up to 10000 looping VUs for 1m0s over 1 stages (gracefulRampDown: 30s, gracefulStop: 30s) 3 4 data_received..................: 43 MB 687 kB/s 5 data_sent......................: 26 MB 412 kB/s 6 http_req_blocked...............: avg=9.58µs min=291ns med=625ns max=24.67ms p(90)=2.08µs p(95)=5.12µs 7 http_req_connecting............: avg=6.49µs min=0s med=0s max=24.63ms p(90)=0s p(95)=0s 8 http_req_duration..............: avg=1.09s min=206.91µs med=1.01s max=4.27s p(90)=1.93s p(95)=2.05s 9 { expected_response:true }...: avg=1.09s min=206.91µs med=1.01s max=4.27s p(90)=1.93s p(95)=2.05s 10 http_req_failed................: 0.00% ✓ 0 ✗ 283646 11 http_req_receiving.............: avg=12.6ms min=4.75µs med=28.37µs max=63.84ms p(90)=40.78ms p(95)=40.95ms 12 http_req_sending...............: avg=7.84µs min=1.54µs med=3.29µs max=14.72ms p(90)=9.62µs p(95)=19.2µs 13 http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s 14 http_req_waiting...............: avg=1.07s min=160.75µs med=1s max=4.27s p(90)=1.91s p(95)=2.03s 15 http_reqs......................: 283646 4578.819621/s 16 iteration_duration.............: avg=1.09s min=237.41µs med=1.01s max=4.27s p(90)=1.93s p(95)=2.05s 17 iterations.....................: 283646 4578.819621/s 18 vus............................: 1254 min=0 max=9953 19 vus_max........................: 10000 min=8081 max=10000
defの場合の結果は下記です。40%以上のリクエストがエラーとなっており、負荷が高くなると処理しきれなくなってしまっていることがわかります。
1 scenarios: (100.00%) 1 scenario, 10000 max VUs, 1m30s max duration (incl. graceful stop): 2 * default: Up to 10000 looping VUs for 1m0s over 1 stages (gracefulRampDown: 30s, gracefulStop: 30s) 3 4 data_received..................: 7.8 MB 113 kB/s 5 data_sent......................: 6.1 MB 89 kB/s 6 http_req_blocked...............: avg=84.32µs min=375ns med=58µs max=23.9ms p(90)=179.7µs p(95)=241µs 7 http_req_connecting............: avg=28.74µs min=0s med=0s max=12.76ms p(90)=102µs p(95)=137.12µs 8 http_req_duration..............: avg=7.13s min=6.94ms med=7.46s max=13.97s p(90)=12.97s p(95)=13.42s 9 { expected_response:true }...: avg=6.78s min=6.94ms med=7.42s max=13.97s p(90)=13.03s p(95)=13.43s 10 http_req_failed................: 43.32% ✓ 21033 ✗ 27510 11 http_req_receiving.............: avg=2.09ms min=4.7µs med=34.16µs max=50.81ms p(90)=718.15µs p(95)=15.35ms 12 http_req_sending...............: avg=8.36ms min=1.91µs med=30.41µs max=383.18ms p(90)=21.56ms p(95)=25.39ms 13 http_req_tls_handshaking.......: avg=0s min=0s med=0s max=0s p(90)=0s p(95)=0s 14 http_req_waiting...............: avg=7.12s min=6.88ms med=7.45s max=13.96s p(90)=12.96s p(95)=13.41s 15 http_reqs......................: 48543 704.735709/s 16 iteration_duration.............: avg=7.13s min=6.99ms med=7.46s max=13.97s p(90)=12.97s p(95)=13.42s 17 iterations.....................: 48543 704.735709/s 18 vus............................: 569 min=0 max=9901 19 vus_max........................: 10000 min=7328 max=10000
k6は結果をInfluxDBに保存してGrafanaで可視化するのも簡単です。下図はそれぞれのテスト時のリクエスト数とエラー数です。明らかにasync defの方がスループットが優れていることがわかります。
検証3
FastAPI(というよりFastAPIのベースであるStarlette)には処理をスレッドプールに逃がすrun_in_threadpool
という関数が実装されています。この関数はawaitに対応しているので、処理の中で一つだけawaitに対応していないものがあるようなケースでは、async defで定義してawaitできない処理をrun_in_threadpool
でくるんでしまう、という方法1があります。
この方法が実際にパフォーマンスを向上させるのかを検証してみたいと思います。
検証内容
fastapiに下記のようなエンドポイントを追加して、それぞれに負荷をかけてみます。/async/sleep_threadpool
の方はasync defで定義されており、処理の一部のみがrun_in_threadpool
で実行されています。そのため、処理の2/3の時間はイベントループ内で実行され、残りの1/3の時間だけスレッドプール内で処理が実行されます。一方、/sync/sleep_without_async
の方はdefで定義されており、全ての処理がスレッドプール内で実行されます。
1@app.get("/async/sleep_threadpool") 2async def async_sleep_threadpool_endpoint(): 3 await async_sleep(0.1) 4 await run_in_threadpool(sleep, 0.1) 5 await async_sleep(0.1) 6 return {"message": "Hello World"} 7 8@app.get("/sync/sleep_without_async") 9def sync_sleep_without_async_endpoint(): 10 sleep(0.1) 11 sleep(0.1) 12 sleep(0.1) 13 return {"message": "Hello World"}
検証2と同様にk6を利用して負荷をかけていきたいと思います。
結果
k6の実行結果は下図の通りです。/async/sleep_threadpoolの方が2倍程度スループットが出ており、処理時間の大部分がawitに対応しているような処理を行う場合はawaitに対応していない処理をrun_in_threadpool
で実行することでパフォーマンスを向上できることがわかりました。
検証4
CPU Boundな処理のみな場合にasync defの方が本当にパフォーマンスがよいのか検証してみます。
検証内容
単純な足し算を行う関数をasync def, defそれぞれで定義してみました。
1@app.get("/async/cpu_bound") 2async def async_cpu_bound_endpoint(): 3 x = 0 4 for i in range(10000): 5 x += i 6 7 return {"message": x} 8 9@app.get("/sync/cpu_bound") 10def sync_cpu_bound_endpoint(): 11 x = 0 12 for i in range(10000): 13 x += i 14 15 return {"message": x}
これらのエンドポイントにk6で負荷をかけていきます。
結果
k6の実行結果は下図の通りです。async defの方がdefより20%程度パフォーマンスが高い事がわかります。また、topでプロセスのCPU使用率を確認してたところ、async defでは93%程度でしたが、defでは107%程度になっていました。コンテキストスイッチが内分だけasync defのイベントループ内で処理を行うほうが性能が高いことが確認できました。
まとめ
🔗今回はFastAPIでasync defとdefをどのように使い分ければいいかをまとめてみました。また、実際にasync defを使うことでパフォーマンスが改善したり、逆にパフォーマンスが悪化してしまうケースを試してみました。負荷テストをした結果としては、高トラフィックなシステムでは適切にasync defを使うことで大きな差が出てくるケースも存在することがわかったので、ぜひ皆さんも用法用量を守ってasync defを使ってみてください!