Cutting Edge

ASP.NET の非同期ページについての再考

Dino Esposito

ASP.NET では、どのバージョンでも HTTP の同期ハンドラーと非同期ハンドラーをサポートしています。ASP.NET 2.0 にも、開発者が非同期ページを簡単かつ迅速に作成できる新機能があります。特にサーバーベースのアプリケーションを拡張可能にするには、非同期操作が不可欠です。既存の Web アプリケーションを拡張する必要があることがわかったら、まず、どの程度のページ数を非同期に追加できるかを検討します。

非同期に関しては、ASP.NET は他のサーバー アプリケーションと同様に機能し、複数のクライアントに代わってなんらかのバックグラウンド作業を実行します。着信する各要求には、ASP.NET が所有するスレッドが ASP.NET スレッド プールから選択されて、割り当てられます。そのスレッドは、操作が完了し、要求を行ったクライアントに向けてなんらかの応答が生成されるまで、ブロックされた状態が保持されます。スレッドはどの程度待機すればよいでしょうか。ASP.NET のランタイム環境を構成して独自のタイムアウト時間 (既定値は 90 秒) を定義できますが、スレッドがブロックされないようにすることが重要です。

時間がかかる可能性のある操作を扱うときは、タイムアウトを利用して、最長でも指定した秒数が経過したら、必ず、スレッドを解放し、プールに戻すようにします。しかし、長時間スレッドをブロックしたままにしたい場合があります。理想的には、要求を開始するためにスレッドを要求し、その後、他の ASP.NET 以外のスレッドに明け渡します。操作の完了時に、要求を行ったクライアントに応答を送信するために、同じスレッド、または ASP.NET プールから別のスレッドを再度選択することになります。このパラダイムを ASP.NET の非同期ページと呼びます。

非同期操作を行うときは、ページがユーザーに対して非同期なのか、ASP.NET ランタイムに対して非同期なのかを区別する必要があります。ページがユーザーに対して非同期の場合、実行可能なアプローチは AJAX 操作のみです。ただし、低速になる可能性がある操作の実行に AJAX を使用すれば、エンドユーザーへの影響は少なくなりますが、ASP.NET ランタイムにはなんら救済にはなりません。

非同期ページと ASP.NET ランタイム

1 つの要求に長時間関わっているスレッドがあると、ASP.NET プールの中で新しく着信する要求にサービスを提供できるスレッドが 1 つ少なくなります。新たな要求にサービスを提供できるスレッドがなくなると、その要求はキューに登録されます。その結果遅延が生じ、全体的なパフォーマンスが低下します。

ASP.NET の HTTP ハンドラーは既定では同期ハンドラーです。非同期 HTTP ハンドラーは、やや異なるインターフェイスを適用することによって、明示的に設計および実装する必要があります。同期ハンドラーと非同期ハンドラーを区別する 1 つの重要な点は、非同期ハンドラーでは同期メソッドの ProcessRequest ではなく、IHttpAsyncHandler インターフェイスに含まれる以下のメソッドを使用することです。

IAsyncResult BeginProcessRequest(

     HttpContext context, 

     AsyncCallback cb, 

     object extraData);


void EndProcessRequest(
     

     IAsyncResult result);

BeginProcessRequest メソッドには、要求にサービスを提供するために実行する操作を含めます。このコードは、二次スレッドで操作を開始し、すぐに復帰するよう設計します。EndProcessRequest メソッドには、以前に開始した要求を完了するためのコードを含めます。

既にお分かりのように、非同期 HTTP 要求は、"非同期ポイント" の前と後の 2 つに分割されます。非同期ポイントとは、要求のライフサイクルの中でその要求を所有するスレッドが変わる位置のことです。非同期ポイントに到達すると、本来の ASP.NET スレッドから別のスレッドに制御が明け渡されます。実行時間がかかる可能性のある操作は、ASP.NET 要求のこの 2 つの部分の間で実行します。非同期要求の各部分は他の部分とは独立して実行され、スレッドに関する限りどのような関係もありません。つまり、1 つの要求の 2 つの部分に同じスレッドが利用される保証はありません。操作中にブロックされるスレッドがないというのが実質的な効果です。

ここで明らかな疑問が生じます。"実行時間がかかる" 操作は、実際にはどのスレッドで実行されるのでしょう。ASP.NET は内部で I/O 完了ポートを使用して、要求の完了を追跡します。非同期ポイントに到達すると、ASP.NET は保留中の要求を I/O 完了ポートにバインドし、要求の完了時に通知を受け取るコールバックを登録します。OS は OS 自体が所有する専用スレッドの 1 つを使用して操作の完了を監視するため、ASP.NET スレッドが完全なアイドル状態で待機することはなくなります。操作が完了すると、OS から完了キューにメッセージが送られます。それが ASP.NET コールバックのトリガーとなり、コールバックによって ASP.NET が所有するスレッドの 1 つが選択され、要求の処理が再開されます。既に説明したように、I/O 完了ポートは OS の機能の 1 つです。

非同期ページの実情

ASP.NET の非同期ページは、一般に、時間がかかる可能性がある操作を実行する特定のページのパフォーマンスを向上するという考え方から使用されます。しかし、他にも注意すべき点がいくつかあります。ユーザーの観点からは、同期要求でも非同期要求でもそれほど変わりはありません。要求した操作が実行され、その完了に 30 秒かかるとすると、ユーザーは新しいページが返るまで最低でも 30 秒待機することになります。ページを同期方式で実装しても、非同期方式で実装しても、このことに違いはありません。さらに、あまり驚かないでいただきたいのですが、1 つの要求が完了するまでの時間は、最終的には非同期ページの方がやや長くなります。では、非同期ページのメリットはどこにあるのでしょう。

スケーラビリティとパフォーマンスはまったく同じものではありません。つまり、スケーラビリティとパフォーマンスはほぼ同じ意味で使われますが、レベルが異なり、スケーラビリティは 1 つの要求ではなく、アプリケーション全体を対象とする表現です。非同期ページがテーブルにもたらすメリットは、ASP.NET プール内のスレッドにもたらすメリットに比べればわずかなものです。実行時間がかかる要求を高速に実行するようなことはありませんが、システムがあまり実行時間のかからない通常の要求にサービスを提供する際に有効です。つまり、低速の要求が実行されていても特別な遅延が生じることはありません。

非同期要求は非同期 HTTP ハンドラーを使用します。このハンドラーは、そのバージョンの ASP.NET プラットフォームにも備わっている機能の 1 つです。ただし、ASP.NET Web フォームと ASP.NET MVC ではしくみが異なりますが、どちらも開発者が非同期操作を簡単に実装できるようにします。ここからは ASP.NET MVC 2 での非同期操作について説明します。

非同期コントローラーのアクション

ASP.NET MVC 1.0 では、コントローラーのどのアクションも同期方式でしか実行できませんでした。しかし、新たに AsyncController クラスが MVC Futures ライブラリに追加されました。コントローラーのこの非同期 API は試行期間を経て正式に ASP.NET MVC フレームワークに追加され、ASP.NET MVC フレームワークのバージョン 2 として完全に利用できるようになり、ドキュメントにも記載されました (このコラムで扱っている構文と機能は ASP.NET MVC 2 RC のものです)。MVC Futures ライブラリの AsyncController クラスをお使いの場合は、いくつか変更が加えられ、簡単かつ明確になっていることがわかるでしょう。

AsyncController の目的は、ASP.NET MVC フレームワークを特徴付けるプログラミングの全体的アプローチを変えずに、公開している任意のアクション メソッドを非同期に実行できるようにすることです。図 1 は、非同期アクションの処理に関わる手順を順番に示したものです。

Figure 1 Mechanics of an Async Action Method in ASP.NET MVC
図 1 ASP.NET MVC での非同期アクション メソッドのメカニズム

非同期ポイントは、実行中イベントと実行完了イベントの間にあります。アクションの呼び出し側が、アクションが実行直前であることを通知する時点で関連するスレッドは、依然として、Web サーバー キューから要求を選択した元のASP.NET スレッドです。ここで、アクションが実行されます。最終的に、アクションの呼び出し側でアクションの実行完了イベントを通知する準備が整った時点では、おそらく別の ASP.NET スレッドが要求を実行します。図 2 にこのシナリオを示します。


図 2 非同期アクション メソッド呼び出しのスレッド切り替え

非同期メソッドの作成方法とデバッグ方法を説明する前に、非同期 ASP.NET 操作の基本的なポイントについて、もう 1 つ明確にしておきます。それは、すべてのアクションが非同期操作の適切な候補になるわけではないという点です。

実際に非同期にする操作

I/O の制約を受ける操作が、唯一、非同期コントローラー クラスで非同期アクション メソッドにする適切な候補です。I/O の制約を受ける操作とは、ローカル CPU が完了を決定できない操作です。I/O の制約を受ける操作がアクティブになると、CPU は外部ストレージ (データベースやリモート サービス) から処理される (ダウンロードされる) データを待機するだけです。I/O の制約を受ける操作は、タスクの完了が CPU のアクティビティによって決まる、CPU を集中的に使用する操作とは対極をなします。

I/O の制約を受ける代表的な例は、リモート サービスの呼び出しです。この場合、アクション メソッドは、要求を発行してから、なんらかの応答がダウンロードされるまで待機するだけです。実際の作業は、別のコンピューターの別の CPU で実行されます。そのため、ASP.NET スレッドは完了を待機するアイドル状態になります。このアイドル スレッドを待機状態から解放し、着信する他の要求にサービスを提供できるようにすることで、パフォーマンスが向上します。これを実現するのが、アクションやページの非同期実装です。

時間がかかる操作を非同期実装しても、すべての場合に具体的なメリットがあるわけではないことがわかります。時間がかかる操作でも、メモリ内で計算を行う操作は非同期実装にしても大きなメリットはありません。それどころか、同じ CPU が ASP.NET 要求と計算の両方にサービスを提供するため、実行速度がわずかに低下することもあります。さらに、物理計算の実行に ASP.NET スレッドを必要とする場合もあります。CPU を集中的に使用する操作を非同期実装にしても、メリットはほとんどありません。一方、リモート リソースを呼び出している場合、それが複数のリソースであっても、非同期メソッドを使用すると、個別の要求のパフォーマンスは向上しなくても、アプリケーション全体のパフォーマンスが向上します。

もう少し後で、例を使ってこの点を説明します。その前に、ASP.NET MVC での非同期アクションの定義と実行に必要な構文に注目してみましょう。

非同期ルートの認識

非同期ルートと同期ルートの違いはどこにあるのでしょう。MVC Futures では、同期ルートと非同期ルートの登録に異なるメソッドを使用する必要がありました。以下に、この古い方法での非同期ルートの登録を示します。

routes.MapAsyncRoute(

    "Default",

    "{controller}/{action}/{id}",

    new { controller = "Home", action = "Index", id = "" }

);

従来は、同期メソッドに使用していた標準の MapRoute メソッドではなく、MapAsyncRoute 拡張メソッドを使用する必要がありました。しかし、ASP.NET MVC 2 RC ではこの区別がなくなります。アクションを実行する方法には関係なく、ルートの登録には MapRoute メソッドだけを使用することになります。

そのため、要求の URL は通常どおりに処理され、使用するコントローラー クラスの名前が導き出されます。実際に必要なことは、以下に示すように、新しい AsyncController クラスから派生したコントローラー クラスで非同期メソッドを定義することです。

public class TestController : AsyncController

{

  ...

}

コントローラー クラスを AsyncController から継承する場合は、アクション名をメソッドにマップする表記法がやや異なります。AsyncController クラスは、同期要求にも非同期要求にもサービスを提供できます。そのため、使用する表記法では、以下に示すように、Run メソッドと RunAsync メソッドの両方を認識できます。

public class TestController : AsyncController

{

  public ActionResult Run(int id) 

  {

     ...

  }

  public void RunAsync(int id) 

  {

     ...

  }

}

ただし、このようにすると例外がスローされます (図 3 参照)。

非同期アクションは名前で識別します。想定するパターンは xxxAsync で、xxx は実行するアクションの既定の名前を示します。明らかに、xxx という名前が付いた別のメソッドが存在すると、この属性を使用してあいまいさを排除している効果がなくなり、図 3 に示すような例外がスローされます。


図 3 アクションの名前によるあいまい参照

単語 Async はサフィックスと見なされます。RunAsync メソッドを呼び出す URL は、プレフィックスの Run のみを含みます。たとえば、次の URL はルート パラメーターとして値 5 を渡して、RunAsync メソッドを呼び出します。

http://myserver/demo/run/5

これが同期アクションと非同期アクションのどちらに解決されるかは、AsyncController クラスに含めたメソッドによって異なります。ただし、xxxAsync メソッドは操作のトリガーであることのみを識別します。要求のファイナライザーは、コントローラー クラス内で xxxCompleted という名前の付いた別のメソッドです。

public ActionResult RunCompleted(DataContainer data)

{

    ...

}

非同期アクションを定義している 2 つのメソッドのシグネチャが異なることに注意してください。トリガーは、void メソッドと想定されます。このメソッドで戻り値を返すように定義しても、その戻り値は単純に無視されます。xxxAsync メソッドの入力パラメーターは、通常どおり、モデル バインディングになります。ファイナライザーは、処理の対象となり、ビュー オブジェクトに渡すことが想定されるデータを含むカスタム オブジェクトを受け取り、通常どおり ActionResult オブジェクトを返します。トリガーが計算した値とファイナライザーが宣言したパラメーターとを対応付けるには、特殊なプロトコルが必要です。

AsyncController クラス

AsyncController コントローラー クラスは、Controller から継承し、以下に示すような一連のインターフェイスを実装します。

public abstract class AsyncController : Controller, 

                IAsyncManagerContainer, 

IAsyncController, IController

非同期コントローラーの最も特徴的な側面は、操作の実行内容を含む特別なアクション呼び出しオブジェクトです。この呼び出しオブジェクトでは、そのアクションを構成する個別の操作の数を追跡するカウンターが必要です。この個別の操作は、アクション全体の終了を宣言する前にすべて同期する必要があります。図 4 に、非同期アクションのサンプル実装を示します。

図 4 簡単な非同期アクション メソッド

public void RunAsync(int id) 

{

    AsyncManager.OutstandingOperations.Increment();



    var d = new DataContainer();

     ...

            

    // Do some remote work (i.e., invoking a service)

     ...



    // Terminate operations

    AsyncManager.Parameters["data"] = d;

    AsyncManager.OutstandingOperations.Decrement();

}

public ActionResult RunCompleted(DataContainer data)

{

   ...

}

AsyncManager クラスの OutstandingOperations メンバーは、保留中の非同期操作の数を管理するコンテナーを提供します。これは OperationCounter ヘルパー クラスのインスタンスで、カウンターを増減するためのアドホック API を提供します。Increment メソッドは、以下に示すように単項増分式ですが、これに限定されるわけではありません。

AsyncManager.OutstandingOperations.Increment(2);

service1.GetData(...);

AsyncManager.OutstandingOperations.Decrement();

service2.GetData(...);

AsyncManager.OutstandingOperations.Decrement();

AsyncManager の Parameters ディクショナリは、同期呼び出しのファイナライザー メソッドに引数として渡す値をグループ化するために使用します。Parameters ディクショナリは、ファイナライザー (上記の例では xxxCompleted メソッド) に渡すパラメーターごとに 1 つエントリを含むことを想定しています。パラメーターの名前に一致するエントリがディクショナリに見つからなければ、パラメーターには既定値 (参照型の null) が指定されていると仮定されます。null オブジェクトにアクセスを試みない限り、例外は発生しません。xxxCompleted メソッドはサポートしている型のパラメーターを受け取り、このパラメーターを使用して ViewData コレクションまたはビューが認識する厳密に型指定されたオブジェクトを設定します。xxxCompleted メソッドには、ActionResult オブジェクトを返す役割があります。

お気に召しましたか

まとめると、同期要求は ASP.NET には欠くことのできない機能ですが、実際には、非同期 HTTP ハンドラーも ASP.NET 1.0 からサポートされています。

ASP.NET Web フォームと ASP.NET MVC はどちらも、非同期操作のコーディングに必要な高度なツールを提供しますが、それぞれ独自のアプリケーション モデルがあります。つまり、ASP.NET MVC には非同期コントローラーがあり、Web フォームでは非同期ページを使用します。

ただし、非同期アクションで重要な点は、対象とするタスクが非同期操作の実装に適しているかどうかを判断することです。非同期メソッドは、I/O の制約を受ける操作のみに実装します。最後に、非同期メソッドはメソッド自体が高速に実行されるわけではなく、他の要求を高速に実行できるようにするということに注意してください。

Dino Esposito は、近々発表される『Programming ASP.NET MVC』(Microsoft Press) の著者であり、『Microsoft .NET: Architecting Applications for the Enterprise』(Microsoft Press、2008 年) の共著者でもあります。Esposito はイタリアに在住し、世界各国で開催される業界のイベントで頻繁に講演しています。ブログは weblogs.asp.net/despos (英語) です。

この記事のレビューに協力してくれた技術スタッフの Stefan Schackow に心より感謝いたします。