Node.jsの非同期I/Oについて調べてみた

こんにちは、本記事は リクルートライフスタイル Advent Calendar 2019 13 日目の記事です。今日は sadnessOjisan がやっていきます。この記事では Node.js の非同期 I/O について調べたことを紹介します。

調べようと思ったきっかけは、先日の JSConfJP で Wrap-up: Runtime-friendly JavaScriptというランタイムレベルでの最適化を解説したセッションを見て Node.js の理解を深めたいと思ったからです。私は Node.js でのコーディングは多少経験がある程度なので、まずは Node.js の大きな特徴である非同期 I/O からキャッチアップすることにしました。

Node.js の大きな特徴

Node.js は公式の説明を借りると、スケーラブルなネットワークアプリケーションを構築するために設計された非同期型のイベント駆動の JavaScript 環境です。その大きな特徴としては、

  • シングルスレッドで動作すること
  • I/O が非同期であること

があげられます。

シングルスレッドで動作するとは

Node.js はシングルスレッドで動作します。一つのスレッドしか使えないということは、一度に一つの処理しか実行できず非効率そうにも見えます。なぜシングルスレッドで動くように設計されたのでしょうか。

スレッドとは

スレッドは CPU 利用の単位です。CPU 利用の単位としては、プロセスとスレッドがあります。関係でいうと、実行中のプログラムをプロセスと呼び、プロセスは一つ以上のスレッドを持ちます。

複数のスレッドで動くとは

サーバーは複数リクエストをあつかうときに、利用するプロセスやスレッドを増やすことで、それぞれのリクエストが要求する処理を同時に行うことができます。

マルチスレッドでリクエストを捌く

しかし、あまりにも多くのリクエストが届くと、その分だけプロセス/スレッドが作成されて、メモリの消費量が多くなったり、コンテキストスイッチによって、パフォーマンスが落ちる可能性があります。これは C10K 問題として知られている問題です。

Node.js が注目された理由

Node.js はこの C10K 問題を解決できるものとして注目されていました。Node.js はマルチスレッドで実行するのではなく、シングルスレッドで実行するため、C10K 問題を解決することができます。しかし、シングルスレッドで動かすことは、処理の同時実行を考えると非効率な実行方法にも見えます。そこで Node.js は処理をスレッドの数に分散させるのではなく、時間軸で分散することで同時実行できない問題の解決を図っています。

シングルスレッドで動かす鍵は 非同期 I/O

時間軸による処理の分散を可能にするために Node.js では非同期 I/O を利用します。

I/O とは

アプリケーション外部との入出力処理を I/O といいます。例えばファイルからデータを読み取る処理や、Socket からデータを読み込む処理を指します。

I/Oとは

一般的にファイル I/O やネットワーク I/O は Memory I/O に比べると遅く、またデータを外部から読み取る以上は読み取り結果を取得するための待ち時間も発生したりするため、I/O が発生するとその処理は遅くなります。

さらにその処理をシングルスレッドで動作させると、後続の処理も遅れます。特にサーバーにおいてはある人のリクエストによって I/O が発生すると、後からリクエストした他の人の処理が遅れるため、パフォーマンス上の問題にもなりやすいです。そのため Node.js では I/O による処理のブロックを避けるデザインがされています。

非同期 I/O

私は Node.js を初めて書いたとき、このようなコードを書いて、データを読み取れなかった経験があります。

1
2
3
4
5
var input;
fs.readFile("/sample.txt", "utf8", function(err, data) {
  input = data;
});
console.log(input);

これは、data を読み取って input に代入する前に console.log(input) が実行されているから起きる現象です。つまり、I/O の完了を待たずに console.log(input) が実行されています。I/O による待ち時間は発生しておらず、後続処理をブロックしていません。このように Node.js では後続処理をブロッキングしない非同期 I/O として利用できます。

処理の分散

Node.js はこの非同期 I/O を駆使して、時間軸で処理を分散することができます。

Reactor Pattern による I/O 対応

処理を時間軸で分散させるために、Node.js では I/O の完了を待たずに後続処理を実行させます。そして、I/O の結果に紐づいた処理の実行は、I/O が完了したかどうかを知り、完了していたらデータを取り出して処理を実行するという方式で実行されます。これを実現するためには I/O の完了ステータスをアプリケーションが知る必要があります。

I/O の完了を監視する

では、どのようにして I/O が完了したかのステータスを知るのでしょうか。単純に考えると、I/O の完了を監視すれば良さそうです。ファイルを読み取ることを考えましょう。ファイルの中身を出力するためには次のステップを踏みます。

  1. ファイルへの read を要求する
  2. ファイルが read 可能になる
  3. ファイルの中身を読み取る

単純に考えると、この処理を実現するためには無限ループを用いて I/O の完了ステータスを監視し、処理が可能になることを待つ方法が考えられます。

処理の分散

しかし I/O が完了したかを判定する処理をループでずっと実行することは、計算資源を非効率に使っているため推奨される方法ではありません。これは Busy Waiting とも呼ばれ避けるべき実装方法です。

Reactor Pattern

そこで、Node.js では Reactor Pattern と呼ばれる方法で、複数の I/O を処理します。このパターンでは I/O が完了したかを監視するために、OS から提供されている監視機能を使います。

イベント多重分離

  1. I/O が要求される、そのときに完了時の処理も受け取る
  2. I/O が完了していなくても、アプリケーション側に処理を戻す
  3. I/O が完了すると、完了時にすべき処理を queue に入れる
  4. event loop がその queue から処理を取り出し実行する

I/O の完了はどう判定しているのか

上の Reactor Pattern では、 I/O Event Demultiplexing(イベント多重分離)というテクニックを使って、処理を非同期に実行しています。

Event Demultiplexing はどのようにして実現するのか

Event Demultiplexer は無駄な while ループを作らなくても I/O の結果を監視できる機構です。しかしこの Event Demultiplexer は説明で使われる概念的なものであり、その実体は OS が用意しているイベント通知インターフェースを指します。例えばファイル I/O を監視する場合、MacOS なら kqueue を利用できます。

kqueue を使えば、例えばファイルへの書き込みはこのようにして監視できます。

1
2
EV_SET(&kev, fd, EVFILT_VNODE, EV_ADD, NOTE_WRITE, 0, NULL);
ret = kevent(kq, &kev, 1, NULL, 0, NULL);

(https://github.com/sadnessOjisan/kqueue_fileio)

このように、「I/O が完了したときになになにする」といった監視やハンドラの実行は、OS の機能を使うことで実現できます。OS からイベントの完了通知を受け取るため、I/O 完了の判定のための while ループを用意する必要もありません。

OS の機能を呼べば Reactor Pattern を実装できるのか

kqueue を使うことで I/O の監視ができることが確認できました。そのためあとはイベントループを用意すれば、 Reactor Pattern を実現できそうです。しかし、この kqueue は BSD 系の OS(Mac 含む) 以外にはなく、kqueue はどのプラットフォームでも使えるものではありません。そこで Node.js ではこのようなイベント通知の仕組みを色々なプラットフォームで利用できるようにしてくれているライブラリ libuv を利用します。

libuv

libuvは公式の説明に、'libuv is a multi-platform support library with a focus on asynchronous I/O.‘ とあり、マルチプラットフォームでの非同期 I/O 提供するライブラリです。しかし、非同期 I/O を抽象化しただけでなく、イベントキューやイベントループも提供しているため、Reactor Pattern をさまざまなプラットフォームで実現できるようにしたライブラリとも言えるでしょう。

libuv はどのようにイベントループを提供しているのか

libuv にはuvbookというとても丁寧な libuv のユーザーガイドがあります。これを参考にしてみましょう。

libuv は非同期 I/O だけでなく、それ自体がイベントループを提供しています。このようにすればイベントループを実行できます。

1
uv_run(uv_default_loop(), UV_RUN_DEFAULT);

それでは I/O を監視させてみましょう。libuv では、このループの中で「こういう I/O 要求があったときには、こうしてほしい」という watcher を登録できます。

1
uv_fs_read(uv_default_loop(), &readReq, req->result, &uvBuf, 1, -1, onRead);

ここではファイル書き込みがあったときに、onRead 関数を実行するように登録しました。コールバック関数を登録するような感じでなんとなく Node.js の雰囲気を感じます。

(https://github.com/sadnessOjisan/uv_read)

まとめ

  • Node.js の特徴はシングルスレッドでの動作と非同期 I/O
  • シングルスレッドでも複数のリクエストを捌くアイデアとして イベント駆動形式を採用している
  • Reactor Pattern は OS の機能で実現されるが、それをどの OS でも Reactor Pattern を実現するために libuv を利用している

参考文献