HTTPサーバにJava NIOは必要か

0x00. はじめに


筆者はJava製のWAF(Web Application Firewall)、Guardian@JUMPERZ.NETの開発とメンテナンスを行っている。元は自社のシステムを守るために(そして半分趣味で)作ったものだが、数年前にこれをコアのエンジンとしてさらに拡張し、SaaS型の商用サービス「Scutum(スキュータム)」を立ち上げた。

その後順調に顧客を獲得することができ、システムリソース的にも増強が必要となる段階などを経験した。Google、mixiやはてな等、さまざまな大規模サイトのインフラエンジニアの方々がインフラ設計に関する考え方などをインターネット上で公開してくれているおかげで、初期のシステム設計時に「将来的にスケールアウト可能なシステム構成にしておくこと」が重要であるということがわかっていた。その教えに従っていたおかげで、リソースの逼迫(ちなみに今回はCPUリソースだった)に遭遇した際にも、単にサーバを追加することでこれを乗り切ることができた。先人の知恵に感謝したい。

WAFというのはわかりやすく言えばSQLインジェクションのようなウェブアプリケーションに対する攻撃を防ぐものである。技術的にはそれはHTTP/HTTPSのプロキシサーバであり、一度リクエストをパースして中身をチェックする。チェックの際には(大量の)既知の攻撃パターンとの比較を行うため、文字列マッチングによって多くのCPUサイクルを消費する。

Guardian@JUMPERZ.NETのアーキテクチャはシンプルなスレッドプール+ブロッキングI/Oである(キューも使っているのでスレッドプールの数以上のクライアントが押し寄せた場合にはそれらのタスクはキューに溜まる)。昔のApacheのようなマルチプロセス+ブロッキングI/Oほどではないが、やや古き良きアーキテクチャと言ったところである。1台のハードウェアで同時に処理できるコネクション数はスレッド数と同等となる(キューにタスクとして溜めておくことができるので、一時的に受け付けることができるクライアント数はスレッド数より多くすることが可能ではある)。Linux上でOracleのJRE6系列を使う場合、64bitでメモリをたっぷり積んだマシンであれば1〜2万スレッドほど生成して動作させることも可能である。この場合ボトルネックはスレッド数の増加に伴うメモリ消費量となる(Linuxのスレッドは非常に優秀であるため、コンテキストスイッチのオーバーヘッドは筆者がベンチマークしてみた感覚では全く問題にならない)。

Javaでは一般的にこのようなアーキテクチャ(スレッドプール+ブロッキングI/O)ではC10K問題をクリアできないとされており、C10K問題の向こう側に行くためにはNIOパッケージを使用した非同期I/Oによるアーキテクチャに切り替える必要があるとされている。筆者としてもこの問題が非常に気になっており、「JavaのNIOパッケージは速い」「NIOでスケーラブルなソケット」などの文句を目にするたびに自分が使っているアーキテクチャは過去の遺物なのかと心配になっていた。もしやGuardian@JUMPERZ.NETをNIOで書き直せば爆速になるのでは…?という期待と、非同期プログラミング(その難しさには定評がある)に手を出す怖さの間で悶々とした日々を過ごしていたのである。

0x01. C10K問題


2011年の2月に、Infoq.comに掲載されていたDeftと呼ばれるソフトウェアの記事などを目にしたことがきっかけで、これまで敬遠していたJavaNIOに少し手を出してみることにした。積ん読となっていたJavaネットワークプログラミングの真髄とKindle版のJava Network Programming(20$以下で買える!)を参考にNIOを使った簡単なコードを書いてみた。Selectorを中心にループするという、いわゆるselect()を使ったソケットプログラミングのJava版という感じである。筆者は王道的な(UNIX系OS上における)C言語のソケットプログラミングについてもブロッキングI/Oを使ったものしか経験がないため、JavaNIOを使ったプログラミングはかなりハードルが高かった。

NIOを使ったアプリケーションのコードは通常のIOを使ったコードとはまったく異なるものになるため、Guardian@JUMPERZ.NETをNIOで書き換えるのはかなり困難だということがわかった。そこで本当にNIOで書き直す価値があるのかを再検討することにした。

NIOがIOに比べて明らかに優れているのは、スレッド数の上限(=メモリの上限)を超える数のコネクションを同時に処理できるということである。そのため、Guardian@JUMPERZ.NETにおいて、同時に処理したいクライアントの数が現在のボトルネックであれば、NIOにすることでこれを解消できる可能性がある。しかし現在のシステムにおいて実際にボトルネックとなっているのはCPUリソースであり、その主な原因は先述した文字列マッチングによるものである。同時接続数としては数百〜数千程度であり、従来のIOで(そしてスレッドモデルで)処理できる数だ。そもそも同時に処理するクライアントの数を増やしてしまうと、より多くの文字列マッチングが行われることでCPUリソースがさらに不足する。そのため、一時的にアクセスが集中する際などには、処理しきれないクライアント(ソケット)はacceptした後にタスクとしてキューに待機させておき、実際に同時処理を行うスレッドプールのスレッド数は一定の数にしておく方がよいといえる。このとき重要なのは、Guardian@JUMPERZ.NETはごく一般的なウェブサーバの前にリバースプロキシとして位置するHTTPプロキシサーバであるため、クライアントはFIFOで先に接続してきたものから順番に処理すればそれでよいということだ。この点がNIOを検討する際の非常に大きなポイントとなる。(もちろんウェブサーバ側のアプリケーションがCometなどを使っている場合にはまずいことになるが、ここではそのようなケースは想定しない)

Cometなどを使用するような、数万オーダのクライアントが同時接続するチャットアプリケーションなどの場合、実際にその数万のソケットを絶え間なく監視する必要がある。いわゆる、多くのソケットが待機時間が長い(ロングポーリングなどと呼ばれる)状態で使用され、アプリケーション自体のCPU使用率としては低いというシステムである。このようなケースでは、先述したGuardian@JUMPERZ.NETのように、クライアントをFIFOで処理すればよい、というわけにはいかない。そのためNIOが必須となり、旧来のIOでは対応できないということになる。これがC10K問題である(この議論は既に数年前にインターネット上では完結していたもので、知っている人には今さら、という感じだろう。筆者は不勉強だったため、ここにきてようやくこの問題の切り分けができた)。

つまり結論としては、必ずしもGuardian@JUMPERZ.NETはNIOにする必要はないということだ。Guardian@JUMPERZ.NETのアプリケーションの処理ではCPUサイクルを多く消費する。そのためより多くのクライアントからのアクセスを同時に処理するためにはCPUリソースを追加する必要があり、それはサーバ数の増加によって行うのが適切である。

0x02. NIO vs IO


次に気になるのは、比較的少なめの、数百〜数千のクライアントを処理する場合に、NIOにすることでパフォーマンスが上がるのかということだ。この程度の数であればスレッド+ブロッキングIOで十分に処理できる。しかしNIOにすることでパフォーマンスが大きく異なるのであれば、それはNIOで書き直すモチベーションになる可能性がある。

個人的な実感を伴って本当に「NIOが速い!」と感じたのは、TomcatのNIOコネクタのベンチマークテストを行ったときである。Tomcatについては、デフォルトのコネクタのパフォーマンスはひどいもので、そもそも大量の並列度でベンチマークを取ろうとしてもエラーが多発してしまいまともにレスポンスを返せない(もしかしたらチューニングすればマシになるのかもしれない。要調査)。しかしNIOコネクタはまったく異なり、CPUの使用率を低めに抑えながら素晴らしいパフォーマンスを見せる。また、クライアントの同時接続数とスレッド数が独立しているため、クライアントの並列度を1000としてベンチマークを行った際にもスレッドの数は220前後となっており、メモリ使用量が抑制されている。

そこで、「NIOは爆速なのか?」を念頭に、ギガビットLAN環境でベンチマークテストを行った。対象はTomcatなどのJavaNIOサーバと、スレッド+ブロッキングIOであるGuardian@JUMPERZ.NETである。ベンチマークテストの方法についてはここでは省略するが、基本的にはGuardian@JUMPERZ.NETも十分なパフォーマンスを見せるという結論に達した。スレッドの数が数千から1万程度でもコンテキストスイッチのオーバーヘッドは気にならないレベルであり、スレッドの生成も問題にならないコストであった。

その後ウェブ上で情報を漁ってみた。JavaのNIOはどのくらい速いのか?本当に速いのか?というのが気になっていたエンジニアは当然ながら大勢いたようで、下記のような興味深い記事が見つかった。

http://stackoverflow.com/questions/4057853/java-i-o-vs-java-new-i-o-nio-with-linux-nptl
http://www.thebuzzmedia.com/java-io-faster-than-nio-old-is-new-again/
http://blog.uncommons.org/2008/09/03/avoid-nio-get-better-throughput/
http://paultyma.blogspot.com/2008/03/writing-java-multithreaded-servers.html
http://monkeypatched.blogspot.com/2008/09/java-multi-threaded-blocking-io-wins.html

これらの記事からわかったのは、Linux2.6ではスレッド実装が非常に優秀であり、従来のモデル(スレッドプール+IO)でもパフォーマンスが問題ない、あるいはNIOよりも速いということだ。これは筆者自身がテストしたベンチマーク結果とも一致する。この段階で筆者としては調査の目的を達成し、ひとまず満足した。

0x03. Java NIOのバグ?


ここからは少々本題から外れる。今回の調査で気になったのが、TomcatのNIOコネクタのベンチマークを取っていると、たまにTCPコネクションが張れないというエラーが発生することである。負荷をかける(大量のリクエストを生成する)クライアント側アプリケーションが筆者自作のJavaアプリケーションの場合も、Apache Bench(abコマンド)の場合も同様であった。おそらくこれはTomcat独自のバグだろうと思い、JavaNIOを使っている別のHTTPサーバ実装についてもいくつか同じベンチマークテストを行ってみることにした。対象は以下である(リストにはTomcatも含める)。

  • Jboss Netty
  • Apache HttpComponents
  • Deft
  • Apache Tomcat
  • JavaNIOにはエンジニアを熱狂させる何かがあるらしく、多くのアプリケーションはウェブサイト等においてJavaNIOを使っていること、非同期I/Oであることをアピールしていたのが印象的だった。さて結果だが、驚いたことに全てのアプリケーションでTCPコネクションのエラーが発生した。abはこのエラーが出るとベンチマークテストを中断してしまうため、リクエスト数をある程度多くしてしまうと毎回テストが完走できないという状態であった。一方で筆者自作のベンチマークツールではエラーがあってもログに記録するだけでテストそのものは中断しない。300万リクエストを並列度1000でテストした結果、だいたい10〜20回程度のTCPコネクションエラーが発生するようだった。また、一応書いておくと、Guardian@JUMPERZ.NETではエラーはまったく発生しない。

    コネクションエラーが発生する原因はよくわからないが、JavaNIOを使っているアプリケーションのみに特徴的に発生することを考えると、おそらくJavaNIOそのもののバグなのではないかと思う。上記リストにあるアプリケーションは、(Deftを除き)それなりに多くのユーザが使っているものだと思われるため、アプリケーションのコードに問題があるとは考えにくい。筆者以外でもこの現象に遭遇したユーザがいるようでメーリングリストに投稿があったのだが、結局原因は特定されていないようだ。また、Javaとは関係ないのだが、ついでにnode.jsについても同じ負荷をかけてみたところ、TCPコネクションエラーはまったく発生せずに300万リクエストを処理することができた。そのため原因はOS設定やネットワークではないと考えられる。

    また、Deftに対してTelnetでHTTPリクエストを送ろうとしたところ、HTTPリクエストヘッダの終わりを意味するCR LF CR LFを送る前にレスポンスが返ってくる状態だった。まともにプロトコルを解釈できておらず、アプリケーションとしては非常に未熟なものであることが伺えた。また、少し前の話だがTomcatのNIOコネクタに対してHTTPリクエストを1バイトずつ間を開けて送った際も、正しく動作しないことがあった。JavaNIOのコーディングの難しさがこのような点に現れているように思える。これらのことから筆者自身は当分の間NIOをメインで使うことはないだろうと考えている。

    0x04. スケーラブルな…?


    さてここまで調べてみてあらためて感じるのは、NIOは騒がれすぎているということだ。「スケーラブルなソケット!」「スケーラブルなI/O!」などという文句を目にするが、そもそもスケーラブルの定義は何なのか。筆者の感覚では、新たにハードウェアリソースを投入すればそれだけ処理能力が上がるように設計されたシステムが「スケーラブルなシステム」である。スケーラブルなソケットということは、つまりメモリか何かを増やせばポート番号が10万まで使えるようになるとかそういうことなのだろうか。

    結局、NIOが解決するのは同時に扱えるクライアントの数がスレッド数に引っ張られずに済む、ということでしかない。つまり今まではスレッドの数(=メモリの量)がボトルネックだったが、そのボトルネックは解消できる、ということだ。ひとつボトルネックを解消すれば次は別の部分がボトルネックになるわけであって、結局NIOを使っている場合でもそこで壁に当たるだろう。NIOはまるで魔法のように何でも解決してくれるソリューションではない。また、仮にスレッドモデルで動いているシステムがあり、そこにメモリを追加することでさらに多くのスレッドを生成できるのであれば、そのシステムもスケーラブルだと言えるだろう(「スケーラブルなスレッド」といったところだろうか?)。NIOを「スケーラブルな…」と表現することは問題をわかりにくくするだけのように感じる。

    0x05. ハードウェアはボトルネックではない?


    C10K問題を調べる際に、「ハードウェアはもはやボトルネックではない」という表現を目にしたが、これは違うのではないだろうか(C10K問題を提起した記事に書かれている記述なので突っ込むのは野暮なのかもしれないが)。ハードウェアがボトルネックでないということは、そのハードウェアのCPUをいくら速くし、メモリをいくら足しても(そしてストレージの速度をいくら上げても)システムの性能が変わらないということである。マルチスレッドモデルやマルチプロセスモデルはCPUコアが増えることで性能が上がることが考えられるため、ハードウェアはボトルネックになっていると言えるだろう。本当にハードウェアがボトルネックではないケースというのは、例えばポート番号が足りないなどのパラメータの制限によるものになるだろう。

    0x06. まとめ


    今回の調査で筆者が得たことを簡単にまとめると以下のようになる。

  • アクセスしてくるクライアントをFIFO的に処理すればよいシステム(多くのHTTPサーバがこれに当たる)では、必ずしもJavaNIOを使う必要はない
  • 特にアプリケーションの処理自体がそれなりに重いシステムではスレッドプールの数によって同時に処理するタスクの数をコントロールすることが適切である
  • 数万以上のクライアントを同時に相手にする必要があるシステムでは、NIOを使う必要がある
  • 特にアプリケーションの処理自体が軽いものの場合NIOが適切である
  • JavaNIOの実装にはバグがありそう
  • JavaNIOを使用しているHTTP実装にもまだまだバグがありそう
  • 数千から1万くらいのスレッドは十分に速く、IOもNIOより速い場合が多い
  • Advertisements

    2 Comments on “HTTPサーバにJava NIOは必要か”

    1. こむすび says:

      twitterから来ました。とても興味深く読ませていただきました。

      マルチコアになって多重スレッドを意識する時代かと思いきや、Webサーバーとしてはすでにスレッドプールされているので関係ないですね。本題とも外れてしまいましたが、また寄らせていただきます。

    2. […] java nio This entry was posted in Uncategorized. Bookmark the permalink. […]


    Leave a Reply

    Fill in your details below or click an icon to log in:

    WordPress.com Logo

    You are commenting using your WordPress.com account. Log Out / Change )

    Twitter picture

    You are commenting using your Twitter account. Log Out / Change )

    Facebook photo

    You are commenting using your Facebook account. Log Out / Change )

    Google+ photo

    You are commenting using your Google+ account. Log Out / Change )

    Connecting to %s