👏

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

2022/07/11に公開
4

前に ASP.NET Core で非同期と同期で3秒待ってレスポンス返すだけのプログラムを書いて Azure の Web Apps にデプロイして結果を見るという事をやりました。

https://zenn.dev/okazuki/articles/do-not-lock-thread

今回は、これの ASP.NET 4.8 編になります。

デプロイした Web API

純粋に Thread.Sleep(3000); で待つものと await Task.Delay(3000); で待つものの二種類を作りました。ASP.NET 4.8 の Web API のテンプレートで出力される ValuesControllerGet() メソッドに対して上記の2種類のコードを追加しています。

同期版

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Web.Http;

namespace SyncWebApp.Controllers
{
    public class ValuesController : ApiController
    {
        // GET api/values
        public IEnumerable<string> Get()
        {
            Thread.Sleep(3000);
            return new string[] { "value1", "value2" };
        }

        // GET api/values/5
        public string Get(int id)
        {
            return "value";
        }

        // POST api/values
        public void Post([FromBody] string value)
        {
        }

        // PUT api/values/5
        public void Put(int id, [FromBody] string value)
        {
        }

        // DELETE api/values/5
        public void Delete(int id)
        {
        }
    }
}

非同期版

using System;
using System.Collections.Generic;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading.Tasks;
using System.Web.Http;

namespace AsyncWebApp.Controllers
{
    public class ValuesController : ApiController
    {
        // GET api/values
        public async Task<IEnumerable<string>> Get()
        {
            await Task.Delay(3000);
            return new string[] { "value1", "value2" };
        }

        // GET api/values/5
        public string Get(int id)
        {
            return "value";
        }

        // POST api/values
        public void Post([FromBody] string value)
        {
        }

        // PUT api/values/5
        public void Put(int id, [FromBody] string value)
        {
        }

        // DELETE api/values/5
        public void Delete(int id)
        {
        }
    }
}

デプロイした環境と JMeter のテストプラン

Azure の App Service の Web App にデプロイしました。プランは S1 というオーソドックスなプランです。コア数 1 で RAM が 1.75 GB のプランになります。
これに対して、以下のようなシンプルな JMeter のテストプランを作って負荷をかけました。

Number of Threads が 100 で Ramp-up period が 1 で Loop Count を 10 にしています。

そして先ほどスリープを仕込んだエンドポイントに対して純粋に GET リクエストを送信しています。

この結果を Summary Report で集計しています。

結果

では、早速同期版と非同期版の結果を見ていきましょう。

非同期版

Label # Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg. Bytes
HTTP Request 1000 3203 3123 3955 166.6461938839271 0.0 30.271841133377734 17.914780983229402 4.2865400042380575 606.0
HTTP Request 1000 3198 3125 3689 129.09135275454938 0.0 30.384054448225573 17.981188472289745 4.302429584953816 606.0
HTTP Request 1000 3205 3123 3650 132.32687270165596 0.0 30.458089668615983 18.025002284356724 4.312913087841131 606.0
HTTP Request 1000 3190 3124 3648 130.94387337710762 0.0 30.461800901669303 18.027198580480075 4.313438604240282 606.0
HTTP Request 1000 3189 3124 3660 131.03790236415927 0.0 30.426580660865334 18.006355352035538 4.308451363110813 606.0

同期版

Label # Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg. Bytes
HTTP Request 1000 7147 3065 22751 4142.388936393949 0.0 13.382043973396495 7.893314999933089 1.8818499337588823 604.0
HTTP Request 1000 5537 3035 12016 2457.799718325111 0.0 17.387977952043958 10.256190120150928 2.4451843995061813 604.0
HTTP Request 1000 6921 3040 25229 5174.788928208666 0.0 13.995801259622112 8.25533589923023 1.9681595521343596 604.0
HTTP Request 1000 7660 3050 23234 4753.892515225707 0.0 12.74664763167287 7.518530438994545 1.7924973232039974 604.0
HTTP Request 1000 7186 3073 21269 4221.9017889902425 0.0 13.432555140638852 7.9231086962361985 1.8889530666523386 604.0

圧倒的ではないか!我が軍(async/await)は!!最初はデータを取得してから記事にしようと思っていたのですが、同期版のほうが時間がかかりすぎて思わず記事を書き始めてしまうくらいには同期版のほうが遅かったです…。

非同期のほうは大体 3 秒でレスポンスが返ってきているのに対して同期版のほうは 20 秒以上かかっているものもあります。やばいですね…。

やっぱり時代は非同期です。

なんちゃって非同期対応

もうちょっと遊んでみようと思います。非同期にしないといけないということで雑に await Task.Run(() => 同期処理); で包んでみました。

public async Task<IEnumerable<string>> Get()
{
    // ダメ絶対!!
    await Task.Run(() => Thread.Sleep(3000));
    return new string[] { "value1", "value2" };
}

デプロイして同じように JMeter で負荷をかけてみました。

Label # Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg. Bytes
HTTP Request 1000 4945 3046 10671 1799.2011478072704 0.0 19.068321796998646 11.24733043494842 2.6814827527029346 604.0
HTTP Request 1000 6990 3013 24228 4695.030984432797 0.0 13.795577137969568 8.137234952474238 1.9400030350269706 604.0
HTTP Request 1000 3885 3016 8236 1124.086469006722 0.0 24.273022962279722 14.31729088790718 3.413393854070586 604.0
HTTP Request 1000 3284 3009 5644 475.23412324874073 0.0 27.4228048044754 16.175170021389786 3.856331925629353 604.0
HTTP Request 1000 3081 3008 5157 179.12730556785306 0.0 29.972425368660833 17.67904777604604 4.21487231746793 604.0

くっそ不安定なんですが…。

スレッドプールを大きくする

同期版のメソッドを投げ捨てて非同期にするのが良いのはそうなんですが、そうはいっても非同期への書き換えは大変です。
では、どうすれば早くなるのかというのを考えてみましょう。

.NET のスレッドプールですが、こちらは大きさが増えていくのは結構時間がかかるようです(ドキュメント何処だろう)。

https://stackoverflow.com/questions/46191002/requests-are-queuing-in-azure-appservice-though-it-has-enough-threads-in-threadp

上の stackoverflow にある通りスレッドプールを増やすところがボトルネックになっているのだとしたら、そこを増やしてやれば性能が改善することになります。この設定を行うために Global.asax.cs の Application_Start メソッドに以下のコードを追加してみました。

Global.asax.cs
public class WebApiApplication : System.Web.HttpApplication
{
    protected void Application_Start()
    {
        ThreadPool.GetMinThreads(out var _, out var completionPortThreads);
        ThreadPool.SetMinThreads(5000, completionPortThreads); // 5000 に最小値を増やす!
        AreaRegistration.RegisterAllAreas();
        GlobalConfiguration.Configure(WebApiConfig.Register);
        FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters);
        RouteConfig.RegisterRoutes(RouteTable.Routes);
        BundleConfig.RegisterBundles(BundleTable.Bundles);
    }
}

デプロイして JMeter で負荷をかけてみました。

Label # Samples Average Min Max Std. Dev. Error % Throughput Received KB/sec Sent KB/sec Avg. Bytes
HTTP Request 1000 3104 3008 3676 146.1306757665888 0.0 31.391260673028626 18.515938912606728 4.4143960321446505 604.0
HTTP Request 1000 3033 3008 3222 27.434533784980722 0.0 31.9642000958926 18.85388365031165 4.494965638484897 604.0
HTTP Request 1000 3029 3008 3305 34.25826212754852 0.0 31.87555782226189 18.801598559224786 4.482500318755578 604.0
HTTP Request 1000 3034 3008 3232 27.371136257001975 0.0 32.035880185808104 18.89616370334775 4.505045651129265 604.0
HTTP Request 1000 3027 3008 3157 18.8074378637362 0.0 31.985670419651996 18.866547786591607 4.497984902763562 604.0

ということで、ボトルネックだったスレッドプール内のスレッドが増えるところを解消したので早くなりました。めでたしめでたし。
というか、スレッドも無料のリソースじゃないのであまりスレッドをいたずらに増やすのもよくないですよね…。

まとめ

async/await を使えるところでは async/await を使いましょう。

Microsoft (有志)

Discussion

kuremakurema

await Task.Run();ってダメなんだっけ?

Kazuki OtaKazuki Ota

今回のケースではTask.Delayすればいいので、無駄に別スレッドをブロックするようにしてなんちゃって非同期を装うというのが駄目な感じですー

kuremakurema

あー、Thread.Sleep()は同期的で例外吐かない重い処理の代替表現ってことじゃなくて、そのまんまってことか。
Task周りは今でも割と雰囲気でやってるところあるから、「実は今まで間違ってた?」と不安になる。
ConfigureAwait()とか時折検索したり…。

Kazuki OtaKazuki Ota

Thread.SleepはイメージとしてはCPU使わないWebAPIよびだしやDBアクセスのかわりです。