fastapi svg logo
FastAPIでasync defとdefをちゃんと使い分ける
投稿日: 2023年8月5日

FastAPIでdefとasync devのどちらを使うべきか悩むことはありますか?私は昔からよく悩んでいました。今回はどういうときにdefを使い、どういうときにasync defにすべきかについてまとめたいと思います。

公式の説明

🔗

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

  • await を使用して呼び出すべきサードパーティライブラリを使用している場合: async def を使用してpath operation 関数を宣言します。
  • データベース、API、ファイルシステムなどと通信し、await の使用をサポートしていないサードパーティライブラリ (現在のほとんどのデータベースライブラリに当てはまります) を使用している場合、次の様に、単に def を使用して通常通り path operation 関数 を宣言してください
  • アプリケーションが (どういうわけか) 他の何とも通信せず、応答を待つ必要がない場合は、async def を使用して下さい。
  • よく分からない場合は、通常の def を使用して下さい。

https://fastapi.tiangolo.com/ja/async/

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.py
1from 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_sleep5秒5秒
/async/sleep5秒10秒
/sync/sleep5秒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の方がスループットが優れていることがわかります。

fastapi_load_test1

検証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で実行することでパフォーマンスを向上できることがわかりました。

fastapi_load_test2

検証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_load_test3

まとめ

🔗

今回はFastAPIでasync defとdefをどのように使い分ければいいかをまとめてみました。また、実際にasync defを使うことでパフォーマンスが改善したり、逆にパフォーマンスが悪化してしまうケースを試してみました。負荷テストをした結果としては、高トラフィックなシステムでは適切にasync defを使うことで大きな差が出てくるケースも存在することがわかったので、ぜひ皆さんも用法用量を守ってasync defを使ってみてください!

Footnotes

  1. https://github.com/tiangolo/fastapi/issues/3316#issuecomment-853087283