LoginSignup
60
52

More than 5 years have passed since last update.

【弐の段:前編】Node.js道場1stシーズン課題プレイバック

Last updated at Posted at 2013-01-24

この投稿は「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)
http_bench.js
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以降)だとsocketOnEndcreateHangUp()します

課題2

最大同時接続数を100に制限した内部のHTTPサーバに対して1,000,000回HTTPリクエストを行い、平均req/sec結果を出力してプロセスが終了するベンチマークプログラムを作成しなさい。

ただし課題1のプログラムに縛られる必要はありませんが、メモリを効率的に利用するよう心がけできるだけ短い実行時間で終了すること。

回答

B君が作成した「http_bench.js」のコードですが、前半部分が

  1. 「Hello World」を返すだけのHTTPサーバをつくる
  2. 最大同時接続数を3にする
  3. ポート番号8080で、listen()し、そのコールバックでdoBench()を実行

後半のdoBench

  1. maxreq回、get()を実行する
  2. get()optsで定義されたリクエストを送り、レスポンスのendイベントで、リクエストをmaxreq回行ったかを判定し、最終的にHTTPサーバを終了させる
  3. リクエストのsocketイベントで割り当てられたsocketオブジェクトのconnectイベントが発生した時に、時刻をconsole.log()で出力する。これらのイベントリスナーはonce()で登録してある

という動作になっています。

さて、問題のエラーですが、出力結果から、

  1. console.log('request start time:', new Date());が5回実行された後にエラーになった
  2. 「socket hang up」エラーが、errorイベント付きでthrowされたが、然るべきerrorイベントのリスナーがなかった(Unhandled)

の2つが読み取れます。

http.Agentという世話係

HTTPリクエストを送るとき、一般に、ソケット数よりもリクエスト数の方が多いので、どのリクエストをどのソケットに割り当てるかを決めなければなりません。keep-aliveな接続のことも考えて、ソケットを再利用する機能も必要です。

http.Agentは、そうした煩わしい処理を行なってくれるクラスです。httpモジュールを読み込んだときにglobalAgentオブジェクトとして自動生成され、今回のコードでも暗黙のうちに利用しています。

さて、このデフォルトのhttp.AgentといえるglobalAgentオブジェクトは、最大ソケット数maxSockets5に設定されています。「request start time」が5回表示された理由がここです。

「request start time」は、socketオブジェクトのconnectイベントが発生した時に表示するようにしていますから、maxSocketsの数だけソケットが接続されたということです。

……ちょっとおかしいですね。

作成したHTTPサーバは同時接続数を3に制限していたはずです。なぜ5個のソケットから接続できたのでしょうか?

HTTPサーバの同時接続数制限は「柔道の審判」

HTTPサーバに設定したmaxConnectionsの処理は、net.jsの下記の部分にあります。

node/lib/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回リクエストを完了するために

さて、ここまででエラー潰しの目処がたちました。いくつかの方法があります。

  1. リクエストを送りなおすエラー処理を行う
  2. HTTPサーバの最大同時接続数を5以上にする
  3. 最大ソケット数を3以下にしたhttp.Agent(カスタムエージェント)を利用する

まず最初のエラー処理ですが、このエラーを投げたhttpモジュールを確認します。

node/lib/http.js
// 大幅に省略して転載

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.ClientRequesthttp.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個作られます。別々のオブジェクトなので、繰り返しリスナを登録していないはずです。一体何に対して、リスナを繰り返し登録しているのでしょうか。

socketconnectイベントのリスナが怪しそうですが、once()で登録しているのでconnectイベントが起こるたびに削除され、2個以上にならないはずですが……。

keep-alive再び

socketconnectイベントは、ソケット接続が完了したときに起こりますが、keep-aliveな接続の場合、ソケット接続が維持されたまま再利用されるのでconnectイベントは、各ソケットごとに1回しか発生しません。したがって、たとえonce()で登録したとしてもリスナが消化されず、どんどん溜まります。

つまり、keep-aliveな接続の場合、

  1. イベントリスナの登録数制限の警告が出る
  2. 「request start time:」がソケットの数しか出力されない

という問題が発生します。この両方を解決する方法は、

  1. レスポンスヘッダに「Connection: close」を追加し、リクエストのたびにソケット接続を行う
  2. connectイベントを使わず、socketイベントのときにconsole.log()する

が挙げられます。1番と2番はどちらでも良いように思われます。

冴えた(?)別解

頭の体操のようなではありますが、一発でエラーも警告も出なくなる方法もあります。

  1. maxConnectionsmaxSocketsを両方とも100以上にする(富豪解)
  2. 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()する

の方針のものを記載します。

http_bench2.js
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の別解にあります。

弐の段:後編に続く。

60
52
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
60
52