Node.jsとは

Node.jsは、JavaScriptをサーバサイドで実行するためのランタイム(実行環境)です。
Node.jsと聞くと、非同期処理が得意でリアルタイム性に優れており、スケーラブルで大量の接続を同時に捌くことができる、なんてイメージがあると思います。
本記事では、なぜ(Why)そのようなことができるのか、どのように(How)すればできるのか、を解説します。

シングルスレッドモデル

Webサーバでは、ほぼ同時に複数の要求が発生します。これを複数のスレッドを立てることで対応しようとする仕組みがマルチスレッドモデルです。Apacheなどは、これに該当します。
マルチスレッドモデルでは、ファイル操作のような重めのI/Oリクエストが発生した場合、一つのスレッドはその処理を行うために使用されますが、他のスレッドに余裕があれば、続くリクエストにも対応できます。
ただし、スレッド毎にメモリを割り当てるため、メモリ管理が複雑になったり、スレッドを切り替えるためにCPUを使用することが、ボトルネックになる場合があります。

一方で、シングルスレッドモデルで重めのリクエストが発生した場合、その処理が終わるまでは続くリクエストに対応できなくなります。すなわち、並列処理ができない、ということです。

Node.jsはシングルスレッドモデルであり、単一のメインスレッドで処理を行います。上図のようにメインスレッドが詰まった場合、アプリケーション全体のフリーズに繋がります。
しかしながら、Node.jsは、イベントループと非ブロッキングI/Oの機能により、この問題を解決します。

イベントループと非ブロッキングI/O

イベントループは、プログラムが開始すると、Node.jsによって自動的に開始されるNode.jsの心臓部です。
Node.jsで実行されるJavaScriptのコードはメインスレッド上のコールスタックと呼ばれる場所で順番に処理されます。
しかしI/Oに対しては、メインスレッドの外で実行することができる仕組みがあります。これは非ブロッキングI/O(Non-blocking Input/Output)と呼び、Node.jsの高速な処理を実現している機能となります。
ちなみにI/Oとは、以下のような処理に代表されます。

  • APIコール
  • データベースへのクエリ
  • ファイルの読み書き

処理の流れ

I/Oを必要とする場合、その処理を非ブロッキングI/Oとしてプログラムを記述することで、メインスレッドをブロッキングする(待機状態にする)ことなく処理を進めることができます。
はじめに、非ブロッキングI/Oを含む非同期処理が発生した場合、コールスタックでは開始のみが実行され、非同期処理が完了した後に実行されるコールバック関数がイベントループに管理されます。
非ブロッキングI/Oの場合、その処理はすぐにメインスレッドとは異なるワーカースレッド(ワーカープール)に委譲され実行されます。なお、ワーカースレッドはマルチスレッドであり、非同期I/Oが複数ある場合は複数のスレッドを利用できます。
その後、実行結果とコールバック関数がFIFO(First In First Out)のイベントキューに追加されます。
イベントループは、イベントキューを常に監視しており、イベントキューに実行可能なコールバック関数が登録された場合、コールスタックに同期処理が残っていないかを確認します。
残っていなければ、非同期処理のコールバック関数をコールスタックにプッシュし、実行します。

タイマーなどを用いたI/O以外の非同期処理の場合、コールバック関数がイベントループに登録された後、タイマー経過後にイベントループから直接イベントキューにコールバック関数が登録されます。後の処理は同様で、コールスタックが空になったことを確認したのち、イベントキューからコールバック関数をプッシュし、実行します。注意が必要なのは、タイマーを2秒に設定したとしても、コールスタックが空いていなければ、2秒後にコールバックを実行できないため、この非同期性から2秒後は保証されていない、ということです。

以上のように、ブロッキングが生じる処理については、非同期・非ブロッキングな処理としてコーディングしておくことで、メインスレッドをブロッキングさせないようにイベントループやワーカースレッドが裏側で処理し、高速なレスポンスを実現しています。すなわち、Node.jsは同期処理に対してはシングルスレッドですが、非同期処理に対してはマルチスレッドを使用することで処理を高速化しています。

非同期・同期処理の検証

では、実際に同期処理と非同期処理を行ったときにどれくらい処理の速度に差が出るか検証してみたいと思います。
まずは、以下のようにWebサーバを立てて、リクエストに対し同期的な処理としてreadFileSync(ファイルの読み取り)メソッドを3度実行した後、レスポンスを返します。ここでは大きめのテキストファイルを読み取ります。

const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
  const startTime = Date.now();
  if (req.url === '/block') {
    for (let i = 0; i < 3; i++) {
      fs.readFileSync('large_file.txt');
      if (i === 2) {
        res.writeHead(200, { 'Content-Type': 'text/plain' });
        res.end(`Finish (blocking): ${Date.now() - startTime}ms`);
      }
    }
  } 
  else {
    res.end('Invalid URL');
  }
});

server.listen(3000, () => {
  console.log('Server listening on port 3000');
});

Webサーバを起動し、レイテンシを確認してみると、140〜160[ms]前後となっています。

$ curl http://localhost:3000/block
Finish (blocking): 144ms
$ curl http://localhost:3000/block
Finish (blocking): 153ms
$ curl http://localhost:3000/block
Finish (blocking): 162ms

次に、非同期な処理としてreadFile(非同期のファイルの読み取り)メソッドを同様に3度実行した後、コールバック関数でレスポンスを返します。コードの構成は先ほどと全く同じです。

const http = require('http');
const fs = require('fs');

const server = http.createServer((req, res) => {
  const startTime = Date.now();
  if (req.url === '/non-block') {
    for (let i = 0; i < 3; i++) {
      fs.readFile('large_file.txt', (err, data) => {
        if (i === 2) {
          res.writeHead(200, { 'Content-Type': 'text/plain' });
          res.end(`Finish (non-blocking): ${Date.now() - startTime}ms`);
        }
      });
    }
  } 
  else {
    res.end('Invalid URL');
  }
});

server.listen(3000, () => {
  console.log('Server listening on port 3000');
});

以下のように約2倍近く、レイテンシが下がったことがわかります。

$ curl http://localhost:3000/non-block
Finish (non-blocking): 87ms
$ curl http://localhost:3000/non-block
Finish (non-blocking): 93ms
$ curl http://localhost:3000/non-block
Finish (non-blocking): 88ms

なぜこのようなことが起きたのか、この記事を読んだ人はきっとわかりますね。
readFileSyncメソッドが1回目→2回目→3回目とメインスレッドで同期的に実行されてメインスレッドをブロックしたのに対し、readFileメソッドは内部ワーカープール内のスレッドを利用して非同期に並行して実行されたからです。
すなわち、開発者がNode.jsのポテンシャルを最大限に引き出すためには、同期的な処理を行うメインスレッドがシングルスレッドであることを理解し、メインスレッドを重い同期処理でブロックしないようにコーディングする必要があります。

まとめ

Node.jsがシングルスレッドでありながら、高パフォーマンスとスケーラビリティを実現できるのは、イベントループと非ブロッキングI/Oという強力な仕組みのおかげです。同期的な処理がメインスレッドをブロックし、アプリケーション全体の応答性を低下させるのに対し、非同期・非ブロッキングな処理を用いることで、I/O待ちの時間を有効活用し、多くのリクエストを効率的に処理できます。今回触れたNode.jsの裏側は開発者が本来知っておくべき知識であり、意識してコーディングすることが重要です。