JavaScript
Node.js

Node.jsでスレッドローカル風のコンテキストを作る方法


Node.jsにスレッドが無いのはいいが、スレッドローカル変数的なものが欲しい

Node.jsは(まあJavaScript自体がそういうものだが)ご存知の通り徹底した非同期思想で作られていて、並列処理はそれぞれの非同期コンテキストが「待ちに入ったら他に譲る」形で実現されている。それゆえかどうかは知らないが、並列処理のもう一方の雄であるスレッドは用意されていない。

それはいいのだけど、非同期処理をasync(Promise)/awaitなり、コールバックなりで連ねた「ある処理のかたまり」を意識したいこと、あるよね。

典型的な例が、HTTP-Request~Responseの一連の処理、の中で、


  • コールバック関数が呼ばれたはいいけど、どのRequestが元で呼ばれたのかわかんない(ログとかに困る。デバッグにも困る)問題

  • res,reqどこまで引数で引き回せばええねん問題

みたいなことが、「HTTP-Request~Responseの一連の処理」が「ある処理のかたまり」として扱えて、そのかたまりにローカルなコンテキストがあればそこを参照・保存しておけるのに・・・と考えたのが今回の記事の始まりです。


どうやって実現すればいいか

その「ある処理のかたまりにローカルなコンテキスト」をもうちょっとプログラミング言語的に表現したものが、タイトルの「スレッドローカル風のコンテキスト」です。

で、そういうものがnpmにないのかなあと探して見つかったのが以下の記事(良記事ですね!)にあるzonesというものらしいのだけど、コンテキストを見失っちゃうケースがある(らしい)とかいう噂もあって、どうしようかなあと。

Node.js で Request Local なコンテキストを Zone.js で作る

そんな折にふと目に留まったのがNode.jsの標準ライブラリに用意されているasync_hooksでした。

こいつは、非同期処理の生成削除のタイミングで呼ばれる処理を登録できる仕組みのもので、本来なら非同期処理毎にぶっちぎれているコールスタックを疑似的につなげて見せるなんてことに利用されているらしい。

Node.js公式ドキュメントのasync_hooks

これを使うと、どの非同期コンテキストが、どの非同期コンテキストから作られたのか、という、非同期コンテキストの親子関係をツリー状に把握することができます。

そこで、ある非同期コンテキストに「おまえは今からスレッド(風)コンテキストの親玉とする」と色を付けてあげれば、その子孫たちはツリーを辿って自分の属するスレッドコンテキストを特定できることになります。

なんかもうどっかにありそうですね。まあいいです。車輪の再発明、楽しいよね!


ではコーディング実践

ご高説は結構なのでソースを見せやがれ、と?では早速。


親子関係にある非同期コンテキストを同一のスレッドコンテキストにまとめる仕組みを作る

まずは、誰が誰を作ったのか、を覚えておくだけなら以下で終わりです。

const asyncHooks = require('async_hooks');

const asyncMap = {};
asyncHooks
.createHook({
init(asyncId, type, triggerAsyncId) {
const parent = asyncHooks.executionAsyncId();
asyncMap[asyncId] = parent;
}
})
.enable();

(本当は非同期処理が終わった時には開放してあげるとかの処理が必要だけどここでは割愛)

これで、子から親の方向に辿れるツリーは完成です。

ただし、これでは階層が深くなると「スレッドの親玉である先祖」を探すのが効率が悪いですね。

それに、先祖は先にいなくなってしまうかもしれません。そうすると「スレッドの親玉」まで辿れないことになります。

そこで、覚えておくのは親ではなく「スレッドの親玉である先祖(のid)」、というのでもよいですが、親玉も先にいなくなる可能性があり、その場合idが一周して再利用されてしまうかもしれないので、もういっそ「スレッドコンテキスト」自体を覚えておくことにしましょう。

const asyncHooks = require('async_hooks');

// スレッドコンテキストを表すクラス
class Thread {}
// 明示的にスレッドに属さない非同期コンテキストの所属先として「メインスレッド」を用意
const mainThread = new Thread();

const asyncMap = {};
asyncHooks
.createHook({
init(asyncId, type, triggerAsyncId) {
const parent = asyncHooks.executionAsyncId();
// 親がスレッドに属していれば子も属す
if (asyncMap[parent]) {
asyncMap[asyncId] = asyncMap[parent];

// そうでなければメインスレッドに所属
} else {
asyncMap[asyncId] = mainThread;
}
}
})
.enable();


任意の関数を「新しいスレッドの親玉」に仕立てる仕組みを作る

これで、非同期コンテキストの子孫をスレッドコンテキストにまとめる準備はできたわけですが、肝心のスレッドコンテキストがメインスレッドしか作られていませんね。

任意の場所で

asyncMap[asyncHooks.executionAsyncId()] = new Thread();

とすることで、所属するスレッドをメインスレッドから新しいものに上書きしてもいいんですが、これだと、ある時点までメインスレッドだった非同期コンテキストが急に新スレッドに鞍替えしたみたいになってしまうので、非同期処理の開始と同時に新スレッドも始まるようにしたいですね。

Threadクラスにもう少し処理を追加して使いやすくします。

class Thread {

// コンストラクタで、スレッドの開始ポイントになる関数を登録しておきます。
constructor(entryFunction) {
this.entryFunction = entryFunction;
}

// 新たな非同期コンテキストを起こして登録された関数を実行する
run() {
const self = this;
const args = arguments;
return new Promise((resolve, reject) => {
setImmediate(() => {
// 非同期コンテキストの先頭でスレッドを差し替える
asyncMap[asyncHooks.executionAsyncId()] = self;
// 本来実行したかった関数を実行する
try {
resolve(self.entryFunction(...args));
} catch(e) {
reject(e);
}
});
});
}
}

// 使い方
function someFunction() {
// Thread.run()から呼ばれた場合、ここは新スレッド
// ここから呼び出した非同期処理も全部新スレッド
}

// ここが旧スレッドなら
const newThreadContext = new Thread(someFunction);
const result = await newThreadContext.run(arg1, arg2, arg3, ...);
// 新スレッドを立ち上げた後も、ここは旧スレッド

setImmediate()を使っているのは、それがクリーンな状態の非同期コンテキストを作り出す一番手っ取り早い方法だからです。お好みによりprocess.nextTick()でも良いでしょう。

でもasync/Promiseはだめです。これらはI/Oなどの「本気の非同期処理」が発生するまで新しい非同期コンテキストを作らずに呼び出し元のコンテキストのままで処理を進めてしまうようだからです。つまり、

run() {

return (async () => {
asyncMap[asyncHooks.executionAsyncId()] = this;
return this.entryFunction();
})();
}

などとすると、呼び出し元のコンテキストまで新スレッドに書き換えられてしまうことになります(最初とても悩みました)。


現在のスレッドを取り出す。スレッドローカル変数を扱う

あとは、自分が属しているスレッドを取り出して、任意の値を保存したり取り出したりしたいですね。難しくはないですが、関数を用意しておきましょう。

// 呼び出した時点のコンテキストが所属しているスレッドを取得する

function getCurrentThread() {
const eid = asyncHooks.executionAsyncId();
if (asyncMap[eid]) {
return asyncMap[eid];
}
return mainThread;
}

// 任意の場所でスレッドローカル変数に保存
getCurrentThread().req = req;
// 別の任意の場所でスレッドローカル変数から取り出し
const req = getCurrentThread().req;

さあ、これでだいたい終わりです。

途中に書いた通り、非同期が終わった時の開放が必要とか、プロダクションレベルにするには諸々考慮不足ですが、試してみる分には十分でしょう。

フルバージョンをGitHubGistに上げておきました。