SHOEISHA iD

※旧SEメンバーシップ会員の方は、同じ登録情報(メールアドレス&パスワード)でログインいただけます

DeveloperZine(デベロッパージン)- エンジニアの意思決定を支える技術情報メディア ProductZine

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

Javaによる簡易HTTPサーバーの作成

NIOの非ブロック接続を利用した多重IOの実装

Javaによる簡易HTTPサーバーの作成 第4回


解説(後半)

IO多重化の得失

 Selectorを利用すると、IOを同時に複数実行することができます。これにより、通信のように実行に時間がかかるIOを行う場合には、処理効率を向上させることが期待できます。というのは、Selectorを利用しない従来のJavaのIOモデルで複数のIOを実行するには、複数のスレッドを利用するしかないからです。

 ただし、複数のスレッドを利用することと、Selectorを利用してIOを多重化することには、それぞれ長所と短所があるため、その特質を理解したうえで必要に応じて使い分ける必要があります。

 次の表は、マルチスレッドで同時に複数のIOを実行する場合と、Selectorを利用してIOを多重化する場合の得失について示したものです。

マルチスレッドとIO多重化の得失
項目マルチスレッドIO多重化
プログラムの流れ簡潔複雑
IOごとに異なる処理明解複雑
同期の考慮必要不要
必要となるリソース

 マルチスレッドを利用する場合、IOの1つの単位(基本的にはStreamオブジェクト)の制御は個々のスレッドによって実行されます。つまり極論すれば、Runnable#runメソッドの中でオープン-読み書き-クローズすることができます。そのため、IOに関するプログラムの処理の流れが素直に表現できます。また、必要に応じて個々のスレッドと実行すべき処理を一致させることで、複数種類のオペレーションを記述することもそれほど困難ではありません。

 それに対して、IOを多重化した場合には、プログラムの中心は、すべてのIOを集約したSelectorに対する呼び出しとなり、そこから細切れに個々のIO処理が呼び出されることになります。これは個々のIO単位の処理の流れが追いにくくなる原因となります。また、IOが細切れで呼び出されることでプログラム上の分岐が多くなることから、結果に応じて異なる処理が行われるようなプログラムは、さらに書きにくくなります。

 その一方で、マルチスレッドの場合、プログラム上の単一のリソースをアクセスする場合には同期の考慮が必要となり、またOSの実装に依存するとは言え、スレッドはOSの実行スケジュールなどに影響するため、極端に多数のスレッドを生成するとシステムに対して大きな負荷となります。そのため、負荷を適正なバランスに保つために、スレッドプーリングのような実行制御や、同時実行可能なスレッド数を超えた場合のエラー管理などを考慮する必要があります。

 それに対して、IOを多重化した場合には、単一のスレッドで順番にIOを処理すれば良いため同期の考慮が不要なことや、スレッドの多重化ほどはシステムに対する負荷とはならないため、許容可能な最大数が大きく取れるといったメリットが得られます。

 なお、最も効率を高めるには、IOを多重化して、かつそのIOを複数のプールされたスレッドから扱うように実装します。この場合、IO多重化の複雑な面と、マルチスレッドによる同期の考慮の両方が必要となるため、プログラムの難易度は極めて高いものとなります。

Bufferを利用したIO

 NIOのチャネルを利用したread/writeでは、Bufferオブジェクトを引数に利用します。

 次のリストは、リクエストハンドラ内のファイルをクライアントへ転送する処理の抜粋です。

Reqeustのファイル転送処理(チャネルの取得とBufferの用意)
FileChannel file;
ByteBuffer fileBuffer;

void response(SelectionKey key, File f) throws IOException {
    file = new FileInputStream(f).getChannel();
    fileBuffer = ByteBuffer.allocate(FILE_BUFF_SIZE);
    fileBuffer.limit(0);
    responseSuccess(key, (int)f.length(), "text/html");
}

 FileChannelも他のNIOのオブジェクト同様、newを利用しません。代わりにFileInputStream#getChannelメソッドで取得します。なお、どちらも明示的なcloseメソッドの呼び出しが必要なオブジェクトですが、FileChannel#closeを呼び出しても、FileInputStream#closeを呼び出しても、関連するFileChannelFileInputStreamの両方のオブジェクトをクローズできます。

 ByteBufferは、バイト単位で読み書きするためのBufferです。実際にNIOを利用する場合は、あらかじめNIOで出力されたデータの読み込みを除けば、ここでの例のようにByteBufferを利用することになると思います。

 Bufferには、3種類の位置情報があります。ByteBufferの場合、位置情報はバイト単位となります。

  • position
  • 最後のIOが設定した位置です。例えば、read後のpositionは読み込んだ最後のデータの位置+1となります。write後は、出力先へ転送した最後のデータの位置+1です。
  • capacity
  • このBufferの大きさです。
  • limit
  • このBuffer内の有効データの最終位置+1です。読み込み/書き込みのいずれもlimitを超える操作はエラーとなります。

 ここでは、limitに0を設定しています。これは、FileChannelからのreadが必要かどうかの判断にBuffer#remainingメソッドを利用しているからです。Buffer#remainingメソッドは、limitとpositionの差を返します。生成直後のBufferのpositionは0、limitはcapacity(従ってここではFILE_BUFF_SIZE)です。このため、実際にはデータがまったく入っていないにもかかわらず、Buffer#remainingの結果はFILE_BUFF_SIZEと等しくなってしまいます。そのため、limitに0を設定することでデータが入っていない状態を示しています。

 もちろん、この状態でFileChannel#readを呼び出すと、limitが0のためデータを読むことができません。しかし、次のリストの先頭でこの状態は解除されます。

Reqeustのファイル転送処理(チャネルからの読み込みと送信準備)
boolean prepareBuffer(SelectionKey key) throws IOException {
    fileBuffer.clear();
    if (file.read(fileBuffer) < 0) {
        file.close();
        file = null;
        fileBuffer = null;
    } else {
        fileBuffer.flip();
    }
    return fileBuffer != null && fileBuffer.remaining() > 0;
}

 Request#prepareBufferは、FileChannelからBufferへの読み込み処理を実行するメソッドです。

 最初に呼び出しているBuffer#clearは、limitをcapacityに設定し、positionを0に設定するメソッドです。名前と異なり、バッファそのもののデータをクリアするわけではありません。

 このメソッドの呼び出しは、最初の読み込み(limitは0に設定されています)および、読み込んだデータをすべてクライアントへ送信した後(positionとlimitが等しい状態)のいずれかの時点で行われます。そのため最初にBuffer#clearを呼び出して、FileChannel#readへ与えることが可能な状態にします。

 FileChannelからBufferへのデータ転送はFileChannel#readメソッドで実行します。EOF状態であれば負値が返るため、FileChannel#closeを呼び(同時に最初に作った無名のFileInputStreamもクローズされます)、後続の処理が処理の終了を認識できるようにフィールドをnullに設定します。

 一方、読み込みに成功した場合には、Buffer#flipメソッドを呼び出します。Buffer#flipメソッドは、potionを0に設定し、limitを元のpositionに設定します。例えば、10バイトのデータをreadした場合、該当Bufferのpositionは10を指します。limitはcapacityを指したままです。この状態でflipメソッドを呼ぶと、limitは10に、positionは0にそれぞれ変わります。そのため、このBufferをそのままチャネルのwriteメソッドに与えることができるようになります。なぜなら、チャネルのwriteメソッドは、Buffer内のデータをpositionの位置からlimitの位置-1までを転送対象とするからです。

Reqeustのファイル転送処理(送信)
if (fileBuffer != null) {
    if (fileBuffer.remaining() > 0) {
        channel.write(fileBuffer);
    }
    while (fileBuffer.remaining() == 0) {
        if (!prepareBuffer(key)) {
            close(key);
            return;
        }
        channel.write(fileBuffer);
    }
}

 上のリストは、Requeste#readyToWriteメソッドからファイル転送処理の部分を抜粋したものです。最初に、既にファイルの送信が完了しているか(あるいはエラーなどの理由によりファイル転送を行う必要がないか)を判定します。ここで送信が必要であると判断されると、次にBuffer#remainingを呼び出して、まだ送信すべきデータがBufferに残っているかをチェックします。残っていればSocketChannel#writeを呼び出して送信を開始します。この時、もしバッファフルあるいはクライアントからのACK待ちなどの理由で送信できない場合は、非ブロック接続のためremainingは変わらずに戻ります。

 次に、Buffer#remainingが0の間(つまりBufferの内容をクライアントへ送信できている間)、継続してクライアントへデータを送るループに入ります。このループの先頭地点ではBuffer内には送信すべきデータは残っていません。具体的にはpositionとlimitが等しくなっています。従って、Request#prepareBufferを呼び出してFileChannelからデータを読み込みます。もしFileChannelがEOFを返した場合、Request#prepareBufferの戻り値はfalseとなるため、SelectionKeySocketChannelをクローズして処理を終了します。

 このメソッドを退出した時点では以下のいずれかの状態となります。

  • SelectionKeySocketChannelがクローズされている
  • 次回のセレクト時に該当SelectionKeyが廃棄される。
  • fileBufferフィールドにまだ送信すべきデータが残っている
  • 次回のセレクト時にSelectionKey#isWritableが真になれば、再度上のリストが呼ばれる。

 後者の場合、fileBufferremainingメソッド呼び出しの戻り値は0より大きくなるため、続き(現在のpositionの位置)から、データの送信が継続されます。

まとめ

 NIOを利用すると、非ブロック接続を利用した多重IO処理を実装できます。

 多重IO処理は、プログラムが複雑になるというデメリットがあり、またOSの動作に依存した処理となるため考慮/知識不足からバグも入りやすい、どちらかと言うと難しいプログラムです(正直なところ、このプログラムの無謬性についてはそれほど自信はありません。記事という性格上、エラーチェックもテストも甘いからです)。しかし、正しく使いこなせば、高速かつコンパクトで、スケーラブル(1000個のスレッドを同時に実行するのに比較すれば1000個のSelectionKeyを同時にセレクトするほうがシステムに対する負荷は小さく済むからです)なサーバーアプリケーションを作ることができます。しかも、難しいといっても、Cで直接ネイティブAPIやネイティブスレッドを制御するのに比較すれば遙かに安全です。

 NIOには他にもダイレクトバッファ(ByteBuffer.allocateの代わりにByteBuffer.allocateDirectを呼び出して、Javaのヒープではなくネイティブなメモリーを直接利用することでメモリー間の転送オーバーヘッドを抑制します。ただし、実測すると分かりますが、相当な規模でなければ効果を得ることはできません)、同一チャネルへの複数のスレッドからの同時呼び出し(例えばreadとwriteを同時に操作すること)など、高速化に寄与する種々の機能があります。

 NIOは万人が必要とするAPIではないため、資料や説明などに乏しい面がありますが、チャレンジしがいがある領域ですので、サーバーアプリケーションなどを作る場合には利用を検討してみてください。

参考資料

この記事は参考になりましたか?

連載通知を行うには会員登録(無料)が必要です。
既に会員の方はを行ってください。
Javaによる簡易HTTPサーバーの作成連載記事一覧

もっと読む

この記事の著者

arton(アートン)

専門は業界特化型のミドルウェアやフレームワークとそれを利用するアプリケーションの開発。需要に応じてメインフレームクラスから携帯端末までダウンサイジングしたりアップサイジングしたりしながらオブジェクトを連携させていくという変化に富んだ開発者人生を歩んでいる。著書に『Ruby③ オブジェクト指向とはじめての設計...

※プロフィールは、執筆時点、または直近の記事の寄稿時点での内容です

この記事は参考になりましたか?

この記事をシェア

CodeZine(コードジン)
https://codezine.jp/article/detail/358 2006/06/14 18:57

イベント

CodeZine編集部では、現場で活躍するデベロッパーをスターにするためのカンファレンス「Developers Summit」や、エンジニアの生きざまをブーストするためのイベント「Developers Boost」など、さまざまなカンファレンスを企画・運営しています。

新規会員登録無料のご案内

  • ・全ての過去記事が閲覧できます
  • ・会員限定メルマガを受信できます

メールバックナンバー