Djangoで非同期処理を実装するときに,非同期ではない関数を使用するためのアダプター関数 asgiref.sync.sync_to_async()
が用意されています*1.
sync_to_async()
関数は,SyncToAsync
クラスを使って,スレッドプールで実行可能な非同期関数を作成する仕組みになっているようです.
https://github.com/django/asgiref/blob/main/asgiref/sync.py
ソースコードを眺めると,この関数を実行するために僅かながら処理量が増えそうな印象でしたが,非同期関数にすることで,それ以上にCPUを効率的に使用できるならば使用する価値があるように思われました.
どれぐらいの性能低下が発生するのかを,以下のようなDjangoのviewsを比較して検証しました.
views.py
import asyncio import random import time from asgiref.sync import sync_to_async from django.http import HttpRequest, HttpResponse async def slow_view(request: HttpRequest): t = random.uniform(0, 2) await asyncio.sleep(t) return HttpResponse(f"Slow view <br/>t = {t:.3f}") @sync_to_async def slow_view_sync(request: HttpRequest): t = random.uniform(0, 2) time.sleep(t) return HttpResponse(f"Slow view. sync. <br/>t = {t:.3f}")
負荷試験
Locustを使用して負荷試験を実施しました.我が家のパソコン2台を用意して,片方でLocust実行し,別のパソコンにサーバーを公開しました.Djangoはuvicornを使って起動しました.ギガビット・イーサネットで接続して負荷テストを実行しました.以下のlocustfile.pyを利用して,1000ユーザー分の負荷をかけました.
locustfile.py
from locust import FastHttpUser, task, constant_throughput class MyUser(FastHttpUser): wait_time = constant_throughput(10) @task def get_slow(self): self.client.get("/main/slow")
slow_view()
async defで定義した関数です.
スループットは約 620 rps になりました.1リクエストは平均で1秒かかるため,並行処理により性能が620倍になったと言えます.
全部で212,063リクエストを送信し,全て200と正常に処理されました.
slow_view_sync()
同期関数をsync_to_asyncで非同期処理できるようにした関数です.time.sleep()
を使用しているので,通常はPythonインタプリタは他のことができなくなるはずです.
スループットは約 550 rps になりました.並行処理により性能が550倍になったと言えます.@sync_to_async
の処理により,約 11% の性能低下が見られました*2.このオーバーヘッドと,並行処理の効率を比較して,非同期処理を採用するべきかを検討するのがよさそうです.
全部で235,227リクエストを送信し,全て200と正常に処理されました.
並行処理の問題点
上記の関数は両方とも,ユーザー数が増加してもスループットがそれほど増えず,ユーザー数が一定の値に到達しても,その後しばらくはスループットが増え続けるという現象が見られました.
また,リクエストの統計データは以下のようになり,応答時間の最大値が5分と極端に大きくなりました.
- リクエスト数: 212,063
- 中央値: 1300 ms
- 90パーセンタイル: 2100 ms
- 最小値: 3 ms
- 最大値: 341,370 ms
負荷試験開始直後の応答が遅延しているように見える(黄色の線)のもこの現象を表していると思われます.
みーの推測ではありますが,おそらく極端にコルーチンの数が増えると,スレッドプールからの呼び出しが遅延していき,ほとんどのコルーチンはしばらくすれば正常処理されるけれども,ごく一部のコルーチンはスレッドプールから呼び出されずに待機し続けるという現象を見ているのだと思います.
Webサーバーで,ごく一部のユーザーからのリクエストの応答時間がとても長くなるのは大きな問題となりますので,応答時間の最大値にも注意を払うべきだと思いました.
*1:Asynchronous support | Django documentation | Django
*2:550/620=0.8870