Node.js で重い処理をしてしまったときにタイムアウトするの法 (TerminateExecution 編)

ちょっと前にこんなブログが書かれていました.

なぜ JavaScript Advent Calendar 2011 (Node.js/WebSocketsコース) に参加してくれなかったのか問い詰めたい.小一時間問い詰めたい.それはともかくとして,本題の懸念については誰もが一度は思うことですよね.ずいぶん前に話題になったこちらのエントリでも,赤文字で超目立つように問題としてあげられています.

でもまぁ,自分はどちらかというと「ブロックしちゃうような処理とか書かないし!!」みたいなことを顔を真っ赤にして答える方かな.タイムアウトするほどではなくても,イベントループを止めるような重い処理は避けるべきだから.普段からループは小さく分けてイベントループに戻るようにしておくべきだし,それを簡単にというか何も考えなくてもそうなるようにしてもいいくらいでしょう.


とはいえ,Node.js 日本ユーザグループの ML でも最近投稿があったように,数秒間ブロックするような処理を書いておいて,その間他のリクエストが処理できないからクラスタで複数プロセス立てて回避するという,マルチスレッド脳そのまんまな人たちも参入してくるほどに Node が普及しつつあるというのも現実です.たくさん不幸な人たちが出てきちゃいそうですねっ.
なので,長時間実行しているスクリプトというかコールバックをタイムアウトさせる方法を Node が提供すべきなのでしょう.しかし,これまではいくつかの障害があってそれは困難でした (少なくとも自分には).

V8 の問題

一つ目の障害は JavaScript の実行エンジンである V8 の制約です (自分は V8 は全然詳しくないので以下には多くの間違いがあるかもしれません).Node の前の安定版である v0.4 が採用していた V8 v3.4 まで,V8 は本格的なマルチスレッドには対応していませんでした.全く対応していなかったわけではありませんが.


v3.4 (もしかしたら v3.5) までの V8 は,プロセス中で VM が一つしか存在できませんでした.その VM を複数のスレッドから使うことはできましたが,ある瞬間に VMスクリプトを実行することができるのは実行権 (Locker によって管理されます) をもつスレッド一つだけに限られていました.あるスレッドでコールバックを呼び出してすぐに戻る,別のスレッドでコールバックを呼び出してすぐに戻る,みたいな場合はそれなりにうまく動くかもしれませんが,一つのスレッドが時間のかかるスクリプトを実行すると,他のスレッドはそれが終了するまで待たされてしまいます.協調的 (co-operative) なマルチスレッドに近い感じですね (ネイティブスレッドを複数使うにも関わらず!).
それじゃあんまりだよねってことなのか,VM の実行権を強制的 (preemptive) に切り替えることも可能です.そのために ContextSwitcher というスレッドが使われます.これを使っても,ある瞬間に VMスクリプトを実行できるのは一つのスレッドだけです.ただ,その実行が終了する前に他のスレッドに実行権を譲る仕掛けが提供されるだけ.
そんな時代から,実行中のスクリプトを強制終了する API が提供されていました.

  • static void TerminateExecution(int thread_id)

当然ですよね,Chrome とかで必要でしょうから.しかしこれ,コメントにはこう書いてあります.

   * TerminateExecution should only be called when then V8 lock has
   * been acquired with a Locker object.  Therefore, in order to be
   * able to terminate long-running threads, preemption must be
   * enabled to allow the user of TerminateExecution to acquire the
   * lock.

なんということでしょうか,TerminateExecution(int) を呼び出すには,V8 の実行権 (Locker) を持ってないといけないのです.そしてそのためにはスクリプトを実行しているスレッドは (ContextSwitcher スレッドを使って) preemptive に実行権を譲らないといけない...
言うまでもなく,ここでスクリプトを実行しているスレッドというのは Node のメインのスレッドのことです.それに対して余計な割り込みをかけなきゃいけないようだと,タイムアウトする機能を実装したところで Ryan に受け入れてもらうことは無理だっただろうと思います.実際のオーバーヘッドがどれくらいか測定したわけじゃないけど,きっといやがられるだろうな,と.


そんな状況も,Node の最新安定版 v0.6 に採用されている V8 v3.6 から (もしかしたら v3.5 かも) 変わりました.Isolate が導入されたのです.Isolate により,プロセスは独立した VMインスタンスを複数持つことができるようになりました.一つの Isolate は従来の VM そのまんまな感じで,複数のスレッドで同じ Isolate を使うことはできますが,その場合ある瞬間にその Isolate でスクリプトを実行できるスレッドは一つだけです.しかし,異なる Isolate ではそのような制限はなく,複数のスレッドはそれぞれの Isolate を同時 (simultaneously) に実行することが可能です.
そして新しいバージョンの TerminateExecution() も導入されました.

  • static void TerminateExecution(Isolate* isolate = NULL)

こちらのコメントにはこう書いてあります.

   * This method can be used by any thread even if that thread has not
   * acquired the V8 lock with a Locker object.

これですよ,欲しかったのは...
これによって,Node がスクリプトを実行しているスレッドとは (ほとんど) 無関係なスレッドからスクリプトの実行を強制的に終了することができます.やったね!!

エラーハンドリングの問題

もう一つの問題は,スクリプトの実行を終了した後にどうするか,です.
いくらなんでも強制終了した後何もなかったかのように知らん顔をするわけにはいかないでしょう.なんらかのエラー処理ができないと困ります.しかし,以前のエントリでも書いたように,従来 Node のエラーハンドリングは EventEmitter'error' か,process'uncaughtException' か? という二択しか無く,この場合は実質後者しかないという状況でした.それじゃ辛いよねー.


そんな状況も,Node の次期安定版で変わります.Domains の導入です.Domains については先日の東京 Node 学園でも紹介したのでそちらのスライドも参考にどぞー.

これにより,Domain ごとにタイムアウトの上限を設定し,それを超えて実行を打ち切った場合は Domain で 'error' イベントを生成すればいいことになります.
要するにこういうこと.

var myDomain = domains.create(null, function(arg) {
  for (;;); // 無限ループしちゃっても…
});
myDomain.deadline = 1000; //  デッドラインは 1 秒なので…
myDomain.on('error', function(err) {
  // 1 秒後にはここに来る!
});

新しい Domain を作成して,その実行時間の上限を設定します.Domain に関連づけられたコールバックがこの上限を超えてもイベントループに戻らなかった場合,その実行は中断されて Domain 上で 'error' イベントが生成されます.その Domain の他の I/O はキャンセルされますが (Domains の仕様),他の Domain はそのままなので,HTTP リクエストごとに Domain を作成するなどしていれば,他のリクエストに影響を与えることもありません.いい感じでしょ?

デモ

ということで,実際に実装してみました.

とはいえ,まだ Isolates と Domains は別々に開発されていて,両方を同時に使うことができません.しょうがないので,ひとまず Isolates のブランチ (isolates2 ブランチ) をベースにしました.そのため,実行時間の上限は固定で持っています (5 秒).また,実行が打ち切られた場合は process'uncaughtException' で通知されます.


実際に動くサンプルです.

var http = require('http');
http.createServer(function(req, res) {
  process.once('uncaughtException', function(err) {
    res.end(err + '\n');
  });
  for (;;);
}).listen(3000);

これ,普通に動かすと最初のリクエストで無限ループに入り,それっきりレスポンスを返すことはありません.しかし! このパッチを適用すると 5 秒でタイムアウトして,レスポンスとしてエラーメッセージが返ってきます.やってみましょう.

$ curl http://localhost:3000/
Error: Deadline Exceeded
$ 

ねっ!!
もちろん,複数リクエストを同時に飛ばしても,いずれは全部返ってきます.5 個投げると最後のやつが返ってくるのは約 25 秒後だけどw


なので,


Node を避けるべき「たった一つ」の,「たった一つ」の (大事なことなので ry) 理由が無くなるかもっ!?


といいたいところですが,本家の issue ではあまり反応がないので,これを Node 本体に入れられるかどうかは微妙です.っていうか,無理な気がする (既に諦め気味).10 月末の「東京 Node 学園祭 2011」で,「Isolates がサポートされるならこれをやりたい」って Ryan に話した時もあまり興味なさそうだったし,英語圏ではこの問題を取り上げるブログとかも多くはないようなので,グローバルではそもそも必要とされていないのかもしれません.

開発にたずさわる人数が増えたりすれば、開発者の品質もそろいずらくなるし、みたいなことを思ってしまう僕は異端なのでしょうか。

こういう懸念自体が日本 (の SIer) 固有なのかも,とか思ってしまう今日この頃でした.


P.S.
上記デモは Const なんとか先生 (id:Constellation) に相談に乗ってもらったり,サンプルモジュールを作ってもらったりしながら実装しました.ありがとうございました.