Don't Repeat Yourself

Don't Repeat Yourself (DRY) is a principle of software development aimed at reducing repetition of all kinds. -- wikipedia

java.nio を使って簡単な HTTP サーバーを作ってみる: ノンブロッキングなHTTP サーバー

先日,LINE の Meetup in Tokyo #28 に参加してきました.そのときに,Netty を作っている Apple の方が話をしていたのですが,彼の話す ByteBufferChannelSelector などの単語がよくわからず,帰ってから気になって調べてみました.そこで,java.nio (New I/O)というパッケージが引っかかりました.どうやら,TCPUDP を用いた各種 I/O を扱っているライブラリのようでした.

ドキュメントなどを読んでみると,以前 HTTP サーバーを作ったときのように,java.nio を使った HTTP サーバーを作れそうだったので実際に作ってみました.Netty も nio をベースに作られています

今回の記事では,java.nio の主要な概念を解説すると同時に,その裏側にある「ノンブロッキング I/O (Non-blocking I/O)」についても簡単に整理をしたいと思います.そして,それらを利用して GET リクエストに対してレスポンスを返す簡単な HTTP サーバーを実装します.

ノンブロッキングは近年Web 業界でも注目の概念であり,私が作っている広告配信の RTB サーバーでも,ノンブロッキングを選択するかどうかでスループットとレイテンシに影響を与えることがあるくらい重要な概念です.もちろん,その分実装はより複雑になり,理解する必要のあることも多いですが.

なお,HTTP サーバーの方の実装は見よう見まねで適当に行ったものなので,間違っている部分や改善点がある場合は,ぜひ PR を送っていただけると嬉しく思います.また,HTTP サーバーの構築に関係のない概念やクラスなどについては一切説明を省いていますので,あらかじめご了承ください.

目次

java.nio とは?

java.nio (以下,nio と略記します) は,JDK 1.4 から導入されたより高機能な IO を提供する Java のパッケージです.通常の java.io と比べると複雑ですが,高機能な I/O を行うことができます.

java.io は,バイトストリーム (byte streams) や文字列ストリーム (character streams) を使用するストリーム指向の I/O でした.一方で java.nio は,チャネル (channels) とバッファ (buffers) という概念を用いて I/O を行います.read/write の際,常にチャネルにバッファを送ることでやり取りします.チャネルは,バッファに対して read/write を非同期で行うことのできるものです.

チャネルは1つのスレッドに対して複数立てることができます.スレッドの起動や切り替えは,OS にとってはなかなか負荷のかかる作業であり,それをできるだけ発生させないようにできる点でチャネルは優れています.後述するように,1つのスレッド内に生成されたチャネルは,セレクタ (selectors) によって監視・管理されています.

バッファは,read/write できるメモリブロックを表現したものです.データをある程度まとめて入れておくことのできる存在です.nio では,Buffer をラップしたオブジェクトを用いて,メモリブロックに対する操作を簡単に行うことができるようになっています.詳しくは後述します.

ブロッキング・ノンブロッキングとは?

「ノンブロッキング(non-blocking)」という言葉は「非同期(asynchronous)」という言葉と似たような文脈で用いられることが多く,海外のドキュメントを読んでいても意図的なのか無意識なのか,混同して使用している例が多々見受けられました.

また,システムコール側から理解しようと strace するなどして追ってはみたのですが,nio では sendto が呼び出されているようだということまでしかわかっておらず [*1],確信をもてませんでした.

よく言われる話として紹介しておくと,read あるいは writeシステムコールにノンブロッキング用のオプションをつけて実行すると,ノンブロッキングかつ同期の I/O を行うことができます.また,POSIXaio というシステムコールも存在しており,こちらは実行するとノンブロッキングかつ非同期な I/O を行うことができます.

詳しい解説は日本語でいくつか記事が書かれているようですので,そちらを参考にしていただくと雰囲気がつかめるかとは思います (この件はまた別の記事にしたいと思います).

nio 利用するにあたって,さしあたりブロッキング/ノンブロッキングのざっくりした理解をするならば,

  • ブロッキングでは,read/write をすべて完了するまで,スレッドは別の処理を行うことができない.
  • ノンブロッキングにすると,read/write をすべて完了していなくても,スレッドは次の処理に移っていくことができる.

の2点をおさえておけばいいと思います.nio は,1スレッド内で複数の処理をセレクタ・チャネルを用いて走らせることに重きを置いていますので,スレッドをブロックしてしまうとそれがスループットの低下にそのままつながってしまいます.それを回避するために,ノンブロッキングという処理待ちの要らない仕組みを導入したのだ,くらいの理解で初歩的なところは問題ないでしょう.[*2]

ノンブロッキング I/O を行うことによりスレッドの処理の待ちが発生しにくくなるため,大量のアクセスが一気に来た場合でもスループットを落とさずに対応することができます.1つのスレッドがより効率よく多くの処理をさばくことができるようになるため,メモリの使用量が全体的に減ります.これは大きな利点です.

HTTP サーバーを作る

前置きがかなり長くなってしまいましたが,一通り nio の概要とノンブロッキングについて理解したところで実際に HTTP サーバーを作っていきましょう.今回は,nio のうち次のクラスを利用して HTTP サーバーを作成します.

大まかな HTTP サーバーの構成

とても簡易的な HTTP サーバーは,次の手順を踏むことで実装できます.

  • TCP ソケットなどを通じて,HTTP リクエストをバイトストリームを経由して受け取る.
  • リクエストの中身をパースして,HTTP サーバー内部で取扱可能な形にする (Request クラスを作るなどして扱うことが多いです).
  • メソッドやパスをハンドルする.
  • ハンドルしたものを Response クラスに詰めるなどする.
  • HTTP レスポンスをバイトストリームを経由して返す.

さて,今回は /hello というパスに対して GET を行うと,「Hello, NioHttpServer!」という文字列を返すだけのシンプルな HTTP サーバーを作成します.また,実装の大体はこちらのスライドを参考に作っているので,ご覧になると処理の詳細がわかるかと思います.

ソースコード

github.com

ちなみに上記リポジトリには2つの HTTP サーバーが用意してあります.NioHttpServer というクラスが java.nio を使ったサーバーで,HttpServer というクラスは java.io を使ったサーバーです.2つの間で処理がどのように異なるかの比較にどうぞ.

実際に使用したクラスの説明

nio には,ここにあげるもの以外にも FileChannel などさまざまな API が提供されていますが,今回は必要最低限に絞ってまとめておきます.

  • Selector: 上述したように,複数チャネルを1つのスレッド下で扱えるようにします.セレクタは生成されたすべてのチャネルを管理しており,チャネルを利用する際はこのセレクタにアクセスして取得してから利用します.
  • ServerSocketChannel: クライアントからやってくる TCP を受け付けます.
  • SocketChannel: TCP ネットワークソケットを経由してデータの読み書きを行います.ServerSocketChannel だけだと読み書きができません.なので,ServerSocketChannel#socket を使って SocketChannel を取得してから read/write を行います.
  • ByteBuffer: チャネルに渡すバッファの正体です.指定した領域をヒープに確保して,チャネルに送ることで read/write することができます.java.io でいうところの,byte[] です.

Selector, ServerSocketChannel, SocketChannel

セレクタは,自身に複数のチャネルを登録できます.これによって,1つのスレッド内で複数チャネルの処理を実行することができます.セレクタはどのようなチャネルを登録しているかについて, SelectionKey をキーとして内部的に保持しており,登録したチャネルを取り出す際には SelectionKey をイテレートすることで取得できます.

スレッド,セレクタ,チャネルの関係性については次の図のように整理することができるでしょう.

f:id:yuk1tyd:20180309233545p:plain

Selector#open することによって,セレクタを生成することができます.この生成したセレクタに対してチャネルを登録することで,チャネルを取扱可能な状態にします.

ServerSocketChannel ならびに SocketChannel はチャネルを表現するクラスです.これらは SelectableChannel クラスを拡張しており,SelectableChannel#register を呼び出すことによって,セレクタにチャネルを登録できます.

またセレクタに登録する際には,そのチャネルの状態を保存しておきます.下記のような状態を保存することができます.

  • SelectionKey.OP_ACCEPT: チャネルがクライアントからの接続を受け入れ可能.
  • SelectionKey.OP_CONNECT: クライアント側として使用した場合に,クライアントがサーバー (のチャネル) に対して接続可能.
  • SelectionKey.OP_READ: そのサーバー (チャネル) が読み込み処理を行う準備ができている.
  • SelectionKey.OP_WRITE: そのサーバー (チャネル) が書き込み処理を行う準備ができている.

最後に, ServerSocketChannel#configureBlockingfalse にすることで,ノンブロッキング I/O を可能にします.

ServerSocketChannel serverSocketChannel = null;
try {
  serverSocketChannel = ServerSocketChannel.open();
  serverSocketChannel.socket().bind(inetSocketAddress);
  // false を設定することでノンブロッキングにします
  serverSocketChannel.configureBlocking(false);
  // チャネルへ状態を登録します
  serverSocketChannel.register(selector, SelectionKey.OP_ACCEPT);

  (...)
} catch (IOException err) {
  LOGGER.error(err.getMessage());
  err.printStackTrace();
} finally {
  try {
    destroy(serverSocketChannel);
  } catch (NioHttpServerException err) {
    (...)
  }
}

セレクタからチャネルを取り出す際には,セレクタが保持しているキーの一覧をイテレートして取り出します.Selector#selectedKeys を用いることで,保持しているキーの取り出しを行います.具体的には,次のように実装することができます.

try {
  while (selector.select() > 0) {
    for (Iterator iter = selector.selectedKeys().iterator(); iter.hasNext(); ) {
      SelectionKey key = (SelectionKey) iter.next();
      iter.remove();

      (...)
    }
  }
} catch (IOException err) {
  (...)
}

read/write についても,SocketChannel#read あるいは SocketChannel#write で行うことができます.java.io の場合は Socket クラスの InputStream や OutputStream を取得してから行いましたが, nio の場合は先の2つで処理が完結していまいます.なお,read/write には後述する ByteBuffer を使用します.コードの例は下記です.

ByteBuffer buf = ByteBuffer.allocate(1024);
socketChannel.read(buf);
buf.flip();

サンプルコードの置いてある GitHub 上では,read/write をこの箇所で行っています.ご覧いただくとさらに理解が進むかもしれません.

ここまででお分かりの通り,java.io で HTTP サーバーを実装する場合と大きく異なるのは ByteBuffer の存在です.java.io の場合は,プリミティブなバイト配列を InputStream/OutputStream に渡すことでやり取りを行っていましたが,nio では ByteBuffer を用いてやり取りを行います.ByteBuffer は触ってみて少し癖があると思ったので,次の節で簡単に解説を加えます.

ByteBuffer

通常の業務アプリケーションを作っている範囲内では,ByteBuffer を目にすることはほとんどないでしょう.実際,私も Netty のカンファレンスで聞くまでは知りもしませんでした.ByteBuffer低レベルな部分を少しいじることでプログラムを高速化したい場合に用いることがあるようです.

byte[] を使う java.io と何が違うか?という点についてですが,ByteBuffer はバッファ内のデータを前後させることができます.java.io のバッファストリームは読み込みが終わるまでデータの操作を行うことはできませんが,ByteBuffer は読み込み中であってもそのような操作に対して柔軟に対応できる点で異なります.[*3]

ByteBuffer は次の要素によって成り立っています.

  • capacity: バッファが格納可能なサイズの上限値です.
  • limit: バッファの書き込みモード時に使用するもので,どのくらいのデータを書き込み可能か,その上限を決定します.書き込みモードでは capacity と同値になります.
  • position: バッファがどの位置にいるかを示します.書き込みモードの場合は最初は0ですが,書き込みが起こると position は異動します.読み込みモードの場合,flip() を使ってモードの変更を行うと position は0にリセットされます.

ByteBuffer にはいくつかの操作が存在していて,それらを組み合わせることでバッファの位置を調整できます.今回使用したのは次の2つです.

  • ByteBuffer#flip: バッファのモードを,書き込みモードから読み込みモードに変更します.また,その際に position を0に戻し,limit を position と同じ位置に設定し直します.
  • ByteBuffer#clear: position を0にし,limit を capacity と同じ位置に設定します.

その他にも,

  • ByteBuffer#rewind: position を0にします.データを再度読み込み可能にします.
  • ByteBuffer#compact: データの追加書き込みを可能にします.

などの操作があります.

また,ByteBuffer#allocateDirect メソッドを用いることにより,新しいメモリ領域をマシンネイティブに確保することもできる機能ももっています. [*4]

あとは,作成した ByteBuffer をチャネルの read/write に流し込むことでバッファへの read/write を行うことができます.こうして,HTTP リクエスト/レスポンスを返すことができるようになりました.

まとめ

ノンブロッキングを書こうとすると,それなりに理解するべき内容も多く学習コストも高いです.が,普段の Web フレームワークでは,フレームワーク側がその部分を隠蔽して操作しなくていいようにしているため,ほとんど意識することがないというのが実情です.私も Twitter 社が作っている Netty を基盤とした RPC フレームワークの Finagle を仕事上使っています.その際もやはり,ByteBuffer を目にすることはほとんどありません.

HTTP サーバーそのものは比較的簡単に作ることができ,ネットワークプログラミングの勉強のいい練習になるのでオススメです.今回はソースコードの解説をそこまでしませんでしたが,読んでみると, GET は案外単純な実装で実現できるのだ,ということがわかるかと思います.

今後の勉強の展望

  • AsynchronousSocketChannel のような非同期かつノンブロッキングな処理を扱えるクラスもあるみたいなので,時間を見つけて似たように HTTP サーバー作りたい.
  • Netty を触って,Netty に関する記事も書きたい.
  • ノンブロッキング時に呼び出されるシステムコールについても整理する.

*1:しかも TCP をやったつもりなので,このシステムコールが出るのはちょっとおかしい?

*2:興味がある方は上記の記事などをご覧いただくか,『詳解UNIXプログラミング』の該当のページを紐解くのもいいと思います.C 言語を使って実際にシステムコールを呼び出しながら実装を行うと,より理解が深まるかと思います.

詳解UNIXプログラミング 第3版

詳解UNIXプログラミング 第3版

*3:InputStream と OutputStream との比較を考えてみてください.

*4:ただ Netty の人曰く,より速度を向上させるためにマシンネイティブのメモリ領域を使うことをやりたいものの,ByteBuffer を用いるのはなかなかコストのかかる処理とのことです.allocateDirect が高コストなんですね.なので,sun.misc.Unsafe を使ってみたみたいです.