C# の Web アプリで async/await を使わないとどれくらい性能劣化するか見てみよう(.NET Framework編)
前に ASP.NET Core で非同期と同期で3秒待ってレスポンス返すだけのプログラムを書いて Azure の Web Apps にデプロイして結果を見るという事をやりました。
今回は、これの ASP.NET 4.8 編になります。
デプロイした Web API
純粋に Thread.Sleep(3000);
で待つものと await Task.Delay(3000);
で待つものの二種類を作りました。ASP.NET 4.8 の Web API のテンプレートで出力される ValuesController
の Get()
メソッドに対して上記の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 のスレッドプールですが、こちらは大きさが増えていくのは結構時間がかかるようです(ドキュメント何処だろう)。
上の stackoverflow にある通りスレッドプールを増やすところがボトルネックになっているのだとしたら、そこを増やしてやれば性能が改善することになります。この設定を行うために Global.asax.cs の Application_Start メソッドに以下のコードを追加してみました。
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 を使いましょう。
Discussion
await Task.Run();
ってダメなんだっけ?今回のケースではTask.Delayすればいいので、無駄に別スレッドをブロックするようにしてなんちゃって非同期を装うというのが駄目な感じですー
あー、
Thread.Sleep()
は同期的で例外吐かない重い処理の代替表現ってことじゃなくて、そのまんまってことか。Task
周りは今でも割と雰囲気でやってるところあるから、「実は今まで間違ってた?」と不安になる。ConfigureAwait()
とか時折検索したり…。Thread.SleepはイメージとしてはCPU使わないWebAPIよびだしやDBアクセスのかわりです。