node.js と thread hog の話(3)
2012.10.16
[前回までの話へのリンク]
・node.js と thread hog の話(1)
・node.js と thread hog の話(2)
では、なぜ今頃になって HTTP Server の c10k 問題(もしくは、thread hog 問題)が顕在化したのだろう。
当時(90年代の終わり頃)と比べて、もっとも大きく変わったのはCPUの性能である。クロック数は、数百MHzから数GHzへと一桁増えたし、マルチコア化もしている。CPU 性能だけ見れば、当時の数十倍の能力が出てしかるべきである。
しかし、実際の人生はそう簡単ではない。サーバーのパフォーマンスはCPU性能だけが決めるわけではないからだ。そこで、ボトルネックの一つとして注目されはじめたのが、thread の数なのである。
前回述べた様に、thread 一つあたり 2MB~8MB のスタック領域を仮想メモリ空間に確保しなければならないので、thread の数が500とか1000を超えたあたりで 32-bit CPU/OS の限界を超えてしまう。64-bit CPU/OS ならばその問題は回避できるが、だからと言ってやたらと仮想空間にメモリを確保し続けるのは無駄だし、thread 切り替えのオーバーヘッドは64-bit CPU/OSになったところで消えない。
特に最近のCPUは、メインメモリーのスループットをはるかに超える速度(100倍以上)で動いているため、CPUコア上に積んだキャッシュのヒット率がとても重要なのだが、あいにくなことに、CPU 上のキャッシュとthreadを大量に使う thread hog 型アーキテクチャとは相性が悪い。それぞれの thread のスタック領域は、当然だが別々の物理メモリーに割り当てられるため、thread の数が増えてくると、キャッシュのヒット率が極端に下がるのだ。
その結果、multi-thread 型の HTTP Server は、同時接続数がある程度を超した時点で、thread 切り替えのオーバーヘッドにより処理速度が落ち、処理速度が落ちた分だけ同時接続数が増えて(server がもたもたしている間に次のリクエストが来るため)さらに処理速度が落ちるという悪循環に陥る、という欠点を持っているのだ。
ここで注目すべきなのは、なぜそれぞれの thread に数MBのスタック領域を割り当てなければならないか、である。実際に必要なスタック領域はそれよりもはるかに少ないかも知れない。実際にスタックに積まれるデータも、HTTP Server の場合、どの thread も似たようなデータである。
にもかかわらず数MBのスタック領域を確保しておく必要があるのは、thread が汎用的な多重処理のメカニズムだからである。OSとしては、thread が実際にどのくらいスタック領域が必要なのか、thread に積まれたデータの中で、どれがそれぞれの thread にとってユニークで重要なデータかを知るすべはないのだ。
node.js は、非同期APIを使ってイベント駆動型で多重処理を行うが、もちろんここにもオーバーヘッドは存在する。スタック上に積まれた変数のうちコールバック関数からアクセスされる可能性のあるものをクロージャとしてアクセス可能にしておく必要がある。非同期APIからのイベントを受けた時に、適切なコールバック関数を呼び出す、という処理もする必要がある。ここだけに注目すれば「thread 切り替え」を「イベント駆動」に置き換えただけの話であり、一見、それほどのメリットがある様には思えない。
一番の違いはメモリの使い方にある。
multi-thread 型のサーバーの場合に、仮に2MBのスタック領域を持った500個のthreadが、それぞれ200KBのスタック領域(実メモリ)にアクセスしたとすれば、それだけで1GBの仮想メモリと100MBの実メモリを使うことになる。この実メモリのサイズは Intel の Core i7 プロセッサの L3 キャッシュのサイズ(8MB)よりも十分に大きいため、キャッシュのヒット率は極端に低くなる。
node.js のようなクロージャを活用したsingle-thread 型の場合、スタックの代わりにコンテキストを保持するのはクロージャだが、ここには必要なデータだけが記録されるため、必要なメモリは数100バイト~数KBである(ちなみに、このメモリはスタックにではなく、V8 のヒープ上にアロケートされる)。仮に多めの10KBとおいたとしても、500個の並列処理が行われたとして、トータルで5MBしか使わない。multi-thread 型と比べて、仮想メモリで200分の1、実メモリで20分の1だ。
特に差が出るのは、スタック領域である。single thread であるが故、スタックとしてアクセスする場所は実メモリの特定の領域に限定される。結果として、それらのメモリは、高速にアクセスできる L1/L2 キャッシュに常駐することになるのだ。
◇ ◇ ◇
ここで再度認識して欲しいのは、node.js の素晴らしさは「クライアント側で皆が使っているJavaScriptでプログラムが書ける」という部分などにあるのではない、という点だ。node.js がこれほど多くの支持者を得ているのは「本来記述が煩雑になりやすい非同期処理をJavaScriptの無名関数を利用して書きやすく・読みやすくすることにより、イベント駆動型のプログラミングを多くのプログラマーにとって手の届くものにした」点にあるのだ。
前にも説明した通り、イベント駆動型のプログラミングは、直感的にフローを把握しにくいという理由で多くのプログラマーたちに敬遠されて来た。特にクロージャをサポートしていない言語の場合、非同期 API を呼び出すプログラムと、イベントを処理する部分のプログラムが離れたところに記述されてしまうため、プログラムがとても読みにくくなる、という性質があった。
しかし、クロージャが多くの言語でサポートされるようになった今の時代、イベント駆動型のプログラミングを拒否する理由はあまりない、と私は思う。
Comments