🍣

C# の Web アプリで async/await を使わないとどれくらい性能劣化するか見てみよう

2022/02/05に公開

はじめに

.NET 6 がリリースされて、もうすぐ 3 カ月になります。時が経つのは早いですね…。
.NET 6 は長期サポートバージョンで .NET Framework と比べて一般的に性能がいいので、マイグレーションについて考えてる人も多いと思います。
そんな性能のいい .NET 6 ですが、特定の文脈で性能の悪いコードを書くことも当然出来ますし、そうならないように色々ベストプラクティスが提供されています。

例えば ASP.NET Core 6.0 向けでは以下のようなドキュメントがあります。

https://docs.microsoft.com/ja-jp/aspnet/core/performance/performance-best-practices?view=aspnetcore-6.0

その他にも Azure Functions でも以下のようなドキュメントがあります。

https://docs.microsoft.com/ja-jp/azure/azure-functions/performance-reliability

色々書いてありますが、大体のドキュメントに書いてある内容として非同期 API を使って呼び出しのブロックをしないというものがあります。Web アプリケーションだとスレッドプールを使ってリクエストを捌いているので同期版の API を使うと、スレッドプールのスレッドを無駄にブロックして効率的にリクエストを捌けなるということですね。

なので、単一のリクエストの処理時間という観点だけで見ると非同期 API だろうと同期 API だろうと処理速度は変りませんが、何個もリクエストが飛び交っている状態だとスレッド プールが枯渇してなかなか処理が出来ない状態になって結果として処理時間がかかってしまう(=スループットが低下してしまう)ということが起きます。

「じゃぁ、どれくらい劣化するの?」ということを凄く簡単にですが見てみました。これを見て、非同期の方の API を使おうと考えてくれるきっかけになったらいいなと思います。

同期と非同期での比較をやってみよう

ASP.NET Core MVC のプロジェクトを作って HomeController.cs に以下のように 2 種類の方法で 500 ミリ秒待つという処理を書きました。

public async Task<IActionResult> Index()
{
    // 非同期 API で 500 ms 待つ
    await Task.Delay(500);
    return View();
}

public IActionResult SleepIndex()
{
    // 同期 API で 500 ms 待つ
    Thread.Sleep(500);
    return View("Index");
}

https://example.com/Home/Index でアクセスすると非同期に 500 ミリ秒待ってレスポンスを返す動きになります。
https://example.com/Home/SleepIndex でアクセスすると同期的に 500 ミリ秒待ってレスポンスを返す動きになります。

今回は意味のない、ただ待つだけの処理ですが一般的な Web アプリケーションでは外部の Web API を呼ぶといった処理や DB にアクセスするといった処理に対して同期版のメソッドを使うか、非同期版のメソッドを使うかといった形になります。500 ミリ秒くらいなら外部の Web API 呼び出しを複数回してると行くこともありそうですね。ちょっと長い気もしますが。

そして、これを Azure App Service の本番環境利用に耐えうる最低ラインの S1 のプランにデプロイしました。インスタンス数はとりあえず 1 個で。そして Apache JMeter で純粋に上記エンドポイントを叩く処理を作って 50 スレッドで 20 ループの合計 1000 回アクセスするテストを作って実行してみました。

アプリをデプロイ後に適当にアクセスして動いているのを確認してから、各々のテスト実行して Summary Report の中身を確認してみました。

まずは同期のほうの結果が以下になります。

Label #Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg Bytes
同期 1000 7872 556 29659 6166.961995146719 0.0 6.323790733117059 27.296142477771873 1.0189701864885887 4420.015
同期 1000 930 513 1643 328.02984333593804 0.0 51.966949020423016 224.31051429415893 8.37358065270488 4420.001
同期 1000 7881 548 29400 7217.386299933238 0.0 6.288754449293773 27.14484971645578 1.013324691536594 4420.005
同期 1000 3316 657 10174 2003.8229403595026 0.0 14.874975828164278 64.20648221231797 2.396846691061627 4420.003
同期 1000 6329 546 13867 3877.4038654180704 0.0 7.858978489975874 33.92256689709847 1.2663393074668154 4420.003

非同期の方は以下になります。

Label #Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg Bytes
非同期 1000 618 502 1712 239.66452913186808 0.0 77.52538956508258 334.63115176079543 12.113342119544152 4420.001
非同期 1000 563 499 767 30.931207541899006 0.0 87.74238834781083 378.7317934544178 13.709748179345441 4420.0
非同期 1000 836 531 1795 239.64073750303814 0.0 55.328095606949205 238.81859170977646 8.645014938585813 4420.001
非同期 1000 569 506 736 42.54861685178461 0.0 86.58758334054897 373.7472704617283 13.529309896960777 4420.001
非同期 1000 556 502 768 40.42616474512495 0.0 88.11349017534585 380.3337057505066 13.767732839897787 4420.001

色々な項目がありますが Throughput が1秒あたりのリクエストを処理した数なので見てみると、同期版のほうは一度だけ 51 という数字を叩きだしたりしてびっくりしましたが、大体 15 以下の時が多い感じです。非同期の方は大体安定して 80 以上の数字が出てる感じでした。

雑なまとめ

結構適当にやってみただけというレベルですが、思ったより差が出てびっくりしました。ということなので、非同期版のメソッドが提供されているものでは、なるべく非同期でやりましょうということでした。

Microsoft (有志)

Discussion