Tomcat Advanced I/O によるハイパフォーマンス Ajax

異なるリクエスト処理モデルを使って並列スレッドの数を減らす

NIO (Non-Blocking I/O) を利用するとシステム・リソース (スレッド) を効率的に使用できるため、サーバーのパフォーマンスを劇的に改善することができます。特に long-poll メカニズムを持つ Ajax (Asynchronous JavaScript + XML) アプリケーションの場合には、パフォーマンスが改善されるのがはっきりとわかります。また NIO を利用すると、負荷の重いサーバーのシステム・リソースそれぞれの使用量を調整することができます。この記事では、Ajax リクエストを処理する場合と通常のリクエストを処理する場合の両方に関して、サーバーのパフォーマンスを最適化するための方法を説明します。

Adriaan de Jonge, Software Professional, Freelance

Adriaan de JongeAdriaan de Jonge はソフトウェアの専門家として、現在はオランダ政府の業務に携わっており、いくつかの役割の中で複数のプロジェクトを扱っています。彼は IBM developerWorks と Amazon に XML 関係の記事を執筆しています。連絡先は adriaandejonge@gmail.com です。



2008年 9月 30日

ブラウザーは、リクエストを送信し、サーバーから受信されるレスポンスを表示することによって Web サーバーと通信します。これはごく当たり前のことを言っているように聞こえますが、いくつかの理由から、この記事では非常に重要なことを言っているのです。

リッチな Web アプリケーションは、リクエスト/レスポンス・モデルとは異なる動作をする機能を提供するようになりつつあります。Ajax フレームワークは基礎となるリクエスト/レスポンス・モデルを抽象化することによって、クライアント上で実行される (しっかり作られたローカルの Microsoft® Windows® アプリケーションや KDE アプリケーションのような) GUI (Graphical User Interface) アプリケーションのように動作するモデルを提供しています。このモデルでは、リクエストに応答するのではなく、イベントに応答するコードを作成する必要があります。つまり、きめ細かなイベント処理はリッチなユーザー・インターフェースにとって重要な鍵なのです。

しかしそのモデルの下には、相変わらずリクエスト/レスポンス・モデルがあります。リクエスト/レスポンスによるモデルはプログラミング・モデルとして制限が多いだけではなく (その制限を回避するために API による抽象化を行います)、パフォーマンスの問題が発生するケースも多く、高負荷下で動作する場合にはサーバーがリソースを適切に調整することができません。こうした問題は、このモデルが本来の目的以外に使用された場合、さらに悪化します。

例えば、ブラウザー・クライアントはサーバーで変更が行われた場合に通知を受け取りたいと要求するかもしれません。これは本来不可能なことで、以前は 5 分ごとにページを完全にリロードすることで、そうした動作をしているかのように見せかけることしかできませんでした。しかし JavaScript コードと Ajax が導入されて以来、新しい可能性が生まれ、そうしたやり方よりもずっと効率的でスマートにページが変更通知を受け取れるようになりました。

ページは定期的にサーバーをポーリングするため、大量のサーバー・リクエストが作成され、またイベントと通知の間に大きな遅延が生じます。そうする代わりに、ページがサーバーへの接続をオープンなままにしてレスポンスを待つこともできます。この 2 番目のモデルは long-poll と呼ばれます。この記事では、このポーリング・モデルについて説明します。long-poll では、サーバーのイベントに最も早く応答できる一方、いくつかの困難な問題を克服する必要があります。

従来のサーブレット・モデルでは、接続がオープンのままの場合、あるイベントに応答してクライアントを更新する必要が生ずるまで、専用スレッドはひたすら待機するのみです。スレッドは比較的コストが高く、またサーバー上で利用可能なスレッドの数は限定されています。ページを同時に訪問している人が増えると、サーバー上のリソース使用量が急速に増加します。1 人の訪問者であっても、いくつかの偶発的なページ・リクエストをするのみとは限りません。その訪問者がそのページを閲覧している間、接続は (待機スレッドがある状態で) オープンされたままかもしれません。数百人の訪問者が、彼らがコンピューターから離れている間もブラウザーを開いたままにすると、すぐに問題が生じます。どのように抽象化を行ったとしても、こうしたパフォーマンスの問題を解決することはできません。

ソリューション

下位レベルの問題に対するソリューションは、下位レベルの API の中で見つける必要があります。相変わらずリクエスト/レスポンス・モデルを使用する場合には、NIO (Non-Blocking I/O) を使うことによって、待機スレッドを無駄にすることなく接続をオープンにしたまま保つことができます。サーブレットで NIO の利用を容易にするには、オープンされた接続上で適切なタイミングで適切な読み書きアクションを開始する、イベント・ベースの API が必要です。Tomcat 6 には、そうしたイベント・ベースのモデルを容易に実現する CometProcessor API が提供されています。この記事では、この CometProcessor API を紹介します。


NIO

まず、NIO について少し知っておく必要があります。従来のブロッキング I/O では、1 つのスレッドで最初から最後までストリームを読み取り、そのスレッドはストリームが完全に終了するまで待機します。この方式は、一度で処理する必要があるような存続期間の短いストリームには効果的です。つまり読み取りを開始したらデータがなくなるまで読み取りを続け、そして接続を閉じるのです。この一度の処理にはほとんど時間がかからないため、システム・リソースが要求される時間はごく短くなります。その後に短時間の接続が大量に続いても問題はありません。

通知のメカニズムとしては、読み取りが必要な場合にのみ読み取ることができ、書き込みが必要な場合にのみ書き込むことができ、ただし発生するイベントに素早く応答できるように接続をオープンのままに保つメカニズムが必要です。こうしたことを容易に実現するためには NIO が必要です。NIO は Java™ 言語のバージョン 1.4 以来、Java の一部として提供されています。

NIO を使うと、ストリームが終了するまでストリームをループする必要がありません。データがなくなるまでストリームをループした後は、システムは自由に他のことをすることができます。ストリームに影響を与えるイベントが発生した場合 (例えば、さらにデータが送られてきた場合など) には、そのストリームに対するループを継続します。リスト 1 は、そうしたイベントのハンドラーの例を示しています。is.available() に注目してください。

リスト 1. InputStream を一度の処理で読み取る
InputStream is = request.getInputStream();
byte[] buf = new byte[512];
do {
    int n = is.read(buf); //can throw an IOException
    if (n > 0) {
        //read n bytes
    } else if (n < 0) {
        //error
        return;
    }
} while (is.available() > 0);

OutputStreamWriter も同じような方法で使うことができます。書き込み操作が終わった後は、必ずすべてのデータをフラッシュする必要があります。そうしないと、次の操作でストリームへの書き込みが行われるまで、そのデータがバッファーに残ってしまいます。


Comet

この記事では、Advanced IO モジュールで NIO をサポートする Tomcat 6 に焦点を当てます。Jetty にも似たような機能がありますが、API が異なります。もちろん、ある 1 つのコンテナーの実装専用のソリューションを作成するようなことは避けたいものです。幸いなことに、来たるべき Servlet 3.0 仕様が見えてきたという良い知らせがあります。Servlet 3.0 仕様がリリースされ、一般的なコンテナーで実装されるようになると、イベント・ベースの NIO をサポートする標準化されたメカニズムができあがります。

当面は、この機能を Tomcat 専用の CometProcessor を使って容易に実現することができます。Servlet 3.0 仕様がリリースされる前であっても、CometProcessor を試してみる価値があります。独自のフレームワークをセットアップするつもりであれば、Tomcat の実装は使いやすいようです。そこでこの記事では Tomcat の実装を使います。Jetty による実装は、既存のフレームワークやソリューションとうまく統合することができるため、技術的な詳細事項に入り込まずに Comet の付加価値を利用したい場合には現実的な選択肢かもしれません。「参考文献」には、WebSphere® Community Edition で使用されている同様の手法を解説した記事へのリンクを挙げてあります。

CometProcessor を Tomcat 6.0 で動作させるためには 2 つの変更が必要です。1 つは構成の変更であり、もう 1 つはコードの変更です。

まず、server.xml ファイルの中にあるコネクターを見つけます。このコネクターは以下のようになっているはずです。

<Connector connectionTimeout="20000" port="8080" protocol="HTTP/1.1" redirectPort="8443"/>

HTTP/1.1 プロトコルを以下のコードで置き換えます。

<Connector connectionTimeout="20000" port="8080" 
	protocol="org.apache.coyote.http11.Http11NioProtocol" redirectPort="8443"/>

2 番目のステップは、サーブレットで org.apache.catalina.CometProcessor インターフェースを実装することです。このインターフェースでは、event() という 1 つのメソッドを実装する必要があります。最初のステップで構成された Http11NioProtocol は、doGet または doPost の代わりに、この event() メソッドを呼び出してリクエストを処理します。

Comet 対応のサーブレットの最も基本的な実装をリスト 2 に示します。このサーブレットは (まだ) 何もしません。

リスト 2. 基本的な Comet イベントを処理する
package eu.adriaandejonge.comet;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;

import org.apache.catalina.CometEvent;
import org.apache.catalina.CometProcessor;
import org.apache.catalina.CometEvent.EventType;

public class CometServlet extends HttpServlet implements CometProcessor {
   
    	public void event(CometEvent event) throws IOException, ServletException {
       		if(event.getEventType() == EventType.BEGIN) {
          			// fill in code handling here
       		}
       		// and continue handing other events
    	}
}

イベントがスローされるのは、接続が開始された時 (BEGIN)、新しいデータが入手可能になった時 (READ)、そして接続が終了した時 (END) です。これは幸運な場合のフローです。エラーが発生した場合には、いくつかのサブイベント・タイプを持つ ERROR イベントが受信されます。例えば、ロギング用に、あるいはトランザクションのロールバックが必要かどうか調べるために、SERVER_SHUTDOWN イベントから TIMEOUT イベントを分離してみると面白いかもしれません。

Comet では、イベントごとに異なる接続タイムアウトを指定することができます。これはつまり、通常のリクエストに対しては存続期間を短く指定できる一方、long-poll リクエストに応答するメカニズムに対しては存続期間を数分に延ばすことができるということです。こうすることで、long-poll に対応するためにすべてのタイムアウトを長くしてしまい、そのため通常のリクエストが本来のように終了されずにハングアップしたままになる、という予期せぬトラブルを防ぐことができます。


Ajax の処理

コード・サンプルを見ると、CometEvent を受信するための処理は通常のリクエストの処理よりもずっと大変なように思われるかもしれません。ある面で、実際そのとおりです。少なくとも、処理の量は増加しそうです。1 つのリクエストが複数のフェーズに分割されるため、1 つの doGet を処理する代わりに、1 つの BEGIN と少なくとも 1 つの READ、そして1 つの END を処理しなければなりません。

このように複雑になる一因は、すべての処理ロジックをキャプチャーしようと大がかりな if 文を持つ event() メソッドを実装してしまうためです (これはよくある誤りです)。問題がすべて if 文の中にあるわけではありませんが、if 文の中のコード量に大部分の問題があります。この手法には欠陥がありますが、その欠陥は単にコードの保守性だけではありません。

if 文では、データが入手可能となったら接続から読み取り、リクエストを即座に処理します。しかしこれは通知のためのメカニズムとしては適切ではありません。その逆、つまりクライアントに新しい情報を送信することによって応答しなければならないイベントが発生するまで接続を開いたままにしておくメカニズムが必要なのです。そのための方法はいくつかありますが、どの方法にも、適切なオブジェクト・モデルを導入するという 1 つの共通点があります。その適切なオブジェクト・モデルでは、if 文のすべてのコードをキャプチャーしようとする代わりに、プログラム・ロジックを処理するのです。

1 つの方法として、中心となるキューを維持し、イベントが発生した場合にはそのキューを一度使うことによってすべての通知を行う方法があります。通知を受けた後、キューに再接続する作業はクライアントが行います。しかし END イベントまたは ERROR イベントの場合には、CometProcessor はその接続をキューから登録解除する必要があります。リスト 3 を見てください。

リスト3. Comet イベントを登録する
package eu.adriaandejonge.comet;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.catalina.CometEvent;
import org.apache.catalina.CometProcessor;
import org.apache.catalina.CometEvent.EventType;

public class CometServlet extends HttpServlet implements CometProcessor {
   
   	private static final long serialVersionUID = 3616604581787849064L;
 
	   private static final String EVENT_REGISTRY = "event.registry";
 
   	private EventRegistry eventRegistry;

   	public CometServlet() {
      eventRegistry = (EventRegistry) this.getServletContext()
      				.getAttribute(EVENT_REGISTRY);                          
   	}    

   	public void event(CometEvent event) throws IOException, ServletException {
      		HttpServletRequest request = event.getHttpServletRequest();
      		HttpServletResponse response = event.getHttpServletResponse();
      		if(event.getEventType() == EventType.BEGIN) {
         			eventRegistry.register(request, response);
      		} else if(event.getEventType() == EventType.END) {
         			eventRegistry.deregister(request, response);
      		} else if(event.getEventType() == EventType.ERROR) {
         			eventRegistry.deregister(request, response);
      		}

   	}   	  	    
}

別の方法として、中心となる ListenerRegistry を保持し、そこにクライアントをイベント・リスナーとしてフックさせる方法があります。最初の方法との違いは、イベント通知の後も接続が開いたままになる点です。NIO を利用すると、後でその接続に戻って、さらにデータを送信することができます。クライアント・サイドでは、標準化された API (Bayeux など) を使うことで、この方法をサポートすることができます。しかしこれは現在 Dojo でのみサポートされており、Jetty の Comet 実装と組み合わせた場合に最も適切に動作します。

基本的に、Comet はサーバー・サイドの手法です。クライアント・サイドでは、リクエストの処理は通常のリクエストを処理する場合とほとんど変わりません。リスト 4 はその一例として、考えられる最も基本的な Ajax リクエストをテストする方法を示しています。これは従来の XMLHttpRequest ベースの例であり、ウィキペディアから引用したものです。この例は Comet サーバーから送信されるイベントに適切に応答することができます。

リスト 4. Ajax リクエストを送信する
<script>
function ajax(url, vars, callbackFunction) {
  var request =  new XMLHttpRequest();
  request.open("POST", url, true);
  request.setRequestHeader("Content-Type",
                           "application/x-javascript;");
 
  request.onreadystatechange = function() {
    if (request.readyState == 4 && request.status == 200) {
      if (request.responseText) {
        callbackFunction(request.responseText);
      }
    }
  };
  request.send(vars);
}

function testFunction(myText) {
	alert("myText = " + myText);
}


</script>

<input type="button" caption="test"
	onclick="ajax('http://localhost:8080/comettest/CometServlet', 
	'', testFunction);">

このことから、Prototype など他のフレームワークを使っても Comet イベントを処理できることがわかります。それでもやはり、Bayeux の仕様を読んで、より正式なイベント交換プロトコルについて学ぶことは、興味深い作業となりますためにも役立ちます。


一般的なパフォーマンス・チューニング

キューまたはイベント・リスナーを CometProcessor と組み合わせて使うと、開かれるスレッドを大幅に減らすことができますが、ブラウザーに返送するためのページを処理する並列スレッドの数を調整するのにはあまり役に立ちません。そのためサーバーには、並行処理が必要な大量のリクエストが相変わらず殺到するかもしれません。CometProcessor インターフェースによって導入されるイベント・ベースのモデルは、パフォーマンス全般の調整をより適切に行えるようにする上でも有用なモデルです。

そのためのソリューションとして、他のことは何もせずに可能な限り素早くイベントを処理し、それらのイベントを WorkerQueue に登録し、そして処理を終了するメカニズムを導入します。それと同時に、ごく少数の WorkerThread を実行させ、それらのスレッドによって WorkerQueue からリクエストを取り出して 1 つずつリクエストを処理します。こうすることで、ブラウザーに提供するためのページを処理する並列スレッドの数が、設定された WorkerThread の数よりも多くなることはありません。

リクエストの数が増加したために、ワーカー・スレッドの数が小さすぎる場合には、そうしたリクエストを処理するための時間が長くなるかもしれません。しかし、サーバーが負荷によってダウンすることは決してありません (あったとしても非常に稀です)。リクエストの処理時間とキューの長さは、サーバーの追加や余分なサーバーのシャットダウンが必要かどうかをシステム管理者が判断するために監視するのに適した対象です。このようなリクエスト処理の方法はクラウド・コンピューティング環境にも最適です。

そうしたハンドラー・メカニズムに Comet イベントを渡すためのコードは、リスト 5 のようになります。この例では実際のキューの処理は省略していますが、その理由は要件ごとにキューの処理方法が異なり、また並行性に関する多くの微調整が必要になるためです。しかしこのコードを見ると、よりオブジェクト指向のソリューションを使って大がかりな if 文を実装する方法がわかるはずです。さらに、EventTypesEventHandler ファクトリー・オブジェクトに接続する Map で最後の if 文の部分を置き換えると、より一層オブジェクト指向になります。しかしこの例でも、全体的な考え方はわかるはずです。

リスト 5. Comet イベントを EventWorker キューに登録する
package eu.adriaandejonge.comet;

import java.io.IOException;

import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.apache.catalina.CometEvent;
import org.apache.catalina.CometProcessor;
import org.apache.catalina.CometEvent.EventType;

public class CometServlet extends HttpServlet implements CometProcessor {

   private static final long serialVersionUID = 365737675389366477L;

   	private static final String EVENT_WORKER = "event.worker";

   	private EventWorker eventWorker;

   	public CometServlet() {
      		eventWorker = (EventWorker) this.getServletContext()
         			.getAttribute(EVENT_WORKER); 
   	}

   public void event(CometEvent event) throws IOException, ServletException {
      		HttpServletRequest request = event.getHttpServletRequest();
		      HttpServletResponse response = event.getHttpServletResponse();
      		if (event.getEventType() == EventType.BEGIN) {
         			eventWorker.enqueue(new BeginEvent(request, response));
      		} else if (event.getEventType() == EventType.READ) {
         			eventWorker.enqueue(new ReadEvent(request, response));
      		} else if (event.getEventType() == EventType.END) {
         			eventWorker.enqueue(new EndEvent(request, response));
      		} else if (event.getEventType() == EventType.ERROR) {
         			eventWorker.enqueue(new ErrorEvent(request, response));
      		}
   	}
}

両方のリクエストに対応する

もっと一般的なシナリオでは、一般的なリクエストと Ajax リクエストの両方を処理する必要があります。一部の Ajax リクエストは即座に処理する必要があり、他の Ajax リクエストは long-poll として処理する必要があり、また通常のリクエストは WorkerQueue に入れて処理する必要があります。

こうした異なる種類のリクエストを処理するために複数のサーブレットを作成し、それぞれに独自の役割を与えることは適切なことです。しかし問題点として、この記事の始めの方で説明したように、全体として HTTP コネクターの接続タイムアウトが同じになってしまいます (少なくとも設定されるタイムアウト値は同じです)。Comet イベントを使うと、リクエストごとに別の接続タイムアウトを指定することができます。このタイムアウト値は動的に計算する必要がありますが、異なるサーブレットに対して異なる値を指定する場合には、この方法が有効です。


まとめ

Ajax リクエストを処理する場合と通常のリクエストを処理する場合の両方でサーバーのパフォーマンスを最適化するには、単に CometProcessor インターフェースを実装し、server.xml ファイルに別のプロトコル・ハンドラーを追加すればよいだけです。しかし、リクエストの処理を過度に複雑にしないためにはソフトウェア開発のスキルが必要であり、キューやイベント・リスナー、ワーカー・スレッド、そしてリクエスト/レスポンスの組み合わせの登録と登録解除などを考慮する必要があります。

独自のフレームワークを開発する場合には、積極的にそうしたことに取り組み、サーバーをより安定した動作で高いパフォーマンスが得られるようにする必要があります。しかし単純な Web アプリケーションを開発する場合には、イベント処理のために複雑なことをするコストの方が、リソースを追加するためのコストよりも高いかもしれません。

単純な Web アプリケーションを開発する場合に最も適切な方法としては、イベント通知リクエストにのみ CometProcessor を使用し、他のすべてのリクエストに対しては従来の doGetdoPost を使うようにします。

Servlet 3.0 の仕様がいつリリースされるのかに注目していてください。Servlet 3.0 では、もっと Web サーバーで NIO を使用するように推奨されるかもしれません。

参考文献

コメント

developerWorks: サイン・イン

必須フィールドは(*)で示されます。


IBM ID が必要ですか?
IBM IDをお忘れですか?


パスワードをお忘れですか?
パスワードの変更

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


お客様が developerWorks に初めてサインインすると、お客様のプロフィールが作成されます。会社名を非表示とする選択を行わない限り、プロフィール内の情報(名前、国/地域や会社名)は公開され、投稿するコンテンツと一緒に表示されますが、いつでもこれらの情報を更新できます。

送信されたすべての情報は安全です。

ディスプレイ・ネームを選択してください



developerWorks に初めてサインインするとプロフィールが作成されますので、その際にディスプレイ・ネームを選択する必要があります。ディスプレイ・ネームは、お客様が developerWorks に投稿するコンテンツと一緒に表示されます。

ディスプレイ・ネームは、3文字から31文字の範囲で指定し、かつ developerWorks コミュニティーでユニークである必要があります。また、プライバシー上の理由でお客様の電子メール・アドレスは使用しないでください。

必須フィールドは(*)で示されます。

3文字から31文字の範囲で指定し

「送信する」をクリックすることにより、お客様は developerWorks のご使用条件に同意したことになります。 ご使用条件を読む

 


送信されたすべての情報は安全です。


static.content.url=http://www.ibm.com/developerworks/js/artrating/
SITE_ID=60
Zone=Web development
ArticleID=348833
ArticleTitle=Tomcat Advanced I/O によるハイパフォーマンス Ajax
publish-date=09302008