Node.jsのイベントループを理解する

(訳注:2015/8/4、いただいた翻訳フィードバックを元に記事を修正いたしました。)

Nodeの”イベントループ”は高スループットのシナリオを操作する中枢で、ユニコーンや虹で満ちあふれているような魅力的な場所です。そしてこのイベントループのため、バックグラウンドで任意の処理の実行が可能でありながら、Nodeは本質的に”シングルスレッド”になるのです。この記事では、イベントループがどのような処理を行うのかを説明していきます。そうすれば皆さんも、この魔法を使いこなせるようになるでしょう。

イベント駆動型プログラミング

イベントループを理解するためにまず必要なのは、イベント駆動型プログラミングのパラダイムを理解することです。イベント駆動型プログラミングは、1960年代から広く知られてきました。現在は主にUIのアプリケーションに使用されています。JavaScriptでは主にDOMとのやりとりで利用されるので、イベントベースのAPIは自然なことでした。

簡単に定義すると、イベント駆動型プログラミングは、イベントや状態の変化によって決定されるアプリケーションのフロー制御です。一般的な実装には、中核機能があります。イベントをリッスンし、イベントが検出されたら(つまり状態が変化したら)コールバック関数を呼び出します。よく知っていますか? きっとそうでしょう。これが、Nodeのイベントループの背景にある基本的な原理です。

クライアント側のJavaScript開発に慣れている方のために、全ての.on*()メソッドについて考えてみましょう。例えばelement.onclick()は、ユーザのシステム操作を伝えるDOM要素と連動させて使われます。このパターンは、1つのアイテムから多数のイベントが発生する可能性がある場合にうまく機能します。NodeはこれをEventEmitterの形式で使用します。また、NodeはServerSocket'http'モジュールなどに置かれます。一つのインスタンスから複数の状態変化を発生させる必要がある場合に便利です。

これとは別の一般的なパターンは、処理が進められるかどうかを判断するものです。現在、2つの一般的な実装方法があります。1つ目は”エラーバック”のコールバックスタイルで、呼び出しのエラーが最初の引数としてコールバックに渡されます。2つ目はES6と一緒に登場したもので、Promisesを使用します。

'fs'モジュールでは、主にエラーバックのコールバックスタイルを使います。これはfs.readFile()のような、呼び出しに追加されたイベントの発生が技術的に可能です。しかしAPIは、望まれた操作が成功するか、何かを失敗した場合にのみユーザに通知するように作られています。このAPIを使うことを選択したのは構造上の判断であり、技術的な限界のせいではありません。

イベントエミッタは、それ単体では同期性がないとよく誤解されます。しかし、そんなことはありません。以下に挙げるのは、これを証明するためのコードスニペットです。

function MyEmitter() {
  EventEmitter.call(this);
}
util.inherits(MyEmitter, EventEmitter);

MyEmitter.prototype.doStuff = function doStuff() {
  console.log('before') 
  emitter.emit('fire') 
  console.log('after')
};

var me = new MyEmitter();
me.on('fire', function() {
  console.log('emit fired');
});

me.doStuff(); 
// Output: 
// before 
// emit fired 
// after

EventEmitterはしばしば、非同期性に見えます。というのも、これは通常、非同期処理が完了したことを知らせるシグナルに使われるからです。しかし、EventEmitterAPIは完全に同期性です。emit関数は非同期的に呼び出されるかもしれませんが、全てのlistner関数は、ある実行が次に続くemitの呼び出しを続ける前に、加えられる順に同期的に実行されることに注目しましょう。

メカニズムの概論

Node自体が、複数のライブラリに依存します。そのうちの1つはlibuvで、待ち行列の操作や非同期性イベントの処理を行う、魔法のようなライブラリです。心にとめておいてほしいのですが、この記事では、Nodeやlibuvに直接関連付けられたポイントが作られたかどうかを区別していません。

Nodeは、既に使用できるOSのカーネルを、最大限に活用します。要求を書く、接続を保つといったような動作の責任はシステムに任せているので、システムが統制しています。例えば、受け取った接続はNodeで扱われるようになるまで、システムによって待ち行列に加えられます。

Nodeにはスレッドプールがあると聞いたことがあるかもしれません。そして「もしもNodeが全ての動作の責任をシステムに押し付けるなら、どうしてスレッドプールが必要なんだろう?」と思ったかもしれませんが、それは、カーネルが全てを非同期に実行するわけではないからです。そのような場合、Nodeはオペレーションの持続時間でスレッドをロックするので、ブロックされることなくイベントループを実行し続けることができるのです。

次に挙げるのは、いつ何が行われるのかというメカニズムの概要を説明する簡単な図式です。

diagram
注釈
タイマー
  |
未解決のコールバック
  |
問い合わせ  ←  入力:接続、データなど
  |
Immediateの設定

イベントループの内部の仕組みについては、この図式には加えきれなかったのですが、いくつか覚えておかなくてはいけないことがあります。

  • process.nextTick()経由で予定される全てのコールバックは次の段階に移行する前に、イベントループの最終段階(例えばタイマー)で実行されます。これは、process.nextTick()の帰納的な呼び出しにより、予期せずにイベントループが途絶えてしまう可能性を作り出します。
  • “未解決のコールバック”とは、どの段階でも扱われない処理を実行するための待ち行列に加えられているコールバックのことです(例えば、fs.write()に渡されるコールバックがこれに該当します)。

イベントエミッタとイベントループ

イベントループとのやりとりを簡略化するために作られたのがEventEmitterです。EventEmitterは汎用的なラッパーで、これを使うと、イベントベースのAPIの作成が楽になります。Nodeとイベントの間の対話処理はどうあるべきなのか、あまりよく理解していない人もいるので、開発者がつまずきがちなこのポイントについて、ここで詳しく説明します。

次に示す例では、複数のイベントが同時に発生することを配慮していないために、ユーザが操作できないイベントが発生してしまいます。

// Post v0.10, require('events').EventEmitter is not necessary.
var EventEmitter = require('events');
var util = require('util');

function MyThing() {
  EventEmitter.call(this);

  doFirstThing();
  this.emit('thing1');
}
util.inherits(MyThing, EventEmitter);

var mt = new MyThing();

mt.on('thing1', function onThing1() {
  // Sorry, never going to happen.
});

上記のフローでは、ユーザは'thing1'をキャプチャできません。MyThing()が、イベントを監視する前にオブジェクトの生成を完了しなければならなくなるからです。この問題に対する単純な解決方法を以下に示します。クロージャを追加する必要すらありません。

var EventEmitter = require('events');
var util = require('util');

function MyThing() {
  EventEmitter.call(this);

  doFirstThing();
  setImmediate(emitThing1, this);
}
util.inherits(MyThing, EventEmitter);

function emitThing1(self) {
  self.emit('thing1');
}

var mt = new MyThing();

mt.on('thing1', function onThing1() {
  // Whoot!
});

また、以下のように修正しても、問題は解決できます。ただし実行時のパフォーマンスには大きな違いが生じます。

function MyThing() {
  EventEmitter.call(this);

  doFirstThing();
  // Using Function#bind() makes the world much slower.
  setImmediate(this.emit.bind(this, 'thing1'));
}
util.inherits(MyThing, EventEmitter);

さらに、エラー発生時の処理にも問題があります。アプリケーションの問題を考えるだけでもひと苦労ですが、コールスタックがなくなると、問題は解決できなくなります。遠く離れた非同期要求の側でErrorオブジェクトが生成されると、コールスタックは失われます。この問題を回避する合理的なソリューションは2つあります。エラーイベントを同時に発生させること、またはエラーと同時に他の重要な情報を伝播しているかどうかを確認することです。それぞれのソリューションを使用した例を以下に示します。

MyThing.prototype.foo = function foo() {
  // This error will be emitted asynchronously.
  var er = doFirstThing();
  if (er) {
    // The error needs to be created immediately to preserve
    // the call stack.
    setImmediate(emitError, this, new Error('Bad stuff'));
    return;
  }

  // Emit the error immediately so it can be handled.
  var er = doSecondThing();
  if (er) {
    this.emit('error', 'More bad stuff');
    return;
  }
}}

このソリューションを実行する状況を考えてください。アプリケーションの本来の処理を実行する前に、エラー処理を直ちに実行しなければならない事態になることがあります。ただしそれは、例えば引数の値が誤っている場合のように、報告の必要があるけれども後で容易に処理できる、ささいなことである可能性もあります。また、コンストラクタによって作成されるオブジェクトのインスタンスは、完成度があまり高くないので、これを使うのは名案とはいえません。この場合は、単純に例外をスローします。

まとめ

イベントループの技術的な概要とその内部の動作を非常に簡単に説明しましたが、これには意図があります。このテーマは将来的に再び取り上げるつもりなので、私たちの記事を読んでくださる方に一定レベルの基礎知識を持っていただきたかったのです。次の記事では、イベントループが既存のシステムのカーネルと対話を行って、Nodeがすばらしい非同期処理を実行する方法について述べる予定です。どうぞご期待ください。