Denoのイベントループを理解する!
Deno のイベントループを理解するためのスクラップ
とりあえずイベントループの詳細がマニュアルに無かったので Deno の公式マニュアルにissue開いた。
文脈
これの続き。
Discord のヘルプで聞いてみた内容まとめ
- Node のイベントループとの違いは、ウェイクアップメッセージがいつ届くかということであり、これはそれぞれの API に固有で、どの仕様から採用されているかによる
- Deno ではそもそも Phase 概念が無い
- Node の phase に相当するものは Timers のみ
- タスクとして登録されるコールバックは Timers のためのみ
- そもそも Timers 以外にコールバックをタスクとして発火するものは存在していないっぽい
- I/Oはphaseとしては扱ってない
- ドキュメント化されていないAPIで独自のphaseを作れる(
Deno.core.setMacrotaskCallback()
)- これを使って nexTickQueue のポリフィルを作成している
- マイクロタスクの処理は完全にブラウザと同じ
- タスクのコールバックの処理制限は無い
- HTMLのspecはタスクとしてみなされるものについて定義しているが、実際の実装は色々異なる
自分の理解
NodeはI/Oのための非同期 Callback API があったため、完了後の処理をタスクとして処理していたが、Denoの場合には全ての非同期アクションが Promise を返すと言っている。つまりマイクロタスクで処理する。
Deno の setTimeout は別の環境と同じくコールバックをタスクとして発行して phase で処理する。
もちろん Node にも Promise の仕組みが後から追加されたためマイクロタスクとして処理できる。
これと phase がどう関連しているか把握しきれていない。たぶん Node の方も理解しきれてない。
Promise 自体が新しい ECMAScript の機能であり、それまでは Callback API やタスクとして処理する非同期処理が主流だった。したがって、それらを活用するために Node はタスクベースである Phase 概念を取り込んだイベントループが作られた、ということか
- タスクベースの非同期処理(タイマーの
setTimeout()
やfs.readFile()
など) - マイクロタスクベースの非同期処理(データ取得の
fetch()
など)- Deno はタイマー以外は基本的にこちらになる
Promise はコンストラクターを使って 1 から生成すこともできます。これが必要になるのは古い API をラップする場合のみでしょう。
理想的には、すべての非同期関数はプロミスを返すはずですが、残念ながら API の中にはいまだに古いやり方で成功/失敗用のコールバックを渡しているものがあります。顕著な例としては setTimeout() 関数があります。
古い様式であるコールバックとプロミスの混在は問題を引き起こします。というのは、saySomething() が失敗したりプログラミングエラーを含んでいた場合に、そのエラーをとらえられないからです。setTimeout にその責任があります。
幸いにも setTimeout をプロミスの中にラップすることができます。良いやり方は、問題のある関数をできる限り低い水準でラップした上で、直接呼び出さないようにすることです。
(上記ページより引用)
タスクベースの非同期処理自体が理想的ではなく、マイクロタスクベースの非同期処理が理想である旨が読み取れる。タスクベースとマイクロタスクベースの非同期処理が混在してしまうと問題になる。
A more common example you may come across is a technique called Promisifying. This technique is a way to be able to use a classic JavaScript function that takes a callback, and have it return a promise:
(上記ページより引用)
Promise でタスクベースの非同期処理(つまり古いタイプの非同期 API)をラップする手法は "Promisifying" と呼ばれる。
const fs = require('fs');
const getFile = fileName => {
// この部分が Promisifying
return new Promise((resolve, reject) => {
fs.readFile(fileName, (err, data) => {
if (err) {
reject(err); // calling `reject` will cause the promise to fail with or without the error passed as an argument
return; // and we don't want to go any further
}
resolve(data);
});
});
};
getFile('/etc/passwd')
.then(data => console.log(data))
.catch(err => console.error(err));
In recent versions of Node.js, you won't have to do this manual conversion for a lot of the API. There is a promisifying function available in the util module that will do this for you, given that the function you're promisifying has the correct signature.
(上記ページより引用)
promisifying 関数というのが util module にあり、これを使うことで手動でラップすることなくPromisify できるようになっている。
これとは別に Promise-based な API もあるよね?
マイクロタスクベースな非同期タイマーがあってもよいはずと思って探したら次の issue を発見。
そこから、standard library に指定時間たったら Promise を返す関数があった。
Resolve a Promise after a given amount of milliseconds
現在はこんな感じになっている。
Node の概念を取り除いて考えた方がいいかもしれない
もしかしたら Node の phase 概念をいれてしまったせいで Deno のイベントループを理解できていないかもしれない。よりブラウザに近い? 非同期 Callback API が setTimeout
と setInterval
などのスケジューリング機能のみと考えると、やはり phase の概念自体がそもそもいらない気がしてきた。setImmediateの概念はもちろんいらない。
phaseに結びついていたタスクキューとそれらのキューにあるタスクをサイクルで処理するのが Node のイベントループだったが、phase がないなら、タスクキューは基本的には Timers だけのものが存在していればいいのでは?
Deno のネイティブな考えた方はこうなるはずだが、、
Node 互換モード
Node のサイクルで動くこともできるっぽいのでそちらはそちらで考えた方が良さそう。
Promise 返す setTimeout
があるっぽい。
setImmediate
もあった。Promise 返すバージョンも発見。
Promise 返すタイマーって、タスクを発行せず直接マイクロタスクを発行するということ?それとも Promise でラップされているだけなのか。
Node Callback API
The callback APIs perform all operations asynchronously, without blocking the event loop, then invoke a callback function upon completion or error.
The callback APIs use the underlying Node.js threadpool to perform file system operations off the event loop thread*. These operations are not synchronized or threadsafe. Care must be taken when performing multiple concurrent modifications on the same file or data corruption may occur.
(File system: Callback API | Node.js v18.0.0 Documentation より引用)
Node Promise API
The fs/promises API provides asynchronous file system methods that return promises.
The promise APIs use the underlying Node.js threadpool to perform file system operations off the event loop thread. These operations are not synchronized or threadsafe. Care must be taken when performing multiple concurrent modifications on the same file or data corruption may occur.
(File system: Promise API | Node.js v18.0.0 Documentation より引用)
EventEmitter
EventEmitter は deno にない? あった。Node 互換モードで使える。
メソッドとして、addEventListener
と emit
などもちゃんとある。
Synchronously calls each of the listeners registered for the event namedeventName, in the order they were registered, passing the supplied arguments to each.
グローバルの addEventListener
Registers an event listener in the global scope, which will be called synchronously whenever the event type is dispatched.
addEventListener('unload', () => { console.log('All finished!'); });
...
dispatchEvent(new Event('unload'));
これはコールバックをタスクとして発行するわけではなさそう。EventEmitter と同じく、イベント発火する側の dispatchEvent
API がリスナーを登録された順番に同期的に呼び出す。つまり Call stack に関数呼び出しと同じ用にリスナーを実行コンテキストとして積み上げていく感じになる。
Dispatches an event in the global scope, synchronously invoking any registered event listeners for this event in the appropriate order. Returns false if event is cancelable and at least one of the event handlers which handled this event called Event.preventDefault(). Otherwise it returns true.
ブラウザの要素イベント
ブラウザ環境におけるクリックイベントなどは API が Task として発行して Task queue へと行くようになっているが、これとは違うみたい。
addEventListener("myEvent", () => {
console.log("❓ Event dispatched");
queueMicrotask(() => {
console.log("👦 MICRO: Inner listener");
});
});
console.log("🦖 MAINLINE: start");
const myEvent = new Event("myEvent");
queueMicrotask(() => {
console.log("👦 MICRO: (1) top scope");
dispatchEvent(myEvent); // 同期的にリスナーを呼び出すはず
});
queueMicrotask(() => {
console.log("👦 MICRO: (2) top scope");
dispatchEvent(myEvent); // 同期的にリスナーを呼び出すはず
});
setTimeout(() => {
console.log("⏰ TIMRES: delay [100ms]");
dispatchEvent(myEvent); // 同期的にリスナーを呼び出すはず
queueMicrotask(() => {
console.log("👦 MICRO: after Event dispatched");
});
}, 100);
dispatchEvent(myEvent); // 同期的にリスナーを呼び出すはず
console.log("🦖 MAINLINE: End");
Deno の addEventListener
について再度しらべてみたがやはりタスクではなく、関数呼び出しのように同期的にリスナーを呼び出していた。
❯ deno run simpleEvent.js
🦖 MAINLINE: start
❓ Event dispatched
🦖 MAINLINE: End
👦 MICRO: (1) top scope
❓ Event dispatched
👦 MICRO: (2) top scope
❓ Event dispatched
👦 MICRO: Inner listener
👦 MICRO: Inner listener
👦 MICRO: Inner listener
⏰ TIMRES: delay [100ms]
❓ Event dispatched
👦 MICRO: Inner listener
👦 MICRO: after Event dispatched
マイクロタスクのチェックポイントについてのissue
In browsers, a microtask checkpoint is performed whenever the JS stack empties.
We currently also follow the rule that a microtask checkpoint is performed whenever the JS stack empties.
マクロタスクを枯らすための処理の部分。
// Repeatedly invoke macrotask callback until it returns true (done),
// such that ready microtasks would be automatically run before
// next macrotask is processed.
true (done) が返ってくるまでマクロタスクを繰り返しinvokeさせる。
準備できたマイクロタスクは自動的に次のマクロタスクが処理される前に実行される。
ただマイクロタスクをどこで処理しているのかが分からない。
Deno におけるマクロタスクが何なのか?
- Timers:
setTimeout(cb, delay)
,setInterval(cb, interval)
のコールバックは確実にマクロタスク
V8 について
V8, Rusty_v8, Deno について参考になるサイトを見つけた