Node.js
Thread
Cluster
libuv

Node.jsだってmulti-thread/multi-processできるもん。

「Node.jsはシングルスレッドだから、プロダクションで使うのはリスクだ」みたいな発言に対するアンサー(今更感)

Node.jsは戦略的にシングルスレッド・シングルプロセスを採用しているわけで、
しかも任意にマルチプロセス化が可能だし、重いデータを扱う際は内部(libuv)で勝手にマルチスレッド化して処理される。

今のシリコンバレーではNode.js経験者の需要が高い、なんて話も聞く。
開発効率の良いNode.jsでプロトタイプを作り、リリースを繰り返すことがベンチャーにとって重要だからだ。
TwitterやGoogleレベルならともかく、ベンチャーレベルのtrafficで、パフォーマンス的な問題が出たという話は聞いたことがない。

なお、プロダクションで使うことを勧める記事ではありません。
またECMAScriptのSharedArrayBufferなどの話は無視します。

前段

Node.jsではC10K問題への対策として、
- ノンブロッキングI/O + イベントループ(fd監視)
- 非同期I/O
を採用している。

要はディスクI/OやネットワークI/OなどCPUサイクルの多い(=待ち時間の長い)処理に対して、
同期(ブロッキング)I/Oのように戻り値がくるまでアプリケーションのコード実行を止めてしまうのではなく、
カーネル側にやっといてねーとお任せしちゃって他に実行可能な処理があれば先に処理してしまい、
コードが下まで実行された後に回るイベントループ内で、カーネルから終わったよ通知を受けて処理する、という考え方だ。

これにより複数のI/Oをシングルスレッドで扱える利点が生まれた。
マルチスレッドの小難しい並列プログラミング(競合状態の解決とかコンテキストスイッチによるロスとかメモリ消費とか)をせずとも、シングルスレッドで複数I/Oを捌ける仕組みを採用したのである。

Node.js single-thread

merit

  • マルチスレッドのように競合状態・コンテキストスイッチングのコストを考える必要がない
  • マルチプロセスと比べて効率的なメモリ利用
    • プロセス分だけクライアントを捌く方式だと、1プロセス立ち上げるのに必要なメモリ量が多い
  • マルチプロセスのプロセス数は、32767までと制約がある(C10K問題)
  • 他の言語でイベントループを実装をしても同じことができるが、ブロッキングI/Oを利用したライブラリを使ってしまうと破綻する。Node.jsでは言語レベルで非同期ノンブロッキングI/Oなため、ブロッキングI/Oが混在することはない。

demerit?

  • シングルスレッドで動かしていると、エラーがあるとサーバー全体が落ちる?
    • マルチスレッドの内の一つでエラーが起きても普通はサーバーごと落ちるはずなので条件は一緒。例外はerlangくらい
    • いずれにせよ、エラーハンドリングや永続化対応で対策できる
  • メモリリークしたときにサーバー全体が膨れあがる?
    • 参照渡しのjsにおいて、メモリリークするのは設計・コーディングに問題がある気がする
    • 1リクエストのサイズの限界や、DBやファイルから取り出せるデータ量の限界は事前にテストできるはず
  • マシンスペックを使い切れない?
    • Clusterを使おう

Node.js multi-thread

Node.jsは、実は必要に応じてマルチスレッド化している。
主にfsやcryptoなど、localで重い処理を行う時に内部(libuv)でマルチスレッドで処理される。その際、利用者側でスレッドを意識する必要はない。

  • defaultで4つの待機スレッド
    • ex, 5ファイルを処理する場合、4つは並列処理され、5個目への処理はqueueに溜められ、スレッドが空いたら処理されるイメージ
    • 環境変数UV_THREADPOOL_SIZEで増量可能
      • 複数ファイル・重いファイルを扱うならこの調整で処理性能が向上するかも
      • 最大128コまで
    • 重い処理では待機スレッドをブロッキングI/Oで実行し、I/O処理が終わるとメインスレッドで結果を非同期に受け取れる仕組み
      • write処理は非同期にはできないので同期(ブロッキング)I/Oになる
  • スレッドはfsの処理開始時にpoolされるので生成・削除のオーバーヘッドは少ない
$cat no_threadpool.js
console.log('pid=' + process.pid);
while(1){}

$cat threadpool.js
const fs = require('fs');
const readStream = fs.createReadStream('/dev/zero');
const writeStream = fs.createWriteStream('/dev/null');

console.log('pid=' + process.pid);
readStream.pipe(writeStream);
$node no_threadpool.js
pid=19606
# 別ttyで確認
## 下記はlinuxの例なのでSPIDがthread番号(macなら`ps -M (pid)`で表示は違うが確認可)
$ps -Tm 19606
  PID  SPID TTY      STAT   TIME COMMAND
19606     - pts/1    -      0:00 node no_threadpool.js
    - 19606 -        Sl+    0:00 -
    - 19619 -        Sl+    0:00 -
    - 19620 -        Sl+    0:00 -
    - 19621 -        Sl+    0:00 -
    - 19622 -        Sl+    0:00 -
    - 19623 -        Sl+    0:00 -
# 通常時

$node threadpool.js
pid=19650
$ps -Tm 19650
  PID  SPID TTY      STAT   TIME COMMAND
19650     - pts/1    -      0:03 node threadpool.js
    - 19650 -        Rl+    0:03 -
    - 19663 -        Sl+    0:00 -
    - 19664 -        Sl+    0:00 -
    - 19665 -        Sl+    0:00 -
    - 19666 -        Sl+    0:00 -
    - 19667 -        Sl+    0:00 -
    - 19668 -        Sl+    0:00 -
    - 19669 -        Sl+    0:00 -
    - 19670 -        Sl+    0:00 -
    - 19671 -        Sl+    0:00 -
# 確かにthreadが4つ増えている

$UV_THREADPOOL_SIZE=50 node threadpool.js
pid=19683
$ps -Tm 19683 | wc -l
58
# 確かにthreadが50コ増えている

Node.js multi-process

人為的にマルチプロセスで動かしたければclusterchild_processのfork()を使えばよい。

特にWeb APIサーバーとしてNode.jsを使っているならclusterは絶対利用すべき。
シングルで動いていた処理が独立して各コア上で動くことになるので、単位時間当たりに捌ける量がコア数倍になる。

cluster

コア数分プロセスを同時起動し、masterプロセスがworker(=アプリケーション)プロセスへロードバランシングする例を示す。

A. 下準備

# 既存のアプリケーションの例として、express-generatorでAPIサーバーを作る
$yarn add express-generator
$./node_modules/.bin/express
$yarn upgrade
$ls
app.js       node_modules public       views
bin          package.json routes       yarn.lock

B. cluster化。下記1ファイル用意すればOK

$cat master.js
const cluster = require('cluster');
const CPUs = require('os').cpus().length;

if (cluster.isMaster) {
  console.log(`master: ${process.pid}`); // 確認用
  cluster.setupMaster({
    exec: './bin/www',
  })

  for (let i = 0;i < CPUs - 1;i++) { // workerをコア数 - 1コ作成
    const worker = cluster.fork();
  }
}

C. Bで準備完了だが確認表示を仕込む
- listeningのイベントリスナーに仕込む
- アクセス時の確認にmorganへ仕込む

$cat ./bin/www
(中略)
function onListening() {
  var addr = server.address();
  var bind = typeof addr === 'string'
    ? 'pipe ' + addr
    : 'port ' + addr.port;
  debug('Listening on ' + bind);
  console.log(`worker: ${process.pid}`); // 追加
}
$cat app.js
(中略)
app.use(logger(':method :url :response-time ms pid=' + process.pid)); // 改変
(中略)

D. 起動確認: masterが立ち上がった後、workerが別psでlisten状態に

$node master.js
master: 6407
worker: 6408
worker: 6410
worker: 6409

# アクセスする度にラウンドロビンに異なるworkerで処理されていることを確認
$curl "http://localhost:3000/"
# node master.jsを起動しているttyに下記出力
# GET / 422.535 ms pid=6408
# GET / 383.602 ms pid=6410
# GET / 30.502 ms pid=6409
# GET / 34.826 ms pid=6408

これだけでもAPIサーバーがコア数分スケールアウトしたのと同等の効果が得られる。

※マルチ「プロセス」なので、マルチスレッドのように子と親でヒープを共有したりはしない。
プロセス間のデータ共有が必要であれば、外部に共有ストレージ(Redis)を立てるといった工夫が必要となる。

※実際にNode.jsをプロダクションで使うのであればclusterによるスケーリングは必須。もし既に稼働しているコードをcluster化する場合は、Node.js以外の部分で問題が起きないかテストすること。

終わりに

開発初期の段階から将来的な大規模スケーリングを見越す必要があるのなら、
言語レベルで並列処理をカバーしているGoやerlangを採用するべきだろうが、
普通のWebサーバーとしてNode.jsを使う分にはシングルスレッドであることがネックになることは少ないと思う。

リスクがあるとしたら、Node.jsではなくて、使う側の実装だろう。
Eventloopを理解してなくてメモリリークっぽい挙動に陥っているとか、clusterを使ってないとか、心当たりがあれば実装の見直しをお勧めします。

reference