レベル: 中級 Taylor Cowan (taylor_cowan@yahoo.com), Senior Software Systems Engineer, Travelocity
2004年 2月 03日 NIOとServlet APIを組み合わせるのは不可能だと思いますか?もう一度考えてみて下さい。この記事では、Java開発者Taylor Cowanが、どのようにproducer/consumerモデルをconsumer nonblocking I/Oに適用するかを教えてくれます。それにより、NIOとの全く新しい互換性へServlet APIを緩和します。話が進んでいくうちに、NIOを実装する実際のServletベースのウェブサーバーを構築するのに何をしなければいけないのか、また、エンタープライズ環境で標準のJava I/Oサーバー(Tomcat 5.0)に対してそのサーバーがどのように比べられるか知るでしょう。
NIOは、JDK 1.4を備えたJavaプラットフォームへの最も有名な(最も魅惑的でないにしても)追加でした。多くの記事もまた、NIOの基本について、そしてどのようにnonblockingチャンネルの利点を向上させるかを説明しています。しかしながら、これらの記事に欠けているものが1つあります。それはどの様にNIOが、J2EE Web tierのスケーラビリティを向上できるかという適切なデモンストレーションでした。エンタープライズ開発者にとってこの情報は特に重要なものです。なぜならNIOを実装するということは幾つかのインポートステートメントを新しいI/Oパッケージに変えるというように簡単にはいかないからです。最初に、Servlet APIはblocking I/O セマンティックだと想定します。したがって、それはデフォルトによってnonblocking I/Oを都合のいいように利用することができません。次に、スレッドはリソースをJDK 1.0で使っていたほど使わないので、より少数のスレッドで対処しても、必ずしもより多くのクライアントを扱うことができるというサーバーの能力を示しません。
この記事では、NIOを実装するServletベースのウェブサーバーを作成するためにnonblocking I/Oに対するServlet APIの嫌悪感をどう対処するかを学ぶことができるでしょう。その後、どのようにServletベースのウェブサーバーをmultiplexed(多重化された)ウェブサーバー環境にある標準の I/O server (Tomcat 5.0)を基準に能力を計るか分るでしょう。企業で実際に起こっている問題として、すごい勢いで増えているクライアントがそれらのソケット接続を保持する場合、NIOが標準のI/Oにどのように匹敵するかに注目します。
この記事は、Javaプラットフォーム上のI/Oプログラミングの基本をよく理解しているJava開発者向けですので注意して下さい。nonblocking I/Oへの入門に関しては、参考文献を参照してください。
スレッドの割り当て
スレッドは、高価(リソースを沢山使う)であるという評判を持っています。Javaプラットフォーム(JDK 1.0)の初期の時期で、スレッド・オーバーヘッドが負担であったので、開発者はカスタム・ビルド解決策を強いられました。共通の1つの代替手段は個々の新しいスレッドを要求時に作成するのではなく、VMスタートアップで作成されたスレッドのプールを使用することでした。VM層でスレッドのパフォーマンスが最近改善されたにもかかわらず、標準のI/Oは1つのソケットオープンに対して1つのスレッドを提供する必要があります。これは、短期的にはうまくいきます。しかし、スレッドの数が1Kを越えて増加する場合、標準の I/Oはうまく機能しなくなります。CPUは、単にスレッド間のコンテキスト・スイッチングによって過重負担させられるようになります。
JDK 1.4にNIOを導入したことによって、エンタープライズ開発者はやっとthread-per-useモデルの組み込みの解決策を手に入れました: 多重化されたI/Oは決まった数のスレッドにより、ますます増加するユーザーにサービスすることを可能とします。
多重化とは、単一のキャリアーに関して同時に多数のシグナルまたはストリームを送ることを言います。携帯電話を使用する時に多重化の例が生じています。無線の周波数は稀少なリソースです。したがって、無線の供給者は、単一周波数で多数の呼び出しを送るために多重化を使用します。例えば、呼び出しはセグメントに分割され、そして各々のセグメントはデータを伝達するために非常に短い時間を与えられ、受け側は分割されて送られてきたセグメントを組み立てます。これは時分割多重方式、あるいはTDMと呼ばれます。
NIOの内では、受け側は、セレクター(seejava.nio.channels.Selectorを参照すること)に匹敵します。呼び出しの代わりに、セレクターは多数のオープンソケットを扱います。ちょうどTDMでのように、selectorは多数のクライアントから書かれているデータのセグメントを再組み立てます。これは、サーバーが単一のスレッドで多数のクライアントを管理することを可能にします。
Servlet API と NIO
Nonblockingの読み書きは、NIOにとって非常に重要ですが、それらが問題を全て解決するということではありません。nonblockingの読み込みは、それがブロックしないという事実以外は呼び出し元に対する保証をしません。クライアントまたはサーバーアプリケーションは完全なメッセージ、部分的なメッセージあるいは全くメッセージを読まないかもしれません。あるいは、何も読み取らないかもしれません。一方、読み込んだnonblockingは必要以上に読み込み、次の呼び出しの為のバッファーまで使用してしまうかもしれません。最後に、ストリームとは異なり、0バイト読み取りはメッセージが完全に受け取られたことを示しません。
これらが原因で、プーリングのない単純なreadlineメソッドでさえ実装することを不可能にします。すべてのservletコンテナーは、input streamにreadlineメッソドを提供しなければいけません。その結果、多くの開発者がNIOを実装するServletベースのウェブアプリケーションサーバーを構築することに見切りをつけました。しかし幸運にも、Servlet APIの能力およびNIOの多重化I/Oを組み合わせるという解決策がありました。
続くセクションでは、java.io.PipedInputとPipedOutputStreamclasses を使用して、どのようにproducer/consumerモデルをconsumerである nonblocking I/Oに適用するかを学習します。nonblockingチャンネルが読まれると、それは、次のスレッドによって消費されているパイプに書かれています。この分解では殆んどのJavaベースのクライアントサーバアプリケーションと違うかたちでスレッドをマップします。ここにはnonblockingチャンネル(producer)の処理を単独で担当するスレッド、および、データをストリーム(consumer)として消費することを単独で担当するスレッドを持っています。I/Oを消費するとともに、servletがblocking セマンティクスと仮定するので、パイプはアプリケーションサーバーの為にnonblocking I/O問題の緩和もします。
サンプルのサーバー
サンプルサーバーは、Servlet APIおよびNIOの非互換性のproducer/consumer解決策を実証します。サーバは、完全なNIOベースのアプリケーション・サーバーのために概念の証拠を提供するServlet APIに十分に似ています。また、それは特に標準のJava I/OとNIOの性能とを比較するために書かれました。それは単純なHTTPget リクエストを処理し、またクライアントからのキープアライブ接続をサポートします。サーバーが多くのオープンソケット接続を扱うことを要求される場合のみ、多重化I/Oが有益であるので、これは重要です。
サーバーは2つのパッケージ、org.sse.serverとorg.sse.httpに分割されます。サーバー・パッケージは、リクエストを処理するワーカースレッドを作成したり、メッセージを読んだり、新しいクライアント接続を受け取るような主要なサーバー機能を提供するクラスを保持しています。httpパッケージは、サブセットであるHTTPプロトコルをサポートします。HTTPの詳細な説明は、この記事の範囲外です。実装の詳細に関しては参考文献からコード例をダウンロードしてください。
さて、org.sse.serverパッケージで最も重要なクラスを見てみましょう。
サーバークラス
サーバー・クラスはNIOベースのサーバーの中心となるマルチプレクサー・ループを保持しています。リスト1では、select()の呼び出しは、サーバーが新しいクライアントを受け取るか、あるいはオープンソケットに利用可能なバイトが書かれていることを検知するまでブロックします。これと標準のJava I/Oの間の主な差は、データがすべてこのループ内に読まれるということです。これと標準のJava I/Oの主な差は、すべてのデータがこのループ内で読まれるということです。通常は、新しいスレッドは、特定のソケットからバイトを読むタスクを与えられます。NIOセレクター・イベントドリブン・アプローチを使用することにより、単一のスレッドで何千ものクライアントを扱うことは現実に可能ですが、後でスレッドが持っている役割を見ていきましょう。
select()を呼ぶごとに、新しいクライアントが利用可能であること、新しいデータの読む込み準備完了、あるいは、クライアントがレスポンスを受け取る準備ができているかを示すイベントのコレクションを返します。サーバーのhandleKey()メソッドは、新しいクライアント(key.isAcceptable())か、受信データ(key.isReadable())にしか興味を持っていません。そのポイントでは、作業はServerEventHandlerクラスに移ります。
リスト 1. Server.java のselector loop
public void listen() {
SelectionKey key = null;
try {
while (true) {
selector.select();
Iterator it = selector.selectedKeys().iterator();
while (it.hasNext()) {
key = (SelectionKey) it.next();
handleKey(key);
it.remove();
}
}
} catch (IOException e) {
key.cancel();
} catch (NullPointerException e) {
// NullPointer at sun.nio.ch.WindowsSelectorImpl, Bug: 4729342
e.printStackTrace();
}
}
|
ServerEventHandler クラス
ServerEventHandlerクラスはサーバーイベントに応答します。新しいクライアントが利用可能になる時、そのクライアントの状態を表わす新しいクライアント・オブジェクトをインスタンス化します。データはnonblocking方式にあるチャンネルから読み込まれ、クライアントオブジェクトに書き込まれます。ServerEventHandlerは、さらにリクエストのキューを維持します。ワーカースレッドの可変数はキューからのリクエストを処理するために生成されます。従来のproducer/consumer方式では、キューが空になる場合、スレッドが閉鎖するように、また新しいリクエストが利用可能な時通知されるように、キューは書かれています。
Listing 2で、remove()メソッドは待っているスレッドをサポートするためにオーバーライドされました。リストが空の場合、待っているスレッドの数はインクリメントされ、また、現在のスレッドはブロックされます。これは本質的に非常にシンプルなスレッドプールを提供します。
リスト2. Queue.java
public class Queue extends LinkedList
{
private int waitingThreads = 0;
public synchronized void insert(Object obj)
{
addLast(obj);
notify();
}
public synchronized Object remove()
{
if ( isEmpty() ) {
try { waitingThreads++; wait();}
catch (InterruptedException e) {Thread.interrupted();}
waitingThreads--;
}
return removeFirst();
}
public boolean isEmpty() {
return (size() - waitingThreads <= 0);
}
}
|
ワーカースレッドの数はウェブ・クライアントの数に依存しません。1つのオープンソケットに1つのスレッドを割り当てる代わりに、1セットのRequestHandlerThreadインスタンスによってサービスされた総括的なキューにリクエストをすべて入れます。理想では、スレッドの数は、プロセッサーの数および各リクエストの長さか、持続時間に基づいて調整されるべきです。リクエストがリソースまたは処理のニーズ経由で長い時間がかかる時、サービスの質はより多くのスレッドを加えることにより改善することができます。
これが必ずしも全面的な処理能力を改善しないことに注意してください。しかし、それはユーザの経過時間を改善します。大きな負担がかかっている時でさえ、各スレッドは少しの処理時間を与えられます。この原理は、標準のJava I/Oに基づいたサーバーに等しく当てはまります。しかしながら、それらのサーバーは、1つのオープンソケット接続に1つのスレッドを割り当てることを要求される際に制限されています。NIOサーバーはこれが取り除かれ、その結果、多くのユーザに適応できます。言える事は、NIOサーバーもスレッドを必要とするが、それ程多くはいらないということです。
処理のリクエスト
クライアントクラスは2つの目的に役立ちます。最初に、それは受信nonblocking I/OをServlet APIによって消費可能なblockingInputStreamに変換することにより、blocking/nonblocking問題を解決します。次に、それは、特定のクライアントのリクエスト状態を管理します。nonblockingチャンネルはメッセージが完全に読み込まれた時に終了報告をしないので、これをプロトコル層で処理することを強いられます。クライアントクラスは、それが現在進行中のリクエストに関連したら、いつであろうと報告します。それは新しいリクエストを扱う準備ができる場合、write()メソッドはリクエスト処理用のクライアントをエンキューします。それがリクエストに既に従事している場合、それは単にPipedInputStreamとPipedOutputStreamのクラスを使用して、受信バイトをInputStreamに変換します。
図1は、パイプのまわりの2つのスレッドの相互作用を示します。メインスレッドは、パイプにチャンネルから読み込んだバイトを書き込みます。パイプは、同じデータをInputStreamとしてconsumerへ渡します。パイプの別の重要な特徴は、それがバッファーされるということです。もしそうでなければ、メインスレッドはパイプに書き込もうとする時ブロックされなければならないでしょう。メインスレッドはクライアント間に多重化する責任があるので、ブロックすることを認める余裕がありません。
図1. PipedInput/OutputStream
クライアントがそれ自体をエンキューした後、それはワーカースレッドによって消費される準備ができます。RequestHandlerThreadクラスはこの役割を引き受けます。これまでのところ、メインスレッドが、新しいクライアントを受けるか、新しいI/Oを読み込むかしながら、どのように連続的にループするかを確認しました。ワーカースレッドは新しいリクエストを待ちながらループします。クライアントがリクエスト・キューで利用可能になる時、それは、remove()メッソド上でブロックされた、一番目に待っているスレッドによって直ちに消費されます。
リスト 3. RequestHandlerThread.java
public void run() {
while (true) {
Client client = (Client) myQueue.remove();
try {
for (; ; ) {
HttpRequest req = new HttpRequest(client.clientInputStream,
myServletContext);
HttpResponse res = new HttpResponse(client.key);
defaultServlet.service(req, res);
if (client.notifyRequestDone())
break;
}
} catch (Exception e) {
client.key.cancel();
client.key.selector().wakeup();
}
}
}
|
その後、スレッドは新しいHttpRequestおよびHttpResponseインスタンスを作成し、デフォルトservletのサービスメッソドを起動します。HttpRequestがクライアント・オブジェクトのclientInputStreamプロパティーで構成されることに注目してください。これは、nonblocking I/Oをblockingストリームに変換する責任を持つPipedInputStreamなのです。
このポイントから、リクエスト処理は、J2EE Servlet APIに期待するものに似ています。servletへの呼び出しが返る時、ワーカースレッドは別のリクエストが同じクライアントから利用可能かどうかプールに戻る前にチェックします。プールという単語が軽くここに使用されることに注意してください。実際、スレッドは、キュー上の別のremove()の呼び出しを試みて、次の利用可能なリクエストまでブロックされます。
例題のサーバーを動かす
サンプルのサーバーは、HTTP 1.1プロトコルのサブセットを実装しており、一般的なHTTP getリクエストを処理します。それは2つのコマンドライン引き数をとります。1つ目はポート番号を指定し、そして2つ目はHTMLファイルが存在するディレクトリーを指定します。ファイルを解凍した後、cdコマンドでプロジェクト・ディレクトリーへ移動し、下記コマンドを実行して、webrootディレクトリーをあなたのものに変えて下さい。
java -cp bin org.sse.server.Start 8080
"C:\mywebroot"
|
さらにサーバーがディレクトリー・リストを実装しないことに注意してください。したがって、あなたのwebroot下のファイルを指す有効なURLを指定しなければなりません。
パフォーマンス結果
サンプルのNIOサーバーは重い負荷の下でTomcat 5.0と比較されました。それが標準のJava I/Oに基づいた100パーセントのJavaソルーションであるので、Tomcatが選ばれました。いくつかの高機能なアプリケーションサーバーは、スケーラビリティを改善するためJNIネイティブコードで最適化されているため、標準のI/OとNIOの間のよい比較ができません。目的は、どのような状況化でもNIOが重量なパフォーマンスの利点を与えるかどうか測定することでした。
以下に、仕様があります。
- サンプルのサーバーは4つのワーカースレッドだけで実行できたが、Tomcatは最大2000ものスレッドで構成されました。
- 各サーバーは、ほとんどテキストコンテントから成るシンプルなHTTP
getに対してテストされました。
- 負荷テストツール(Microsoft Web Application Stress Tool)は、1人のユーザに対しておよそ1つのソケットとなるよう、"keep-alive"セッションを使用するのに準備されました。NIOサーバーが一定数のスレッドで同じロードを扱っている一方、Tomcat上では1人のユーザ当たり1つのスレッドを使用するという結果となりました。
図2は、増加する負荷の下での1秒あたりのリクエストの割合を示します。200人のユーザでは、パフォーマンスは類似していました。しかしながら、ユーザの数が600を超過するとともに、Tomcatのパフォーマンスは急激に下がり始めました。これは、非常に多くのスレッド間のコンテキストスイッチングのコストにより起きた現象の様です。対照的に、NIOベースのサーバーのパフォーマンスは直線的に下がりました。NIOサーバーが4つのワーカースレッドだけで構成された一方、Tomcatが1人のユーザ当たり1つのスレッドを割り当てなければいけないことを心に留めておいてください。
図2. 1秒あたりのリクエスト
図3は、NIOのパフォーマンスの表示を提供しています。それは、1分当たりに起こるソケット接続のエラーの数を示します。再び、NIOベースのサーバーのエラー割合が比較的低いままだった一方、Tomcatのパフォーマンスは約600人のユーザで徹底的に下がりました。
図3. 1秒あたりのソケット接続エラー数
結論
この記事では、nonblocking機能を使用可能にしても、NIOを使用して、Servletベースのウェブサーバーを書くことが確かに可能であることを知りました。NIOがエンタープライズ環境の中で標準のJava I/Oより適応するので、これはエンタープライズ開発者にとって良いニュースです。標準Java I/Oとは違い、NIOは決まった数のスレッドで多くのクライアントを扱うことができます。ServletベースのNIOウェブサーバーは、クライアントがソケット接続を維持したり持ったりといった扱いをする時、よりよいパフォーマンスをもたらします。
参考文献
著者について  | |  | Taylor CowanはJ2EEを専攻するソフトウェア・エンジニアであり、フリーランス著者です。ジャズ・アレンジング学科で音楽学士号を取得したのと同様に、ノース・テキサス大学ではコンピューター・サイエンス学科で修士号を受け取りました。 |
記事の評価
|