ちょっとだけ Inside node.js

またしてもスケジュールきつめのプロジェクトに放り込まれた KrdLab です.Java ジャバしてます.IDE がないとコード書くのがしんどすぎて,もううんざりです.

はじめに

何らかのプラットフォームを利用する場合,その仕組みについて知っておくことは,より適切な設計を行うという目的に対して有用な情報となるでしょう.
というわけで,今回は少しだけ node.js の内部に潜ってみようと思います.対象は git repo から clone した 0.4.x です (2011/03/29 に clone したっぽい).
なお,今回はイベント駆動に焦点を当て,V8 については割愛します.

見出し

  • 全体像
  • メイン処理
  • イベントループで処理される各種 watcher
  • tick 系のイベント処理
  • gc 系のイベント処理
  • eio 系のイベント処理
  • IOWatcher によるソケットの READ/WRITE
  • Timer による setInterval/setTimeout

全体像

node.js は,イベント駆動をベースとした JavaScript エンジンです.処理は全て非同期に行われます.サーバサイド JavaScript として人気を集めており,ネットワーク系のプログラムを主なターゲットとしています.

モジュール構成としては以下のような感じです (c-ares,http_parser は省略).

libev によってイベントループを,libeio によって非同期 I/O 処理を実現しています.各ライブラリの連携は以下のようになります.


node.js プロセスには,イベントループを実行するメインスレッドと,I/O の実処理を行うワーカスレッドが存在します.ワーカスレッドは libeio によりスレッドプールとして管理されています.
このように,イベントループと各 I/O 実処理は並行処理されますが,コールバックは全てメインスレッド上のイベントループで処理されます.つまり,ユーザの書いた JavaScript はメインスレッド上でしか実行されず,これが「シングルスレッド」と表現される由縁にもなっています.
当然コールバック処理でブロックしてしまうと,イベントループもブロックしてしまいます.コールバック実装は「ブロックしないですぐ終わる」が基本方針となります.
なお,node.js 上のイベントは,libev の event watcher と呼ばれる ev_watcher 構造体で管理されます (libev に ev_idle,ev_async といった名前で定義されている).以降に出てくる 'watcher' はこの 'event watcher' のことを指し,'イベント' もこの watcher に対して発生する (そして,events.EventEmitter が生成するイベントの元になる) ものを指します.

メイン処理

エントリポイントである main 関数からは,node.cc に定義されている node::Start 関数が呼び出されます.

// node.cc:2420
int Start(int argc, char *argv[]) {
  v8::V8::Initialize();
  v8::HandleScope handle_scope;

  argv = Init(argc, argv);

  // Create the one and only Context.
  Persistent<v8::Context> context = v8::Context::New();
  v8::Context::Scope context_scope(context);

  Handle<Object> process = SetupProcessObject(argc, argv);

  // Create all the objects, load modules, do everything.
  // so your next reading stop should be node::Load()!
  Load(process);

  // TODO Probably don't need to start this each time.
  // Avoids failing on test/simple/test-eio-race3.js though
  ev_idle_start(EV_DEFAULT_UC_ &eio_poller);

  // All our arguments are loaded. We've evaluated all of the scripts. We
  // might even have created TCP servers. Now we enter the main eventloop. If
  // there are no watchers on the loop (except for the ones that were
  // ev_unref'd) then this function exits. As long as there are active
  // watchers, it blocks.
  ev_loop(EV_DEFAULT_UC_ 0);

  EmitExit(process);

#ifndef NDEBUG
  // Clean up.
  context.Dispose();
  V8::Dispose();
#endif  // NDEBUG
  return 0;
}

Init 関数では,引数の解析や signal handler の登録,デフォルトイベントループの初期化が行われた後,event watcher を初期化しています.*1

SetupProcessObject 関数では,おなじみの global object である process を組み立てています.

Load 関数では,src/node.js に定義された function を実行して各モジュールのロード,グローバルオブジェクトの生成を行い,ユーザの JavaScript の実行を開始します*2.ここでコールバックが登録され,後に何らかのイベントが発生すれば,このあと出てくるイベントループ (ev_loop) で処理されます.

次の ev_idle_start は,watcher によるイベント監視を開始させます.*3

ev_loop はイベントループの本体です.何らかのイベントが発生して backend_poll (epoll/kqueue) に引っかかれば,登録したコールバックが呼び出されます.単純な (イベント発生を気にしない) スクリプトであればループすることなく素通りします.

EmitExit 関数では process に対して 'exit' を emit します.JavaScript 側で process.on('exit', function() {...}) としていた場合は,コールバックとして function() {...} が呼び出されます.

イベントループで処理される各種 watcher

イベントループ内で処理されるのは,ev_xxx_start で active となった watcher や,I/O で read/write 可となった watcher,タイマ watcher 等に対応するコールバックです.Init 関数で用意される主な watcher には,以下の 3 種類があります.

  1. tick 系
    • prepare_tick_watcher/check_tick_watcher/tick_spinner は,process.nextTick で積まれたキューの消化を管理するために使用される.
  2. gc 系
    • gc_check/gc_idle/gc_timer は,GC のタイミング制御に使用される.
  3. eio 系
    • eio_poller/eio_want_poll_notifier/eio_done_poll_notifier は,fs モジュール API による非同期 I/O 処理のコールバック制御に使用される.

上記以外に,net モジュールは IOWatcher (lib/node_watcher) が管理する ev_io watcher を,setTimeout/setInterval は Timer (lib/node_timer) が管理する ev_timer watcher を利用しています.これらは API 利用時に適宜用意されます.

tick 系のイベント処理

tick 系は以下のイベントで処理されます.

  1. EV_PREPARE
    • イベントループの先頭で発生
  2. EV_CHECK
    • イベントループ最後で各コールバックを呼び出す直前で発生

tick 系 watcher によりコールバックされる関数は node::Tick 関数です.これにより,process.nextTick で登録したコールバックは定期的に処理されます.

// node.cc:221
static void Tick(void) {
  // Avoid entering a V8 scope.
  if (!need_tick_cb) return;

  need_tick_cb = false;
  ev_idle_stop(EV_DEFAULT_UC_ &tick_spinner);

  HandleScope scope;

  if (tick_callback_sym.IsEmpty()) {
    // Lazily set the symbol
    tick_callback_sym =
      Persistent<String>::New(String::NewSymbol("_tickCallback"));
  }

  Local<Value> cb_v = process->Get(tick_callback_sym);
  if (!cb_v->IsFunction()) return;
  Local<Function> cb = Local<Function>::Cast(cb_v);

  TryCatch try_catch;

  cb->Call(process, 0, NULL);

  if (try_catch.HasCaught()) {
    FatalException(try_catch);
  }
}

内容は,node.js に定義された 'process._tickCallback' を呼び出す,というものです.need_tick_cb のチェックや,tick_spinner に対する ev_idle_stop は,process.nextTick によって積まれたキューを消化するまで,イベントループへの参照保持を保証するための処置です.
process._tickCallback は以下のコードです.

  // node.js:161
  startup.processNextTick = function() {
    var nextTickQueue = [];

    process._tickCallback = function() {
      var l = nextTickQueue.length;
      if (l === 0) return;

      try {
        for (var i = 0; i < l; i++) {
          nextTickQueue[i]();
        }
      }
      catch (e) {
        nextTickQueue.splice(0, i + 1);
        if (i + 1 < l) {
          process._needTickCallback();
        }
        throw e; // process.nextTick error, or 'error' event on first tick
      }

      nextTickQueue.splice(0, l);
    };

    process.nextTick = function(callback) {
      nextTickQueue.push(callback);
      process._needTickCallback();
    };
  };

_tickCallback では,とにかくキューに積まれたコールバックを呼び出します.例外発生時は,成功したコールバックをキューから除去し,残りを再度処理するように _needTickCallback を呼び出します.
JavaScript 側で _needTickCallback が呼び出されると,前出の need_tick_cb が true となり,また tick_spinner に対して ev_idle_start が呼び出され,イベントループの参照カウントが増加します (その後の Tick 関数呼び出しで stop が呼び出され,増加した分のカウントは元に戻る).

gc 系のイベント処理

gc_check は EV_CHECK (前出),gc_idle は EV_IDLE (いわゆるプロセスの IDLE 状態),gc_timer は EV_TIMER (timeout) に対応する watcher です.gc_timer は,Init 関数で 5 秒間隔に設定されています.
gc_check のコールバックは node::Check 関数です.

// node.cc:175
static void Check(EV_P_ ev_check *watcher, int revents) {
  ...

  tick_times[tick_time_head] = ev_now(EV_DEFAULT_UC);
  tick_time_head = (tick_time_head + 1) % RPM_SAMPLES;

  StartGCTimer();

  for (int i = 0; i < (int)(GC_WAIT_TIME/FAST_TICK); i++) {
    double d = TICK_TIME(i+1) - TICK_TIME(i+2);
    //printf("d = %f\n", d);
    // If in the last 5 ticks the difference between
    // ticks was less than 0.7 seconds, then continue.
    if (d < FAST_TICK) {
      //printf("---\n");
      return;
    }
  }

  // Otherwise start the gc!

  //fprintf(stderr, "start idle 2\n");
  ev_idle_start(EV_A_ &gc_idle);
}

ev_now でイベント発生時のタイムスタンプを取得し,後半の for 文で GC を開始するかどうかの判断に使用します.StartGCTimer 関数は,gc_timer を active にすることで GC タイマを発動させます.for 文のところでは,直近のイベント発生間隔を調べ,全てが基準値 (FAST_TICK) よりも長ければ gc_idle を active にして,EV_IDLE 発生時にコールバックで GC を実行させます.

gc_timer のコールバックは CheckStatus 関数です.

static void CheckStatus(EV_P_ ev_timer *watcher, int revents) {
  ...

  // check memory
  if (!ev_is_active(&gc_idle)) {
    HeapStatistics stats;
    V8::GetHeapStatistics(&stats);
    if (stats.total_heap_size() > 1024 * 1024 * 128) {
      // larger than 128 megs, just start the idle watcher
      ev_idle_start(EV_A_ &gc_idle);
      return;
    }
  }

  double d = ev_now(EV_DEFAULT_UC) - TICK_TIME(3);

  //printfb("timer d = %f\n", d);

  if (d  >= GC_WAIT_TIME - 1.) {
    //fprintf(stderr, "start idle\n");
    ev_idle_start(EV_A_ &gc_idle);
  }
}

gc_idle がアクティブでない (GC 起動が確約されていない) 場合,メモリ使用量をチェックして合計が 128MB を超えていると gc_idle を active にします.また,3 つ前のイベント発生時刻から 4 秒以上経過していた場合にも gc_idle を active にします.

gc_idle のコールバックは node::Idle 関数です.

static void Idle(EV_P_ ev_idle *watcher, int revents) {
  ...

  if (V8::IdleNotification()) {
    ev_idle_stop(EV_A_ watcher);
    StopGCTimer();
  }
}

内部で V8::IdleNotification 関数を呼び出し,GC を実行します.V8::IdleNotification 関数は呼び出す必要がなくなると true を返すため,watcher (gc_idle) を stop,GC タイマも stop します.

eio 系のイベント処理

eio_poller は EV_IDLE (前出),eio_want_poll_notifier と eio_done_poll_notifier は EV_ASYNC (別スレッドから非同期に通知を受け取る) に対応する watcher です.それぞれ順に,node::DoPoll 関数,node::WantPollNotifier 関数,node::DonePollNotifier 関数がコールバックとして登録されています.
非同期 I/O 処理後のコールバック (JavaScript 側で I/O 処理後に呼び出されるよう登録した function) を呼び出すには eio_poll 関数を呼び出す必要があります.eio_poll 関数は主に node::DoPoll 関数から呼び出されます.node::DoPoll 関数は EV_IDLE のタイミングで処理されるわけですが,この制御には node::EIOWantPoll,node::EIODonePoll,node::WantPollNotifier,node::DonePollNotifier 関数が連携しています.



eio に積まれた I/O 要求が処理される (request queue が 0 になる) と,node::EIOWantPoll 関数が want_poll_cb としてワーカスレッド (メインとは異なるスレッド) から呼び出されます.内部では eio_want_poll_notifier にイベント発行しており,これによってメインのイベントループで node::WantPollNotifier 関数が呼び出されます.node::WantPollNotifier 関数では,eio_poll 関数でコールバックを 1 つ処理し,まだ残っていれば eio_poller に対して EV_IDLE を start させます.これにより,IDLE 状態になる度に eio_poll 関数が呼び出され,非同期 I/O 処理のコールバックが呼び出されていきます.
eio_poller とひも付いているのは node::DoPoll 関数です.内部では eio_poll 関数を呼び出し,コールバックがなくなれば eio_poller による EV_IDLE 監視を停止します.また別ルートとして node::EIODonePoll 関数があります.eio_poll 関数内部で response queue を消化しきると,node::EIODonePoll 関数が done_poll_cb として呼び出されます.内部では eio_done_poll_notifier にイベント発行しており,これによってメインのイベントループで node::DonePollNotifier 関数が呼び出されます.あとは node::DonePollNotifier 関数の中で 1 回だけ eio_poll 関数を呼び出し,コールバックを処理しきったことが確認できれば eio_poller の EV_IDLE 監視を停止します.
以上のように非同期 I/O 処理では,メインスレッドと eio がイベント通知によって連携をとり,eio_poll 関数の呼び出しを制御しています.

なお,実際の I/O 要求発行のあたりは,src/node_file.cc の 740 行目にある Read 関数を見ると良いかもしれません.ASYNC_CALL マクロによって eio API が呼び出されています.

IOWatcher によるソケットの READ/WRITE

ソケットの read/write は IOWatcher として機能提供されています.IOWatcher は EV_READ,EV_WRITE を監視します.net モジュールは IOWatcher を利用して net.Socket や net.Server を実現しています.*4

IOWatcher が READ/WRITE のどちらを監視するのかは,IOWatcher.prototype.set により決定します.

// lib/net.js:565
socket._readWatcher.set(socket.fd, true, false);
...
// lib/net.js:572
socket._writeWatcher.set(socket.fd, false, true);
// src/net_io_watcher.cc:132
Handle<Value> IOWatcher::Set(const Arguments& args) {
  ...
  IOWatcher *io = ObjectWrap::Unwrap<IOWatcher>(args.Holder());
  ...
  int fd = args[0]->Int32Value();
  ...
  int events = 0;

  if (args[1]->IsTrue()) events |= EV_READ;
  ...
  if (args[2]->IsTrue()) events |= EV_WRITE;

  assert(!io->watcher_.active);
  ev_io_set(&io->watcher_, fd, events);

  return Undefined();
}

(file discriptor, readable, writable) を引数とし,watcher にファイルディスクリプタと検出対象イベントを登録しています.

Timer による setInterval/setTimeout

setInterval は内部で Timer (src/node_timer.cc) を利用しています.

exports.setInterval = function(callback, repeat) {
  var timer = new Timer();

  if (arguments.length > 2) {
    var args = Array.prototype.slice.call(arguments, 2);
    timer.callback = function() {
      callback.apply(timer, args);
    };
  } else {
    timer.callback = callback;
  }

  timer.start(repeat, repeat ? repeat : 1);
  return timer;
};

Timer は ev_timer watcher を保持しており,指定された間隔 (上のコードで repeat) でコールバック (Timer::OnTimeout 関数) を呼び出します*5.Timer::OnTimeout 関数では,timer.callback に設定されたコールバック function を呼び出しています.


次に setTimeout ですが,これは setInterval とは仕組みが異なります.Timer を利用してはいますが,watcher の利用数を減らすために,同じ delay (下のコードで after) の callback をまとめて 1 つの Timer で管理しています.

exports.setTimeout = function(callback, after) {
  var timer;

  if (after <= 0) {
    // Use the slow case for after == 0
    timer = new Timer();
    timer.callback = callback;
  } else {
    timer = { _idleTimeout: after, _onTimeout: callback };
    timer._idlePrev = timer;
    timer._idleNext = timer;
  }

  ...

  if (arguments.length > 2) {
    var args = Array.prototype.slice.call(arguments, 2);
    var c = function() {
      callback.apply(timer, args);
    };

    if (timer instanceof Timer) {
      timer.callback = c;
    } else {
      timer._onTimeout = c;
    }
  }

  if (timer instanceof Timer) {
    timer.start(0, 0);
  } else {
    exports.active(timer);
  }

  return timer;
};

setTimeout の delay が 0 以下であれば Timer が使用されますが,それ以外の場合 (通常の利用はこちらの場合に該当) は 4 つのプロパティを持った timer オブジェクトを作成し,active に渡しています.

exports.active = function(item) {
  var msecs = item._idleTimeout;
  if (msecs >= 0) {
    var list = lists[msecs];
    if (item._idleNext == item) {
      insert(item, msecs);
    } else {
      item._idleStart = new Date();
      L.append(list, item);
    }
  }
};

setTimeout から流れてきた場合は必ず item._idleNext == item が成立するため,insert へと流れていきます.
insert は実際に list へ item を追加するところです.

function insert(item, msecs) {
  item._idleStart = new Date();
  item._idleTimeout = msecs;

  if (msecs < 0) return;

  var list;

  if (lists[msecs]) {
    list = lists[msecs];
  } else {
    list = new Timer();
    L.init(list);

    lists[msecs] = list;

    list.callback = function() {
      ...
      var now = new Date();

      var first;
      while (first = L.peek(list)) {
        var diff = now - first._idleStart;
        if (diff + 1 < msecs) {
          list.again(msecs - diff);
          debug(msecs + ' list wait because diff is ' + diff);
          return;
        } else {
          L.remove(first);
          assert(first !== L.peek(list));
          if (first._onTimeout) first._onTimeout();
        }
      }

      debug(msecs + ' list empty');
      assert(L.isEmpty(list));
      list.stop();
    };
  }

  if (L.isEmpty(list)) {
    // if empty (re)start the timer
    list.again(msecs);
  }

  L.append(list, item);
  assert(!L.isEmpty(list)); // list is not empty
}

指定された msecs に対して最初の要素であれば Timer を生成し,list の各要素に対して _onTimeout を呼び出す callback を設定します.最後に again (Timer::Again 関数に対応) を呼び出し,msecs 間隔で動作するよう再設定かつ Timer をスタートさせます.2 個目から後は単純に list へ要素として追加されていきます.

おわりに

今回は node.js の内部を追いかけてみました (Timer のあたりは追いかけ切れていませんが).libev によるイベント監視を駆使し,うまく連携している様子が見て取れます.また,内部を追いかけたことで node.js に対する理解が深まったため,もう少しうまくコード書けるようになるかなー,という淡い期待もありますw

そういえば,v0.6 から Windows サポート強化の一環として,IOCP 対応が入るそうです (http://nodejs.org/codeconf.pdf).そのうちそこら辺も調べてみたいと思います.

*1:ev_xxx_start の後 ev_unref を呼び出しているのは,イベントが積まれていなければすぐに ev_loop を抜けさせるための処置です.ここら辺のことは [http://pod.tst.eu/http://cvs.schmorp.de/libev/ev.pod:title=ev.pod] の ev_ref/ev_unref に記載されています.なお,eio_poller だけは Start 関数内で ev_idle_start したままになっていますが,対応する node::DoPoll でコールバックを処理しきると ev_idle_stop が呼び出されるため,やはり ev_loop は終了することになります.

*2:ユーザ JavaScript 実行の流れとしては,node.js の startup → Module.runMain → Module._load → Module.prototype.load → Module.prototype._compile → V8 へ,といった感じです.

*3:しかし,コメントを読む限りではテストを通すためのものっぽいですね.

*4:ソケットプログラミングでおなじみの socket, bind, listen といった関数は src/node_net で定義されています.

*5:当然ですが best-effort です