C# の Web アプリで async/await を使わないとどれくらい性能劣化するか見てみよう
はじめに
.NET 6 がリリースされて、もうすぐ 3 カ月になります。時が経つのは早いですね…。
.NET 6 は長期サポートバージョンで .NET Framework と比べて一般的に性能がいいので、マイグレーションについて考えてる人も多いと思います。
そんな性能のいい .NET 6 ですが、特定の文脈で性能の悪いコードを書くことも当然出来ますし、そうならないように色々ベストプラクティスが提供されています。
例えば ASP.NET Core 6.0 向けでは以下のようなドキュメントがあります。
その他にも Azure Functions でも以下のようなドキュメントがあります。
色々書いてありますが、大体のドキュメントに書いてある内容として非同期 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 以上の数字が出てる感じでした。
雑なまとめ
結構適当にやってみただけというレベルですが、思ったより差が出てびっくりしました。ということなので、非同期版のメソッドが提供されているものでは、なるべく非同期でやりましょうということでした。
Discussion