この投稿は「Node.js入門」の著者の一人である、shigeki@githubさんが師範となり開催された「Node.js道場」での鍛錬の模様を記したものです。
目次的なもの
NodeらしいHTTPを求めて
CHARA CHA CHA CHARA CHA
CHARA CHA CHA CHARA CHA
CHARACHA CHARACHA CHA CHARA CHARA CHAクライアントから クライアントから リクエストが来てる
僕はそれを右へ受け流す(Nodey勝山)
前回、TCP通信からムーディな修行をはじめた我々は、簡単なhttpサーバにも罠が潜んでいることを学びました。
第2回は「Node.js入門 第10章」です。「httpサーバ」と「httpクライアント」の2つを扱いました。
師範からは、http.Agent
についての諸注意が示されました。
出された鍛錬の課題は、下記であります。
今回は、長くなりすぎたので、前後編に分け、前編では課題1のみを扱います。
ご興味のある方は、ご自身でやってみてください。回答例もこの投稿内にありますが、上からスクロールして表示されたものをただ単に見ているだけでなく、読みながら自分でもやってみると理解が深まります。
課題
B君は、Node.jsのHTTPクライアント機能を使ってWebサーバのベンチマークができるプログラムを作りたいと考えました。
いきなり外部のWebサーバに大量のアクセスをすると迷惑がかかるので、まずプログラムの内部で最大同時接続数を3に制限したHTTPサーバを作成して同時に100回HTTPリクエストを行うベンチマークプログラムを書いたところ、次のようなエラーが発生して終了してしまいました。
> node ./http_bench.js
request start time: Sat Sep 03 2112 12:09:03 GMT+0900 (JST)
request start time: Sat Sep 03 2112 12:09:03 GMT+0900 (JST)
request start time: Sat Sep 03 2112 12:09:03 GMT+0900 (JST)
request start time: Sat Sep 03 2112 12:09:03 GMT+0900 (JST)
request start time: Sat Sep 03 2112 12:09:03 GMT+0900 (JST)
events.js:71
throw arguments[1]; // Unhandled 'error' event
^
Error: socket hang up
at createHangUpError (http.js:1264:15)
at Socket.socketCloseListener (http.js:1315:23)
at Socket.EventEmitter.emit (events.js:126:20)
at Socket._destroy.destroyed (net.js:357:10)
at process.startup.processNextTick.process._tickCallback (node.js:244:9)
var http = require('http');
var counter = 0;
var maxreq = 100;
var server = http.createServer(function(req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
});
server.maxConnections = 3;
server.listen(8080, function() {
doBench();
});
function doBench() {
var opts = {hostname: 'localhost', port: 8080, method: 'GET', path: '/'};
function get() {
var req = http.request(opts, function(res) {
res.on('end', function() {
if(++counter == maxreq) server.close();
});
});
req.once('socket', function(socket) {
socket.once('connect', function() {
console.log('request start time:', new Date());
});
});
req.end();
}
for(var i = 0; i < maxreq; i++) {
get();
}
}
課題1
エラーやwarningが表示されずに100回HTTPリクエストが正常に完了するようコードを修正しなさい。
- 注:「request start time」は100回分表示されるようすること
- 注:エラーの行番号はNodeのバージョンで異なります。新しいバージョン(たぶんv0.8.14以降)だと
socketOnEnd
でcreateHangUp()
します
課題2
最大同時接続数を100に制限した内部のHTTPサーバに対して1,000,000回HTTPリクエストを行い、平均req/sec結果を出力してプロセスが終了するベンチマークプログラムを作成しなさい。
ただし課題1のプログラムに縛られる必要はありませんが、メモリを効率的に利用するよう心がけできるだけ短い実行時間で終了すること。
回答
B君が作成した「http_bench.js」のコードですが、前半部分が
- 「Hello World」を返すだけのHTTPサーバをつくる
- 最大同時接続数を3にする
- ポート番号
8080
で、listen()
し、そのコールバックでdoBench()
を実行
後半のdoBench
が
-
maxreq
回、get()
を実行する -
get()
はopts
で定義されたリクエストを送り、レスポンスのend
イベントで、リクエストをmaxreq
回行ったかを判定し、最終的にHTTPサーバを終了させる - リクエストの
socket
イベントで割り当てられたsocket
オブジェクトのconnect
イベントが発生した時に、時刻をconsole.log()
で出力する。これらのイベントリスナーはonce()
で登録してある
という動作になっています。
さて、問題のエラーですが、出力結果から、
-
console.log('request start time:', new Date());
が5回実行された後にエラーになった - 「socket hang up」エラーが、
error
イベント付きでthrow
されたが、然るべきerror
イベントのリスナーがなかった(Unhandled)
の2つが読み取れます。
http.Agent
という世話係
HTTPリクエストを送るとき、一般に、ソケット数よりもリクエスト数の方が多いので、どのリクエストをどのソケットに割り当てるかを決めなければなりません。keep-alive
な接続のことも考えて、ソケットを再利用する機能も必要です。
http.Agent
は、そうした煩わしい処理を行なってくれるクラスです。http
モジュールを読み込んだときにglobalAgent
オブジェクトとして自動生成され、今回のコードでも暗黙のうちに利用しています。
さて、このデフォルトのhttp.Agent
といえるglobalAgent
オブジェクトは、最大ソケット数maxSockets
が5
に設定されています。「request start time」が5回表示された理由がここです。
「request start time」は、socket
オブジェクトのconnect
イベントが発生した時に表示するようにしていますから、maxSockets
の数だけソケットが接続されたということです。
……ちょっとおかしいですね。
作成したHTTPサーバは同時接続数を3に制限していたはずです。なぜ5個のソケットから接続できたのでしょうか?
HTTPサーバの同時接続数制限は「柔道の審判」
HTTPサーバに設定したmaxConnections
の処理は、net.jsの下記の部分にあります。
// 大幅に省略して転載
function onconnection(clientHandle) {
var handle = this;
var self = handle.owner;
if (self.maxConnections && self._connections >= self.maxConnections) {
clientHandle.close();
return;
}
self._connections++;
}
ソケット接続が行われた直後、そのソケットにHTTPサーバオブジェクトからアクセスできるようするonconnection()
メソッドで、同時接続数制限の処理を行います。ソ ケ ッ ト 接 続 が 行 わ れ た 直 後 に 同時接続数制限の処理を行います。
接続に成功した5個のソケットのうち、サーバの同時接続制限に引っかかった2個は、接続が切られます。ここがエラーの原因です。
クライアント側からすれば、接続が成功してconnect
イベントが起こったのに、直後に接続が切られてしまうわけです。これではまるで、「一本!」の判定が出たのに、技をかけたのが場外だったので「いまのはナシよ」と手を横にふる柔道の審判のようです。ぬか喜びです。ショックを引きずってはいけません。エラー処理を行い、試合を続行しましょう。
ちゃんと100回リクエストを完了するために
さて、ここまででエラー潰しの目処がたちました。いくつかの方法があります。
- リクエストを送りなおすエラー処理を行う
- HTTPサーバの最大同時接続数を5以上にする
- 最大ソケット数を3以下にした
http.Agent
(カスタムエージェント)を利用する
まず最初のエラー処理ですが、このエラーを投げたhttp
モジュールを確認します。
// 大幅に省略して転載
function createHangUpError() {
var error = new Error('socket hang up');
error.code = 'ECONNRESET';
return error;
}
function socketCloseListener() {
var socket = this;
var req = socket._httpMessage;
if (req.res && req.res.readable) {
// Socket closed before we emitted 'end' below.
req.res.emit('aborted');
} else if (!req.res && !req._hadError) {
// This socket error fired before we started to
// receive a response. The error needs to
// fire on the request.
req.emit('error', createHangUpError());
}
/**
注:Nodenのバージョン(たぶんv0.8.14以降)によっては、
エラーがsocketOnEnd()で起こります。
この場合も同様に
req.emit('error', createHangUpError());
されています。
*/
}
socketCloseListener()
のelse if
の部分です。レスポンスを受ける前にソケットが閉じてしまうというエラーです。ソースコード内のコメントにある通り、リクエスト上で取得できるようにreq.emit()
されます。
「Node.js入門」に書かれてますが、http.ClientRequest
がhttp.OutgoingMessage
を継承しているということを思い出すと、なるほど_httpMessage
と内部的に扱われているのかと納得できるように思います。
今回のB君のコードにこのエラー処理を追加する場合、
function doBench() {
var opts = {hostname: 'localhost', port: 8080, method: 'GET', path: '/'};
function get() {
var req = http.request(opts, function(res) {
res.on('end', function() {
if(++counter == maxreq) server.close();
});
});
req.once('socket', function(socket) {
socket.once('connect', function() {
console.log('request start time:', new Date());
});
});
// 'error'イベントのリスナ
req.on('error', function(error) {
// createHangUpError()でerrorオブジェクトが
// 返されてるので利用することもできる
console.log(error.code);
// 再度リクエスト(100回完了させるため)
get();
});
req.end();
}
/* 省略 */
となります。
「2. HTTPサーバの最大同時接続数を5以上にする」は、そのままなので割愛。
「3. 最大ソケット数を3以下にしたhttp.Agent
(カスタムエージェント)を利用する」は、下記のようになります。
function doBench() {
// 最大ソケット数を3にしたカスタムエージェントの作成
var myAgent = new http.Agent({maxSockets: 3});
var opts = {
hostname: 'localhost',
port: 8080,
method: 'GET',
path: '/',
agent: myAgent
};
function get() {
/* 省略 */
イベントリスナの登録数制限
「エラーを潰したぞ!動かしてみよう!」と思っていたあなたに、師範からさらなるプレゼント。
B君のコードを単純にエラーが出ないようにした場合、
(node) warning: possible EventEmitter memory leak detected. 11 listeners added.
と、その関連出力がズラーッと出てきたはずです。これは「Node.js入門 第6章」の内容ですが、デフォルトでは、
- オブジェクトごとに
- ひとつのイベントにくっつけられるイベントリスナは
- 10個まで
と、なっています。イベントリスナは10個を超えても登録はされますが、メモリリークの原因となりうるので、警告が表示されるわけです。
B君のコードでは、別々のリクエストオブジェクトが100個作られます。別々のオブジェクトなので、繰り返しリスナを登録していないはずです。一体何に対して、リスナを繰り返し登録しているのでしょうか。
socket
のconnect
イベントのリスナが怪しそうですが、once()
で登録しているのでconnect
イベントが起こるたびに削除され、2個以上にならないはずですが……。
keep-alive
再び
socket
のconnect
イベントは、ソケット接続が完了したときに起こりますが、keep-alive
な接続の場合、ソケット接続が維持されたまま再利用されるのでconnect
イベントは、各ソケットごとに1回しか発生しません。したがって、たとえonce()
で登録したとしてもリスナが消化されず、どんどん溜まります。
つまり、keep-alive
な接続の場合、
- イベントリスナの登録数制限の警告が出る
- 「request start time:」がソケットの数しか出力されない
という問題が発生します。この両方を解決する方法は、
- レスポンスヘッダに「Connection: close」を追加し、リクエストのたびにソケット接続を行う
-
connect
イベントを使わず、socket
イベントのときにconsole.log()
する
が挙げられます。1番と2番はどちらでも良いように思われます。
冴えた(?)別解
頭の体操のようなではありますが、一発でエラーも警告も出なくなる方法もあります。
-
maxConnections
とmaxSockets
を両方とも100以上にする(富豪解) -
http.Agent
はリクエストが溜まってなければソケットをdestroy
するので、適当にsetTimeout
でリクエストの生成を遅らす(暇人解)
「富豪解」については割愛します。
「暇人解」は、実質的にひとつのソケットしか使わないので、非実用的ではあります。ループでget()
を回す部分にこんな仕掛けをするか、
for(var i = 0; i < maxreq; i++) {
// sleep sort のイメージで
// localhostなので50msぐらい間隔があれば
// 大丈夫です
setTimeout(function(){
get();
},i*100);
}
ループではなくて、レスポンスのend
イベントで再帰的に呼び出すとよいでしょう。
res.on('end', function() {
if(++counter == maxreq) {
server.close();
} else {
setTimeout(function(){
get();
},100);
}
});
// どこかで最初のget()を呼ぶ必要があります
もっとも、ソケットのclose
イベントに引っ掛けて再帰的に呼び出すとやってることの意図が正確になります。
res.on('end', function() {
if(++counter == maxreq) server.close();
});
res.connection.on('close', function() {
// 101回目のプロポーズではなくて、クローズ対策
if(counter < maxreq) get();
});
// 最初のget()を別途呼ぶ必要があります
まだ、他にも頭の体操的な別解があるかもしれません。
課題1の回答例
最終的な課題1の回答例として、
- リクエストを送りなおすエラー処理を行う
-
connect
イベントを使わず、socket
イベントのときにconsole.log()
する
の方針のものを記載します。
var http = require('http');
var counter = 0;
var maxreq = 100;
var server = http.createServer(function(req, res) {
res.writeHead(200, {'Content-Type': 'text/plain'});
res.end('Hello World\n');
});
server.maxConnections = 3;
server.listen(8080, function() {
doBench();
});
function doBench() {
var opts = {hostname: 'localhost', port: 8080, method: 'GET', path: '/'};
function get() {
var req = http.request(opts, function(res) {
res.on('end', function() {
if(++counter == maxreq) server.close();
});
});
req.once('socket', function(socket) {
// 'connect'イベントは最初の1回しか起こらないので
// 毎回起こる'socket'イベントでログを取る
console.log('request start time:', new Date());
});
// 'error'イベントで再度リクエストを送る
req.on('error', function(error) {
console.log(error.code);
get();
});
req.end();
}
for(var i = 0; i < maxreq; i++) {
get();
}
}
後編に続く!
はい。
長くなりました。課題2について、覚えていますでしょうか。
課題2は同様に 100万回リクエストするベンチマーク を作って、「平均req/sec」のパフォーマンス指標を表示してみようというものです。
単純にmaxreq = 1000000
とすると、最初のループが100万回まわるのがNodeらしくないばかりでなく、一気に100万個のリクエストオブジェクトを登録することになるので、メモリの負担も大きくなります。
どうすれば、Nodeらしく非同期に100万回リクエスト出来るのでしょうか。ヒントは課題1の別解にあります。
弐の段:後編に続く。