この投稿は「Node.js入門」の著者の一人である、shigeki@githubさんが師範となり開催された「Node.js道場」での鍛錬の模様を記したものです。
目次的なもの
Node らしい繰り返し処理
ノンブロッキングI/Oで鳴らした俺たち Node.js は、
癌診断され当局に dis られたが、
病院を脱出し電脳世界にダイブした。しかし、電脳世界でくすぶってるような俺たちじゃあない。
npmで追加さえすりゃモジュール次第で
なんでもやってのける命知らず、
不可能を可能にし、大量のリクエストを処理する、
俺たち非同期野郎Nodeチーム※Node is NOT a Cancer※
なお、Node.jsは2009年2月16日生まれの「みずがめ座」なので
「蟹座(Cancer)」ではないと、師範から教えていただきました
前回の続きです。
私の頭がI/O的な意味でブロック状態で、時間があいてしまいましたが、気を取り直していきましょう。
100万回リクエストするベンチマーク を作って、「平均req/sec」のパフォーマンス指標を表示してみようというものです。
課題の詳細は、前編をご参照ください。
では、いきなり師範の模範演舞を見ていきます。
師範の模範演舞
var http = require('http');
var counter = 0; // 全リクエスト数
var maxreq = 1000000; // 最大リクエスト数
var errorCounter = 0; // リクエストエラー発生数
var startTime, endTime; // ベンチ開始時間、終了時間
var maxRequestTime = 0; // 最大リクエスト時間
var minRequestTime = Infinity; // 最少リクエスト時間
var aveRequestTime = 0; // 平均リクエスト時間
var server = http.createServer(function(req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
});
server.maxConnections = 100;
server.listen(8080, function() {
doBench();
});
var myAgent = new http.Agent();
myAgent.maxSockets = 100;
var opts = {hostname: 'localhost', port: 8080, method: 'GET',
path: '/', agent: myAgent};
function doBench() {
function get() {
var req = http.request(opts, function(res) {
// maxreq数までリクエストしたら終了
if (counter >= maxreq) return;
res.on('end', function() {
// リクエスト終了までの時間を計算して累計時間に足す
var elapsedTime = (Date.now() - req.startTime);
if (maxRequestTime < elapsedTime) maxRequestTime = elapsedTime;
if (minRequestTime > elapsedTime) minRequestTime = elapsedTime;
aveRequestTime += elapsedTime;
if(++counter == maxreq) {
endTime = Date.now();
server.close();
} else {
// リクエストが終了したら新規リクエストを行う
get();
}
});
});
req.once('socket', function(socket) {
// リクエストがソケット接続した時間を開始時間とする
req.startTime = Date.now();
});
req.on('error', function() {
// リクエストエラーが発生したら再度リクエストを送信
++errorCounter;
get();
});
req.end();
}
startTime = Date.now();
for(var i = 0; i < myAgent.maxSockets; i++) {
get();
}
}
process.on('exit', function() {
// 統計情報の出力
console.log('counter=', counter);
console.log('totalTime=', (endTime-startTime)/1000, "[sec]");
console.log('performance=', counter/(endTime-startTime)*1000, "[req/sec]");
console.log('minRequestTime', minRequestTime/1000, "[sec]" );
console.log('maxRequestTime', maxRequestTime/1000, "[sec]" );
console.log('aveRequestTime', aveRequestTime/counter/1000, "[sec]" );
console.log('errorCounter=', errorCounter);
});
Node.jsの欠点といわれるものを思い出してください
Node.jsは、子プロセスを作成しないかぎり、シングルプロセスで動くことは、この投稿をご覧の方はご存知かと思います。そのため、フィボナッチ数列の再帰計算や動画のエンコードのように「CPUヘビー」と呼ばれる処理を行うのには不向きと言われています。
Webブラウザが、JavaScriptの処理で固まってしまい、全く操作を受け付けなくなることがあるように、Node.jsも、どこかにそのような処理があれば全体が固まってしまいます。あるひとつのリクエストのせいでサーバ全体が固まってしまう可能性がある……これがNode.jsの欠点として指摘されています。
「短所は長所の裏返し」であります。
Node.js は、httpリクエストのような小さなモノを高速に効率良くさばけるという特徴をもったプログラミング環境です。簡単にさばけないような大きなモノの処理には、工夫が必要なのは必然なのです。(この場合の大小はCPU処理量という意味です)
では、具体的にはどうすれば良いのでしょうか?それがこの課題の狙いでもあります。師範のコードを元に要点をみていきましょう。
イベントのコールバックで再帰
模範演舞のdoBench()
内のコードのレスポンスのend
イベントの処理を見ましょう。
// 大幅に省略
res.on('end', function() {
if(++counter == maxreq) {
server.close();
} else {
// リクエストが終了したら新規リクエストを行う
get();
}
});
get()
の処理が終わるタイミングでget()
の再帰呼び出しを行っています。
なぜ、わざわざイベントのコールバックで再帰にするのか?
ここは余談になってしまうので、「非同期」がよくわからないという人以外は、読み飛ばしてしまって構いません。
Nodeの動作は原則的に「非同期」で行われていると言われます。だから「イベントのコールバックで再帰」するのがNode.jsらしいテクニックなわけですが、この「非同期」はキーワードでありながら、理解しにいところであります。
日常生活ではよくあることなのに、いざプログラミングとなると「結果を待たずに次の処理に移る」という部分が気持ち悪く感じるように思います。私は今もそう思っています。最初のまともなプログラミング体験が Fortran だったので、「プログラミングとは順番通り逐次的に実行されるものだ」という先入観が強いのです。
さきほど、「日常生活ではよくあること」と書きました。「非同期」とはどのようなものか、スターバックスで説明されることがあります。
- 注文の受け付け(リクエスト処理)よりも、商品を作る(データベースなどのI/O処理)ほうが圧倒的に時間がかかる
- 注文を受け付け、商品を作り、お客に渡してから、次の注文を受け付ける……なんてことをすると、効率が悪くて仕方ない
- だから、とりあえず注文をどんどん受け付け、出来たものから順に客に渡す
- その結果、注文した順番と商品が出てくる順番は必ずしも一致しなくなる
これが、スターバックスだけに限らないことは直感的にわかると思います。また、仕事をしている場合だと、「とりあえず先方に投げて返事待ちなので、別の作業を進めとくか」といったことは普通に行われているはずです。
この「返事が来たら」を感知する部分が、イベントになります。そのイベントを受けて行う処理がコールバックです。一気に100万回ループを回すと、ループを回している間、Node.jsは他の処理が行えなくなってしまいます。そういうコードを書かないのがNode.jsの作法になります。
とはいうものの、わかったようなわからないような感じですよね?日常生活の比喩ではなく、具体的な実装例である「フィボナッチ数列をNode.jsらしく最適化したコード」を見てみましょう。
var fibonacci = function(n, callback) {
var inner = function(n1, n2, i) {
if (i > n) {
callback(null, n2);
return;
}
var func = (i % 100) ? inner : inner_tick;
func(n2, n1 + n2, i + 1);
}
var inner_tick = function(n1, n2, i) {
process.nextTick(function() { inner(n1, n2, i); });
}
if (n == 1 || n == 2) {
callback(null, 1);
} else {
inner(1, 1, 3);
}
}
普段はそのまま再帰しているのに、100回に1回は、process.nextTick()
のコールバックで再帰しているのがおわかりになりますでしょうか。単純に再帰するだけなら、for
ループと同じですが、process.nextTick()
を挟むことで、固まることなく他のリクエストの処理を続けることができるようにしてます。「Node.js入門」の「第7章イベントループとprocess.nextTick()」もご参照ください。
なお、process.nextTick()
でループを回すのは、process.nextTick()
本来の使い方ではないとされており、v.0.9からは、setImmediate()
が追加されているとのことです。
ところで、スターバックスの「商品の作り方」を非同期にしたらどうなるか?フォーム機が空いてたら先にカップに泡を追加してから、コーヒーを注ぐ……なんてことをしたらダメなわけで、順番通り同期的にやらないといけない部分も存在します。Node.jsの場合、書き込む順番や読み込む順番が大事なファイル入出力処理などでは、非同期的なfs.write
以外にも同期的なfs.writeSync
が用意されています。
ソケットの数だけループ
再帰で繰り返しているので、最初のひとつだけget()
を呼べば十分なのですが、ソケットの数だけループを行なっています。
for(var i = 0; i < myAgent.maxSockets; i++) {
get();
}
HTTPサーバの方は、同時接続数を100にしているので、それに対応するために最初のget()
を100回呼び出しているということです。
再帰の頭で return
for
ループを回す場合、何回ループしたかはわかりやすいのですが、再帰する場合は、何回目かの処理は自分で行わないといけません。しかも、今回の場合は、同時並列的に100個のソケットを再帰させながら動かしているので、再帰のお尻(end
イベントのコールバック)だけでなく、頭でも終了条件のチェックを行う必要があります。
function get() {
var req = http.request(opts, function(res) {
// maxreq数までリクエストしたら終了
if (counter >= maxreq) return;
ちなみに
function get() {
// maxreq数までリクエストしたら終了
if (counter >= maxreq) return;
var req = http.request(opts, function(res) {
とすると、counter
がmyAgent.maxSockets-1
増えますのでご注意を。
myAgent.maxSockets-1
回、余計なリクエストを送っていますが、時間計測の部分には影響を与えていません。
プロパティを付けてしまう
リクエストごとに時間を測りたいと思ったときに、同時並列的に100個のソケットで非同期に行なっていると、個々の時間を測るには工夫が必要そうにみえますが、単純にそれぞれのリクエストオブジェクトに開始時刻のプロパティをつけてしまうというと簡単に済みます。
req.once('socket', function(socket) {
// リクエストがソケット接続した時間を開始時間とする
req.startTime = Date.now();
});
これは動的な言語だからこそできるテクニックになります。
ちなみに、このコードはmyAgent.maxSockets
を少なくしたほうが速く動きます。ローカルホストへの接続だと、ソケット数が多ければ多いほどソケットをスイッチするコストが無視できなくなるためです。
また、Date.now()
がミリ秒の精度しかないために、myAgent.maxSockets = 1
などとしたときには、平均リクエスト時間が著しく低く表示されます(ほとんどのリクエストが1ミリ秒未満で終了するため、0ミリ秒扱いのものばかりになる)。サードパーティーのmicrotimeモジュールやNode.jsのv.0.8以降で使えるprocess.hrtime()
などの高精度タイマーを使えば、そのあたりも正確に計測できます。
もちろん、ローカルホスト以外への外部へのアクセスでは、ソケット数を増やしたほうが速いのは言うまでもありません。
まとめ:「1stシーズン弐の段」における師範の教え
前編と合わせて