ASP.NET で非同期 (Async) を乗りこなす


こんにちは。

連休でだいぶ間が空いてしまいましたが、今週は、これからの Web 開発に備えて、.NET Web 開発における Async (Asynchronous) の意義を、ASP.NET MVC を例に簡単に説明します。(今後のセミナーなどでも頻繁にこの話に触れることになると思いますので、そのための準備として記載しておきます。)

補足 : 下記で紹介する async を使った非同期処理は、ASP.NET WebForm でも可能です。(ただし、記述方法が多少煩雑になります。) これについては、「ASP.NET WebForms 4.5, WCF 4.5 における非同期 (async) メソッド」の投稿に記載しました。

UI 系 (クライアント サイド) では、よく、UI スレッドを止めない手法として非同期を紹介しますが、サーバー サイドを含む Web 開発では、下記の通り、また違った尺度での考え方が必要となります。

 

Why async ? - Web 開発 (サーバー サイド) における非同期実装 (Async) の意義

まず、基本的な話からですみません。IIS / ASP.NET における処理では、以下の 3 つを理解しておきましょう。

  • Request (要求)
  • Queue (キュー)
  • Thread (スレッド)

ブラウザーなど、クライアント側からの Request (要求) は、いったん Queue に入り、処理の開始と共に Thread が割り当てられます。(Queue にも入りきらない場合には、503 エラーが返されます。) ASP.NET の場合は、ASP.NET のパイプラインで処理が開始されると、CLR スレッド プール (Thread Pool) から Managed Thread が割り当てられて、処理が実行されます。(.NET Framework の既定の設定では、Thread の上限なども、この CLR スレッド プールの管理に任せています。)
Request = Thread ではありません。例えば、Request を 1000 受け付けても、そのうち 100 だけ Managed Thread が割り当てられ、残りの 900 の Request は割り当て待ちになっている場合もあります。また、後述するように、複数の Request を単一の Managed Thread が交代で処理する場合も考えられます。

こうした動きから想像できるように、Request、Queue、Thread の中で、特に Thread はオーバーヘッドも大きく、大量のトランザクションを処理する際、全体のスループット (throughput) に大きく影響します。(Request の上限値も最近では非常に大きな値 (既定値) が割り当てられており、あまり気にならないはずです。)
では、「スレッド (Thread) がスループット (throughput) に大きく影響する」 という状況を、ちょっと簡単な実験で見てみましょう。

補足 : なお、以降の確認は Windows Server を使用してください。(Windows 7 など Client 環境では、そもそも Concurrent 接続数が最大 10 に制限されています。)

まず、準備として、ASP.NET の既定値を変更しておきましょう。ここで使用する ASP.NET 4.5 の既定値は、1 論理コア当たり 100 スレッドまで使用可能になっていますから、このあとの実験がやりやすいように、この値を減らします。
machine.config をメモ帳などで開き、下記の通り修正します。(念のため、iisreset も実行しておきましょう。)

補足 : .NET Framework 4, .NET Framework 4.5 (Beta) などの既定値では、processModel として autoconfig="true" が設定されています。この autoconfig は、こちら の推奨に従って、1 論理コア当たり Max 100 スレッド (最大です) の設定をおこないます。

補足 : Windows Azure の Cloud Services で確認する場合は、Startup Task を使って ApplicationPool の構成を変更してください。(appcmd.exe を使用)

. . .

<system.web>
    <processModel maxWorkerThreads="5" />
    . . .

上記では、CPU ごとの最大ワーカースレッド数を設定しています。このため、私の環境 (論理 2 コア) では、今回、Worker Theread が最大 10 個まで使用可能です。(よって、以降では、その前提で話を続けます。Web Garden とせず、Worker Process も 1 個という前提で進めます。)

では、さっそく実験をしてみましょう。例えば、MVC のコントローラーで Timer を使って処理をおこなう場合、通常の ASP.NET MVC アプリケーションでは以下のようになるでしょう。(コントローラーの処理自体はシングル スレッドのため、下記の通り、待機と同期のためのコードが必要です。)

. . .

using System.Threading;
. . .

public class Sample1Controller : Controller
{
  public ActionResult Test1()
  {
    Guid id = Guid.NewGuid();
    Log(id, "start");

    AutoResetEvent auto = new AutoResetEvent(false);
    Timer timer = new Timer(new TimerCallback((s) =>
    {
      AutoResetEvent a = (AutoResetEvent)s;
      a.Set();
    }), auto, 30000, Timeout.Infinite);

    auto.WaitOne();
    Log(id, "end");
    return View("Index");
  }

  // Write log (sorry, not using trace ...)
  private void Log(Guid id, string op)
  {
    while (true)
    {
      try
      {
        using (System.IO.FileStream fs =
          new System.IO.FileStream(@"C:\Demo\test.log",
            System.IO.FileMode.Append,
            System.IO.FileAccess.Write,
            System.IO.FileShare.None))
        using (System.IO.StreamWriter wr = new System.IO.StreamWriter(fs))
        {
          wr.WriteLine("{0} {1} {2}",
            DateTime.Now.ToLongTimeString(),
            id.ToString(),
            op);
        }
      }
      catch (System.IO.IOException)
      {
        System.Threading.Thread.Sleep(500);
        continue;
      }
      break;
    }
  }
}
. . .

この Web アプリケーションに、Visual Studio のロードテスト (Load Test) などを使用して、多数の要求 (Request) を同時に投げてみてください。

まず、同時 9 接続でテストをおこなうと、9 つの要求 (Request) が Managed Thread (IIS の worker thread) で処理されて、30 秒後には処理が終了します。そして、期待通り、次の要求が正しく処理されます。
しかし、同時 10 接続になると深刻な問題を起こします。まず、同時に 10 個の要求 (Request) を投げた場合、上述の通り、今回は 10 個までスレッド (スレッド プールのスレッド) が使用可能なため、10 個の要求は、すべて Timer の開始に成功します。30 秒後、timer callback で新たに Managed Thread Pool から Thread (11 個目のスレッド) が必要となりますが、この際、スレッド プールからスレッドを確保できず、確保できるまでずっと待たされ続けるでしょう。(実際実行してみると、内部で 2 分ごとに worker process の再起動とスレッドの再開始がおこなわれ、何度も処理が restart されます。いつまで経っても、処理は終了できません。)
本来なら、処理が終了すると、次の要求 (Request) が Managed Pipeline でつぎつぎと処理されますが、この例では、10 個以上の要求 (Request) を投げると、いつまで経過しても 11 番目以降の処理に入ることはできなくなります。

補足 : worker process で処理されている要求 (Request) の内容は、IIS 管理マネージャーの [ワーカー プロセス] で確認できます。下図の赤で囲った部分が、Managed thread (worker thread) で処理されている要求 (Request) です。

補足 : なお、あまりお勧めはしませんが、Thread Pool を使用しない場合は、この制約は受けません。(リソースの使用可能な範囲で、いくつでもスレッドの実行が可能です。)

つぎに、上記の処理を、下記の通り AsyncController (確か、MVC 2 以降から追加されたやつ) を使って書き直してみましょう。

. . .
using System.Threading;
. . .

public class Sample2Controller : AsyncController
{
  public void Test1Async()
  {
    AsyncManager.OutstandingOperations.Increment();
    Guid id = Guid.NewGuid();
    Log(id, "start");

    Timer timer = new Timer(new TimerCallback((s) =>
    {
      AsyncManager.Parameters["id"] = id;
      AsyncManager.OutstandingOperations.Decrement();
    }), null, 30000, Timeout.Infinite);
  }

  public ActionResult Test1Completed(Guid id)
  {
    Log(id, "end");
    return View();
  }

  // Write log (sorry, not using trace ...)
  private void Log(Guid id, string op)
  {
    while (true)
    {
      try
      {
        using (System.IO.FileStream fs =
          new System.IO.FileStream(@"C:\Demo\test.log",
            System.IO.FileMode.Append,
            System.IO.FileAccess.Write,
            System.IO.FileShare.None))
        using (System.IO.StreamWriter wr = new System.IO.StreamWriter(fs))
        {
          wr.WriteLine("{0} {1} {2}",
            DateTime.Now.ToLongTimeString(),
            id.ToString(),
            op);
        }
      }
      catch (System.IO.IOException)
      {
        System.Threading.Thread.Sleep(500);
        continue;
      }
      break;
    }
  }
}
. . .

注意 (2012/12/12 追記) : このコードは、Windows Azure 上では使用しないでください。Windows Azure の WebRole で、こうした Timer 処理をおこなう場合は、「Windows Azure の WebRole で効率的な Timer 処理をする」を参照してください。

このコードの場合、実行結果は大きく異なり、同時 10 接続はもちろん、さらに大量の要求 (Request) も同時に処理できます。その理由 (背景) を考察してみましょう。

まず、標準の Controller (前述の Sample1Controller) の場合には開始から終了までの処理を同一のスレッドで処理します。(これは、いたって一般的なパターンです。) 一方、上記の AsyncController の場合には、Timer の待機時 (上記の Test1Async メソッドの終了後) にスレッドを解放します。(正確には、スレッド プールに戻します。) そして、Timer callback 時に、再度、使用可能なスレッドがスレッド プールから割り当てられます。つまり、上記コードの Log(id, "start") と Log(id, "end") の箇所では、ぞれぞれ別のスレッドが割り当てられます。(たまたま同じになる場合もありますけど。。。) このため、上記の通り、Managed Thread を 10 個に制限しても、30 秒の待機の間はスレッドは使用 (もしくは、ブロック) されておらず、空いているスレッドを使って他の要求 (Requset) を同時に処理できるようになります。
少々乱暴な書き方ですが、多数の要求 (Request) を処理するために、スレッド (Thread) を多数生成するのではなく、スレッド (Thread) の握りっぱなし無くす方法で、少ないスレッド (Thread) で、多数の要求 (Request) を効率的に処理しています。また、非同期処理は、上記のようなデッドロック (deadlock) の回避にも貢献します。

この動きを、スライド (アニメーション) にしてみました。(下図を Click してください)

何でもかんでも非同期 (asynchronous) を使いたくなるかもしれませんが、こうした動きのため、扱い方に注意してください。
.NET では、上記のような timer callback や、専用の I/O Completion Port を使用した非同期 (asynchronous) I/O Completion (例えば、非同期のネットワーク IO, Disk IO, 非同期の DB 接続, など) では、待機と応答に際して Managed Thread が使用されます。この場合は、上記の方法で Async を使って処理することで、待機の際に余計なスレッドのブロックを抑え、スループット (throughput) の向上が期待できます。また、長時間 (long running) の処理を専用のノードに分散して、この Async IO と組み合わせることで、システム全体で効率化された処理と役割の分散 (高い応答性能が要求されるノードと、長時間実行のノード、など) も可能になるでしょう。(やみくもに Scale Out するのとは異なります。)
一方、CPU に強く依存する処理 (例えば、CPU 負荷の高い計算処理など) をこの方法で細々と非同期実行しても、並列性があがって速くなるどころか、動作上、かえってオーバーヘッドとなる場合があるので注意してください。(同じ「並列性」でも、CPU 割り当ての最適化と、スレッドの使用効率の問題は、分けて考えてください。スレッドの頻繁な解放・割り当て自体が、CPU にとって負荷になることもあります。)

補足 : Monitoring の目的で、ワーカー スレッドの最大数 (maxWorkerThreads) や使用可能なワーカー スレッド数などの情報を取得する場合は、下記のコードで取得できます。
int avlWorker, avlIO;
int maxWorker, maxIO;
ThreadPool.GetAvailableThreads(out avlWorker, out avlIO);
ThreadPool.GetMaxThreads(out maxWorker, out maxIO);

なお、ここでは動きを理解する目的で上記の通り maxWorkerThreads を設定しましたが、前述の通り、既定では、充分大きな値が設定されていますので安心してください。(ほどほどのトランザクションなら、めったに枯渇するようなものではありません。) 設定値については、下記を参照してください。

[MSDN] processModel Element (.NET Framework 4)
http://msdn.microsoft.com/en-us/library/7w2sway1.aspx

補足 : ここでは説明を省略しますが、使用する IO thread (Completion Port thread) も、上記の worker thread 同様に管理・設定できます。(例えば、IO thread を制限するには、上記の machine.config で、maxIoThreads を設定します。)

補足 : HttpListener を使用した Selfhost (ASP.NET Web API の HttpSelfHostServer など ) や WCF では、worker thread の代わりに Thread pool の IO thread が使用されます。(同じ理屈で、非同期処理にすることで、スレッドの無駄使いを節約できます。)

 

これからの .NET Web 開発 ~ Async, Async, Async !

上記のコードでは、AsyncController、AsyncManager などを作って、非同期を意識した特別な書き方をおこないました。
ASP.NET MVC 4、さらに、今後の ASP.NET 4.5 (.NET Framework 4.5) では、このような特別な書き方ではなく、さらにストレートな書き方ができるようになっています。

まず、ASP.NET MVC 4 では、.NET で登場した Task を使って、上記と同じ効率的な処理を以下のように記述できます。(下記の通り、AsyncController を使用する必要はありません。)

. . .
using System.Threading.Tasks;
using System.Net.Http;
. . .

public class Sample3Controller : Controller
{
    public Task<ActionResult> Test1()
    {
        // Step 1
        return GetHeavyPage().ContinueWith(task =>
        {
            // Step 2
            ViewBag.ResultHeaders = task.Result.ToString();
            return (ActionResult)View();
        });
    }

    private Task<HttpResponseMessage> GetHeavyPage()
    {
        HttpClient cl = new HttpClient();
        return cl.GetAsync(@"http://testsite/HeavyWeb/");
    }
}

上記では、コンテンツの取得に時間のかかるページ (http://testsite/HeavyWeb/) を取得していますが、この結果の取得を待機する際、Step 1 (上記のコメント) で割り当てられたスレッドは、いったんスレッド プールに戻されます。そして、Step 2 (上記のコメント) で、新しいスレッドがスレッド プールから割り当てられます。
この結果、前述した AsyncController (Sample2Controller) のときと同じように、待機している間もスレッドがブロックされることなく、ワーカー スレッド (worker thread) は次々と新しい要求 (Request) を処理できます。(つまり、ワーカー スレッドの最大数以上に、多数の Request を処理できます。)

さらに、今後正式リリースする ASP.NET 4.5 (.NET Framework 4.5 の ASP.NET) を使うと、同じ処理は、async、await キーワードによって、以下の通り記述できます。(Async CTP でも、この async / await が使用できます。)

. . .
using System.Threading.Tasks;
using System.Net.Http;
. . .

public class Sample4Controller : Controller
{
    public async Task<ActionResult> Test1()
    {
        HttpResponseMessage res = await GetHeavyPage();
        ViewBag.ResultHeaders = res.ToString();
        return View();
    }

    private Task<HttpResponseMessage> GetHeavyPage()
    {
        HttpClient cl = new HttpClient();
        return cl.GetAsync(@"http://testsite/HeavyWeb/");
    }
}

上記の async, wait はシンタックス上のキーワード (「言語」が持つ機能です) で、コンパイル時に、前述したコード (Sample3Controller) と同等の処理に変換されます。

補足 : 実際には、上記の Sample3Controller とはまったく別のコードに変換されますので注意してください。("同等" の処理に変換されますが、使用されるクラスや処理などは全く異なります。また、スレッドの使用方法も、ContinueWith を使用した場合と異なります。) ここでは、コンパイラー サービスの処理についての説明は省略します。

補足 : データベース接続 (IO) の Task ベース (TAP) の非同期呼び出しについて :
ADO.NET 4.5 の SqlDataReader では非同期メソッドが提供されています。また、Entity Framework では、Entity Framework 6 (EF6) で Task ベースの非同期呼び出しが提供される予定です。

補足 : Task ベース (TAP) に対応していないリソースへのアクセスについて :
ライブラリー (SDK) を使用した Windows Azure Storage へのアクセス、サービス参照プロキシーを使った WCF Data Services へのアクセスなど、Task ベースの非同期呼び出し (TAP) に対応していないリソース アクセスの
場合は、Task.Factory.FromAsync を使用して、APM のメソッド (BeginExecute、EndExecute など) を Task ベースのメソッドに変換できます。(2013/06 追記 : このサンプル コードは、こちら に掲載しました。)

比較のために、Async ではなく、ごく普通に Sync で書いた場合には下記のコードになります。大変似たコードになっているのがお分かり頂けるでしょう。(上記と下記のコードは似ていますが、スレッド利用の観点では、まったく動作が異なります。下記では、GetHeavyPage() の呼び出し前後を含め、ずっと同じワーカー スレッドを占有するため、要求数が増えた場合、すぐにワーカー スレッドがパンクすることでしょう。)
このように、.NET 4.5 では、通常の同期処理とほぼ同等の書き方 (スタイル) を使って、非同期で、ノンブロッキングな処理が記述できます。このため、こうした Tricky なコードが、より身近なコードとして定着してくるでしょう。(今後登場するサンプル コードや、他のプログラマーが書くコードなど、頻繁に、こうした処理に出くわすことでしょう。)

public ActionResult Test1()
{
    HttpResponseMessage res = GetHeavyPage().Result;
    ViewBag.ResultHeaders = res.ToString();
    return View();
}

 

ということで、ここではベース (導入部分) を紹介しましたが、ご存知の通り、ASP.NET Web API、WCF 4.5 のクライアント proxy 等々、今後の .NET 開発では、さまざまな場面で非同期処理が利用可能になっていますので、是非、うまく乗りこなしてください。

 

Comments (0)

Skip to main content