解説(後半)
IO多重化の得失
Selectorを利用すると、IOを同時に複数実行することができます。これにより、通信のように実行に時間がかかるIOを行う場合には、処理効率を向上させることが期待できます。というのは、Selectorを利用しない従来のJavaのIOモデルで複数のIOを実行するには、複数のスレッドを利用するしかないからです。
ただし、複数のスレッドを利用することと、Selectorを利用してIOを多重化することには、それぞれ長所と短所があるため、その特質を理解したうえで必要に応じて使い分ける必要があります。
次の表は、マルチスレッドで同時に複数のIOを実行する場合と、Selectorを利用して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オブジェクトを引数に利用します。
次のリストは、リクエストハンドラ内のファイルをクライアントへ転送する処理の抜粋です。
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を呼び出しても、関連するFileChannelとFileInputStreamの両方のオブジェクトをクローズできます。
ByteBufferは、バイト単位で読み書きするためのBufferです。実際にNIOを利用する場合は、あらかじめNIOで出力されたデータの読み込みを除けば、ここでの例のようにByteBufferを利用することになると思います。
Bufferには、3種類の位置情報があります。ByteBufferの場合、位置情報はバイト単位となります。
- position
- capacity
- 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のためデータを読むことができません。しかし、次のリストの先頭でこの状態は解除されます。
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までを転送対象とするからです。
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となるため、SelectionKeyとSocketChannelをクローズして処理を終了します。
このメソッドを退出した時点では以下のいずれかの状態となります。
SelectionKeyとSocketChannelがクローズされているfileBufferフィールドにまだ送信すべきデータが残っている
SelectionKeyが廃棄される。SelectionKey#isWritableが真になれば、再度上のリストが呼ばれる。 後者の場合、fileBufferのremainingメソッド呼び出しの戻り値は0より大きくなるため、続き(現在のpositionの位置)から、データの送信が継続されます。
まとめ
NIOを利用すると、非ブロック接続を利用した多重IO処理を実装できます。
多重IO処理は、プログラムが複雑になるというデメリットがあり、またOSの動作に依存した処理となるため考慮/知識不足からバグも入りやすい、どちらかと言うと難しいプログラムです(正直なところ、このプログラムの無謬性についてはそれほど自信はありません。記事という性格上、エラーチェックもテストも甘いからです)。しかし、正しく使いこなせば、高速かつコンパクトで、スケーラブル(1000個のスレッドを同時に実行するのに比較すれば1000個のSelectionKeyを同時にセレクトするほうがシステムに対する負荷は小さく済むからです)なサーバーアプリケーションを作ることができます。しかも、難しいといっても、Cで直接ネイティブAPIやネイティブスレッドを制御するのに比較すれば遙かに安全です。
NIOには他にもダイレクトバッファ(ByteBuffer.allocateの代わりにByteBuffer.allocateDirectを呼び出して、Javaのヒープではなくネイティブなメモリーを直接利用することでメモリー間の転送オーバーヘッドを抑制します。ただし、実測すると分かりますが、相当な規模でなければ効果を得ることはできません)、同一チャネルへの複数のスレッドからの同時呼び出し(例えばreadとwriteを同時に操作すること)など、高速化に寄与する種々の機能があります。
NIOは万人が必要とするAPIではないため、資料や説明などに乏しい面がありますが、チャレンジしがいがある領域ですので、サーバーアプリケーションなどを作る場合には利用を検討してみてください。
