Closed15

Denoのイベントループを理解する!

PADAone🐕PADAone🐕

Discord のヘルプで聞いてみた内容まとめ

  • Node のイベントループとの違いは、ウェイクアップメッセージがいつ届くかということであり、これはそれぞれの API に固有で、どの仕様から採用されているかによる
  • Deno ではそもそも Phase 概念が無い
  • Node の phase に相当するものは Timers のみ
    • タスクとして登録されるコールバックは Timers のためのみ
    • そもそも Timers 以外にコールバックをタスクとして発火するものは存在していないっぽい
    • I/Oはphaseとしては扱ってない
  • ドキュメント化されていないAPIで独自のphaseを作れる(Deno.core.setMacrotaskCallback())
    • これを使って nexTickQueue のポリフィルを作成している
  • マイクロタスクの処理は完全にブラウザと同じ
  • タスクのコールバックの処理制限は無い
  • HTMLのspecはタスクとしてみなされるものについて定義しているが、実際の実装は色々異なる
PADAone🐕PADAone🐕

自分の理解

NodeはI/Oのための非同期 Callback API があったため、完了後の処理をタスクとして処理していたが、Denoの場合には全ての非同期アクションが Promise を返すと言っている。つまりマイクロタスクで処理する。

Deno の setTimeout は別の環境と同じくコールバックをタスクとして発行して phase で処理する。

もちろん Node にも Promise の仕組みが後から追加されたためマイクロタスクとして処理できる。

これと phase がどう関連しているか把握しきれていない。たぶん Node の方も理解しきれてない。

PADAone🐕PADAone🐕

Promise 自体が新しい ECMAScript の機能であり、それまでは Callback API やタスクとして処理する非同期処理が主流だった。したがって、それらを活用するために Node はタスクベースである Phase 概念を取り込んだイベントループが作られた、ということか

  • タスクベースの非同期処理(タイマーの setTimeout()fs.readFile() など)
  • マイクロタスクベースの非同期処理(データ取得の fetch() など)
    • Deno はタイマー以外は基本的にこちらになる
PADAone🐕PADAone🐕

https://developer.mozilla.org/ja/docs/Web/JavaScript/Guide/Using_promises#古いコールバック_api_をラップする_promise_の作成

Promise はコンストラクターを使って 1 から生成すこともできます。これが必要になるのは古い API をラップする場合のみでしょう。
理想的には、すべての非同期関数はプロミスを返すはずですが、残念ながら API の中にはいまだに古いやり方で成功/失敗用のコールバックを渡しているものがあります。顕著な例としては setTimeout() 関数があります。
古い様式であるコールバックとプロミスの混在は問題を引き起こします。というのは、saySomething() が失敗したりプログラミングエラーを含んでいた場合に、そのエラーをとらえられないからです。setTimeout にその責任があります。
幸いにも setTimeout をプロミスの中にラップすることができます。良いやり方は、問題のある関数をできる限り低い水準でラップした上で、直接呼び出さないようにすることです。
(上記ページより引用)

タスクベースの非同期処理自体が理想的ではなく、マイクロタスクベースの非同期処理が理想である旨が読み取れる。タスクベースとマイクロタスクベースの非同期処理が混在してしまうと問題になる。

https://nodejs.dev/learn/understanding-javascript-promises#creating-a-promise

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" と呼ばれる。

https://ja.javascript.info/promisify

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 もあるよね?

PADAone🐕PADAone🐕

マイクロタスクベースな非同期タイマーがあってもよいはずと思って探したら次の issue を発見。
https://github.com/denoland/deno/issues/4052

そこから、standard library に指定時間たったら Promise を返す関数があった。
https://deno.land/std@0.137.0/async#delay

Resolve a Promise after a given amount of milliseconds

https://github.com/denoland/deno/blob/a0d3b4ebc509d9e5dfca555084fd1100e114664a/std/util/async.ts#L110-L117

現在はこんな感じになっている。
https://github.com/denoland/deno_std/blob/58204e6f5617fe7a80dfdbe98e6d9f1cadef1d1e/async/delay.ts

PADAone🐕PADAone🐕

Node の概念を取り除いて考えた方がいいかもしれない

もしかしたら Node の phase 概念をいれてしまったせいで Deno のイベントループを理解できていないかもしれない。よりブラウザに近い? 非同期 Callback API が setTimeoutsetInterval などのスケジューリング機能のみと考えると、やはり phase の概念自体がそもそもいらない気がしてきた。setImmediateの概念はもちろんいらない。

phaseに結びついていたタスクキューとそれらのキューにあるタスクをサイクルで処理するのが Node のイベントループだったが、phase がないなら、タスクキューは基本的には Timers だけのものが存在していればいいのでは?

Deno のネイティブな考えた方はこうなるはずだが、、

Node 互換モード

Node のサイクルで動くこともできるっぽいのでそちらはそちらで考えた方が良さそう。

https://doc.deno.land/https://deno.land/std@0.137.0/node/timers.ts

Promise 返す setTimeout があるっぽい。
https://doc.deno.land/https://deno.land/std@0.137.0/node/timers/promises.ts

setImmediate もあった。Promise 返すバージョンも発見。
https://doc.deno.land/https://deno.land/std@0.137.0/node/timers.ts/~/setImmediate

Promise 返すタイマーって、タスクを発行せず直接マイクロタスクを発行するということ?それとも Promise でラップされているだけなのか。

PADAone🐕PADAone🐕

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 より引用)

PADAone🐕PADAone🐕

EventEmitter

EventEmitter は deno にない? あった。Node 互換モードで使える。

https://doc.deno.land/https://deno.land/std@0.137.0/node/events.ts/~/EventEmitter

メソッドとして、addEventListeneremit などもちゃんとある。

Synchronously calls each of the listeners registered for the event namedeventName, in the order they were registered, passing the supplied arguments to each.

グローバルの addEventListener

https://doc.deno.land/deno/stable/~/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 に関数呼び出しと同じ用にリスナーを実行コンテキストとして積み上げていく感じになる。

https://doc.deno.land/deno/stable/~/dispatchEvent

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 へと行くようになっているが、これとは違うみたい。

PADAone🐕PADAone🐕
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
PADAone🐕PADAone🐕
PADAone🐕PADAone🐕
      // 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させる。
準備できたマイクロタスクは自動的に次のマクロタスクが処理される前に実行される。

ただマイクロタスクをどこで処理しているのかが分からない。

PADAone🐕PADAone🐕

Deno におけるマクロタスクが何なのか?

  • Timers: setTimeout(cb, delay), setInterval(cb, interval) のコールバックは確実にマクロタスク
このスクラップは2022/05/07にクローズされました