Open50

読者コミュニティ|イベントループとプロミスチェーンで学ぶJavaScriptの非同期処理

ピン留めされたアイテム
PADAone🐕PADAone🐕

内容更新情報

このスタックでは「イベントループとプロミスチェーンで学ぶJavaScriptの非同期処理」の更新情報を追記しています。最新の3つ以外も表示すると長くなってしまうので非表示にしています。

Hidden comment
Hidden comment
Hidden comment
Hidden comment
Hidden comment
Hidden comment
Hidden comment
Hidden comment
Hidden comment
Hidden comment
PADAone🐕PADAone🐕

Big News: 2023-08-03

技術評論社さんの Software Design 2023年9月号(8月18日発売)の非同期処理特集に記事を寄稿させていただきました。僕は第2章「なぜ非同期処理は難しいのか?
初学者がハマらないための前提知識と用語の整理」を担当しています。

https://gihyo.jp/magazine/SD/archive/2023/202309

僕意外にも様々な方が執筆に参加しているのでかなり面白くなっているはずです。ぜひ購入してみてください!!

PADAone🐕PADAone🐕

このスクラップについて

本の感想や質問をお気軽にコメントしてください。あるいは「非同期処理についての気になる謎」などあれば募集しているのでコメントをください。

https://zenn.dev/estra/books/js-async-promise-chain-event-loop

このスレッドでは内容更新以外での重要な情報について記載していきます。

PADAone🐕PADAone🐕

今後の執筆予定内容

2023-04-19 更新
今後書けそうな(書きたいと思っている)非同期にまつわるネタです。
今後、この本では非同期処理を通じて様々な領域を学ぶこと、非同期処理に関するあらゆる謎を一つずつ潰していくという目的で更新を続けるつもりなのでそういったタレコミ等も募集しています。

  • Realm関連 (ECMA)
  • NodeにおけるCommonJSとESMのローダーによる違い(同期と非同期)
  • dynamic import
  • top-level await の実行順序への影響
  • Parallelism vs Concurrency
  • JS Promise と C# Task のメソッドチェーンとの類似性
  • Node と Deno のアーキテクチャ概要
  • Deno のイベントループ詳細(Tokio & Rust & V8)
  • IOなどのOSレベルでの差分を吸収している箇所について
  • Node の C++ API についての詳細
  • 並列処理(Worker系 API)
  • Awaited 型と再帰型
  • React における非同期処理とHook
  • 長時間タスクの分割方法
Hidden comment
Vermee81Vermee81

とても勉強になっています。
読んでる途中ですが、02までで気づいた細かい誤植をお伝えします。

01 はじめに

before

元々のワード 別の表記
Task Macrotask, マイクロタスク、タスク
Microtask マイクロタスク

after

元々のワード 別の表記
Task Macrotask, マクロタスク、タスク
Microtask マイクロタスク

02 Event loopの概要と注意点

before
2. 単一の Task(Macrotask) の実行: Task queue(Macrotask queue) から最も古いタスク(マクロタスク)を選択して Call Statck が空になるまで実行する。

after
2. 単一の Task(Macrotask) の実行: Task queue(Macrotask queue) から最も古いタスク(マクロタスク)を選択して Call Stack が空になるまで実行する。

PADAone🐕PADAone🐕

教えていただきありがとうございます😊
後で直しておきます!

Hidden comment
もけもけ

本、どうもありがとうございます。
興味深いトピックで、大変ありがたいです。

誤植と思われる部分がありましたので、共有させてください(誤植じゃなかったらごめんなさいm(_ _)m)

03 イベントループの概要と注意点

before after
Livevent (Chrome) Libevent
Livuv (Node.js) Libuv
実装はそれぞれ違いまし、 実装はそれぞれ違いますし、
その中にあるタスクをを処理しま その中にあるタスクを処理しま
Task soruce からくるタスク Task source からくるタスク
Even loop Event loop
自分はこのこのに気付いてさえいな 自分はこのことに気付いてさえいな
PADAone🐕PADAone🐕

めちゃくちゃ誤植ですね笑。教えていただきありがとうございます!
あとで直しておきます👍

Hidden comment
はるぐちはるぐち

非同期処理についての理解が深まりました。ありがとうございます。
typoを見つけたので報告しておきます🙏

非同期APIと環境

非同期処理の目的

誤:JavaScript はシングルスレッド言語であり・・・略・・・また、取得したデータのを使って何かをしたい場合もデータが取得できるまではそのデータを使った処理は何もできません。

正:JavaScript はシングルスレッド言語であり・・・略・・・また、取得したデータを使って何かをしたい場合もデータが取得できるまではそのデータを使った処理は何もできません。


誤: さて、JavaScript はシングルスレッドで実行されるはずでしたが、このように非同期 API を介して複数のことができるは環境がJavaScript エンジンだけではなく色々なものを機能として持っているからです。

正:さて、JavaScript はシングルスレッドで実行されるはずでしたが、このように非同期 API を介して複数のことができる環境がJavaScript エンジンだけではなく色々なものを機能として持っているからです。

PADAone🐕PADAone🐕

教えていただきありがとうございます!
さっそく直しておきました👍

ysgkysgk

大変有用な記事をありがとうございます。非常に勉強になりました。
細かい部分ですが、一点仕様の解釈で気になる箇所がありましたので指摘させてください。

https://zenn.dev/estra/books/js-async-promise-chain-event-loop/viewer/d-epasync-task-microtask-queues#タスクキュー

An event loop has one or more task queues. A task queue is a set of tasks.

Task queues are sets, not queues, because the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.

複数のタスクキューはキューではなく集合となっていることがわかります。イベントループは複数のタスクキューから1つのタスクキューを選び、そのキューの先頭にある最も古いタスクを処理しなくてはいけませんが、複数あるタスクキューからどのタスクキューを選ぶかというのは実装側、つまり環境が考慮します。

HTML Standard のこの部分の解釈はやや誤解が含まれているように思います。
私には、注釈部分は「(それぞれの) Task queue は (queue って名前だけど実は) queue ではなく set だよ」と読めました。実際に、 because 節を読むと「イベントループ処理モデルは、選択されたキューの中から最初の実行可能な (runnable) タスクを選び出します、先頭のタスクを dequeue するのではなく」とあります。この「先頭のタスクを dequeue するのではなく、 最初の実行可能な (runnable) タスクを処理系が選び出す」という挙動をとらえて、 Task queue は厳密な意味での queue ではないと注意喚起しているのがこの注釈の意味ではないでしょうか。そう思って最初の行を読むとまさにそう言っているようです。

A task queue is a set of tasks.

PADAone🐕PADAone🐕

指摘していただきありがとうございます。
たしかにこの部分は自分の解釈が間違っていたようです。おっしゃっている解釈で正しいと思います。

実際に調べてみたところ、WAHTWG仕様の Infra Standard の5. Data structuresにデータ型の仕様がありました。

タスクキューはデータ型の List の一部である Set であり、そして、Queue ではないようです。

  • Lists (a specification type consisting of a finite ordered sequence of items)
    • Stacks
    • Queues: ←マイクロタスクキュー
    • Sets: ←タスクキュー

複数のタスクキューから環境実装で一つ選ぶという解説は、Processing model の箇所に記載している以下のところと混在させていたようです。

  1. If the event loop has a task queue with at least one runnable task, then:
    1. Let taskQueue be one such task queue, chosen in an implementation-defined manner.

また、以下に書いてあるとおり、taskQueue 内の 最初の実行可能タスク(the first runnable task)taskQueue 内から処理するので、もともとの解説で言っていた「そのキューの先頭にある最も古いタスク」ではなかったですね。

Task queues are sets, not queues, because the event loop processing model grabs the first runnable task from the chosen queue, instead of dequeuing the first task.

また、タスクキューが厳密に Queue ではなく、Set のデータ型であるというのに違和感があったのですが、これも Set の定義において順序付き(ordered set)であるからということでした。おっしゃっている通り、タスクの状態によって runnable であるかどうか決まり処理されるようなのでただの Queue だとそれはできないということでした。

Some lists are designated as ordered sets. An ordered set is a list with the additional semantic that it must not contain the same item twice.

まとめて、元の解説を修正するとこんな感じになるでしょうか。

複数のタスクキューはキューではなく集合となっていることがわかります。イベントループは複数のタスクキューから(少なくとも一つ以上の実行可能タスクがある)1つのタスクキューを選び、そのキューの先頭にある最も古いタスクを処理しなくてはいけませんがそのキュー内の実行可能かつ順番的に最初にあるタスクを処理しなくてはいけませんが、複数あるタスクキューからどのタスクキューを選ぶかというのは実装側、つまり環境が定義する方法(in an implementaition-defined manner)で考慮します。

(本の方の文章はもう少しわかりやすく修正しておきます)

ysgkysgk

早速対応いただきありがとうございます。
途中から自然言語で考えてしまっていましたが、原典は用語定義へのリンクがついていることから明確でしたね 😅
改めてこの記事を執筆していただいたことに感謝をお伝えします。

PADAone🐕PADAone🐕

自分も完全に自然言語で考えてしまってました笑
また何かあれば、コメントいただけると助かります!

鷹勇鷹勇

読んでいる途中です!
さまざまな記事が口を閉ざしている、JSにおける”非同期処理”や”並列・並行処理”について
だんだんと理解が深まっていくように感じ、とても楽しく読ませていただいてます。

https://zenn.dev/estra/books/js-async-promise-chain-event-loop/viewer/d-epasync-task-microtask-queues
この章を読んでいるのですが、イベントループというのはOSのようなものに感じました。
タスクキュー、マイクロタスクキューは、メモリに展開されたようなプログラムのように感じます...
イベントループがキューや割り込み処理(Web APIなど)から、次に実行すべき仕事を選ぶ仕事をしているのかなと...この様がOSに似ているなと感じます。
このような、喩え・把握は誤りだと感じますか?

この後、本を読む進めていく中で、この理解・認識は誤りであり、変わっていく可能性が高いです。
何か良い喩えはないかと思っているところです。

PADAone🐕PADAone🐕

OS については自分は詳しくないのですが、調べてみたところ、一般的な「イベントループ」という概念自体はそもそもプログラミングの構成デザインパターンらしいです。
なので OS レベルでのイベントループやアプリケーションレベルでのイベントループなどいろいろな階層の場所で存在しているみたいですね。従って、仰る通り OS でも抽象的には同じようなことをやっているという話になりますね。

ちなみに Unix などのシステムだとファイル自体(ファイル記述子)がイベントループ上のメッセージ(イベント)となって監視処理するような仕組みになっているみたいです。

UNIXでは「あらゆるものはファイルである」というパラダイムにより、ファイルベースのイベントループが自然に生まれた。ファイルの読み書きだけなく、プロセス間通信、ネットワーク通信、デバイス制御が全てファイルI/Oで行われ、対象はファイル記述子で指定される。selectおよびpollシステムコールを使えば、複数のファイル記述子の状態変化を同時に監視でき、読み込むべきデータが到着したことを検知できる。
(https://ja.wikipedia.org/wiki/イベントループ より)

鷹勇鷹勇

なるほど!
イベントループというのはデザインパターンレベルの抽象概念を指しているんですね!さんこうになります!

はるぐちはるぐち

コールスタックと実行コンテキスト#タスクとなるコールバック関数で疑問に思ったことがあります。

// simpleTask.js
console.log("[1] 🦖 MAINELINE: Start [Global Execution Context]");
setTimeout(function taskFunc() {
  console.log("[3] ⏰ TIMERS: timeout 5000ms [Functional Execution Context (taskFunc)]");
}, 5000); // 5000 ミリ秒後にタスクキューへタスクを発行
console.log("[2] 🦖 MAINELINE: End [Global Execution Context]");

(6 → 7) コールスタック上のトップはグローバルコンテキストであるため、Running Execution Context はグローバルコンテキストとなりますが、他の何も処理すべきものが残っていないのでグローバルコンテキストはすぐにポップし破棄されます。

「他の何も処理すべきものが残ってない」とありますが、
console.log("[2] 🦖 MAINELINE: End [Global Execution Context]");の処理があるように思います。

私が思ってる流れは簡単のため関数実行コンテキストだけ追っていくと

  1. console.logの関数実行コンテキストpush → pop
  2. setTimeoutの関数実行コンテキスト(5000msは別スレッドで計測) push → pop
  3. console.logの関数実行コンテキストpush → pop
  4. 5000msたった後(かつグローバルコンテキストpop後)コールバック関数の関数実行コンテキスト push
  5. コールバック関数内で呼ばれる console.logの関数実行コンテキスト→ push
  6. console.logの関数実行コンテキストpop → コールバック関数の関数実行コンテキストpop

となるように思っていて、その場合3の処理が抜けているのではないかと思っています(図に関しても同様)。いかがでしょうか?
(意図的にトップレベルにある後者のconsole.logを省いたのかなと思ったのすが、念のため確認です。🙏)

また、マイクロタスクの場合も同様の現象が起こってる気がします。

PADAone🐕PADAone🐕

指摘ありがとうございます。ざっと見てみたところ、おそらく指摘のとおりだと思います。説明と図からいつのまにかconsole.logの部分が抜け落ちてしまっていました🙇‍♂️

後ほど細部を調査して修正しておきます!!

はるぐちはるぐち

修正ありがとうございます。GECの上にFECが乗ってる様子がわかり、全体的に理解しやすくなりました!

はるぐちはるぐち

Node環境のイベントループについての報告です。

microTaskQueueとnextTickQueueについての優先度の言及があり以下のコードがありますが、nodeのバージョンによって出力順序が違っていたので、報告までと思いまして。優先度が変わったなどのソースなどは調べられていないのですが、手元で試した結果を載せておきます。

// queueMicroVsNextTick.js
console.log("[1] 🦖 MAINLINE: Start");

(function main() {
  setTimeout(() => {
    console.log("[7] ⏰ TIMRES: Task")
  });
  Promise.resolve().then(() => {
    console.log("[4] 👦 MICRO: [microTaskQueue] then");
  });
  queueMicrotask(() => {
    console.log("[5] 👦 MICRO: [microTaskQueue] queueMicrotask");
  });
  process.nextTick(() => {
    console.log("[3] 👦 MICRO: [nextTickQueue] process.nextTick");
    queueMicrotask(() => {
      console.log("[6] 👦 MICRO: [microTaskQueue] queueMicrotask");
    });
  });
})();

console.log("[2] 🦖 MAINLINE: End");
# node -v 12.2.0node queueMicroVsNextTick.js
[1] 🦖 MAINLINE: Start
[2] 🦖 MAINLINE: End
[3] 👦 MICRO: [nextTickQueue] process.nextTick
[4] 👦 MICRO: [microTaskQueue] then
[5] 👦 MICRO: [microTaskQueue] queueMicrotask
[6] 👦 MICRO: [microTaskQueue] queueMicrotask
[7] ⏰ TIMRES: Task
# node -v 14.8.1 or 16.13.0 or 18.14.0node queueMicroVsNextTick.js
[1] 🦖 MAINLINE: Start
[2] 🦖 MAINLINE: End
[4] 👦 MICRO: [microTaskQueue] then
[5] 👦 MICRO: [microTaskQueue] queueMicrotask
[3] 👦 MICRO: [nextTickQueue] process.nextTick
[6] 👦 MICRO: [microTaskQueue] queueMicrotask
[7] ⏰ TIMRES: Task

余裕があったら自分でも調べておくのですが、versionによってmicroTaskQueueとnextTickQueueの優先度が変わったのかなと思いました。

PADAone🐕PADAone🐕

v10→v11の変更じゃなくて、v12→v13あたりで変更があるとは知りませんでした😳教えて頂きありがとうございます。調査してみます👍

PADAone🐕PADAone🐕

ちょっとテストしてみたのですが、自分の環境だと、v12.2.0 でも v18.14.0 でも同じ結果でした🧐

node -v
v18.14.0

❯ node nto1.js
[1] 🦖 MAINLINE: Start
[2] 🦖 MAINLINE: End
[3] 👦 MICRO: [nextTickQueue] process.nextTick
[4] 👦 MICRO: [microTaskQueue] then
[5] 👦 MICRO: [microTaskQueue] queueMicrotask
[6] 👦 MICRO: [microTaskQueue] queueMicrotask
[7] ⏰ TIMRES: Task

❯ volta install node@12.2
success: installed and set node@12.2.0 as default

❯ node -v
v12.2.0

❯ node nto1.js
[1] 🦖 MAINLINE: Start
[2] 🦖 MAINLINE: End
[3] 👦 MICRO: [nextTickQueue] process.nextTick
[4] 👦 MICRO: [microTaskQueue] then
[5] 👦 MICRO: [microTaskQueue] queueMicrotask
[6] 👦 MICRO: [microTaskQueue] queueMicrotask
[7] ⏰ TIMRES: Task
PADAone🐕PADAone🐕

もしかしてこれが関連しているかもしれません。
https://github.com/nodejs/node/issues/45048

.mjs ファイルでモジュールとして実行すると指摘した実行順番になりました。

node nto2.mjs
[1] 🦖 MAINLINE: Start
[2] 🦖 MAINLINE: End
[4] 👦 MICRO: [microTaskQueue] then
[5] 👦 MICRO: [microTaskQueue] queueMicrotask
[3] 👦 MICRO: [nextTickQueue] process.nextTick
[6] 👦 MICRO: [microTaskQueue] queueMicrotask
[7] ⏰ TIMRES: Task

モジュールとして実行、つまり package.json がある状態で type: "module" を指定して実行していれば指摘した実行順番になるかと思います。

PADAone🐕PADAone🐕

この実行順番になるという問題の解答らしきものがコメントでありました。
https://github.com/nodejs/node/pull/45093#issuecomment-1295498457

モジュールは promise によってロードされる、つまりファイル全体がマイクロタスクとして処理されるので、イベントループの工程が最初からマイクロタスクを処理する段階で始まるのだと思います。そのように考えると処理順番が実際に一致します。

while (tasksAreWaiting()) {
  queue = getNextQueue();
  while (queue.hasTasks()) {

    if (queue.arriveMaxTasks()) break;
    task = queue.pop();
    execute(task);

    do {
      while (nextTickQueue.hasTasks()) {
        doNextTickTask();
      }
      while (microTaskQueue.hasTasks()) { // ← ここから処理が開始されている
        doPromiseTask();
      }
    } while (nextTickQueue.hasTasks());

  }
}
はるぐちはるぐち

手元でもそのようになりました。調査していただいてありがとうございます:bow:

モジュールは promise によってロードされる、つまりファイル全体がマイクロタスクとして処理されるので、イベントループの工程が最初からマイクロタスクを処理する段階で始まるのだと思います。そのように考えると処理順番が実際に一致します。

ここについてもめちゃくちゃしっくりきました。(このBook読んだおかげだー😆)
ファイル全体がマイクロタスクとして処理される。なるほどです。

PADAone🐕PADAone🐕

Dynamic import とかモジュールの取り扱いで非同期が絡んでて Deno のイベントループでまだ理解していない細かい箇所とかで今後取り組もうかなと思ってる話題に関連してたので、ちょうどよかったかもしれません。もう少し調査して記載できるレベルまで理解したら本の方で新しいチャプターを作って追記しようと思います👍

YABUKI YukiharuYABUKI Yukiharu

こんにちは、興味深く読ませていただきました。

あまりに自明かとはおもうのですが、どのAPI(メソッド・関数)が、どのタクスキュー、またはマイクロタスクに終わったら処理を入れてくれるのかについて、どうやって調べているのか。ドキュメントを読むとわかるものなのか。について指針があると親切かな。と思いました。

単に私が読み落としているならごめんなさい。

PADAone🐕PADAone🐕

読んでいただきありがとうございます!
確かに調べ方についてはあんまり書いていなかったかもしれませんね。どこかでまとめて追記しておきたいと思います。

モダンな非同期 API の理想形態は Promise を返すという形式なので、最近の API はマイクロタスクを間接的に使用するというのが一般化していますが、各 API がどのタスクキュー、マイクロタスクキューを使うかというのがリストアップされた資料などはあまりないかもしれません。

標準仕様がある Web Platform API (例えば fetch, setTimeout) については HTML Standard の方に仕様があり、タスクキューなどの仕様もそこにあったりしますね。なので、その当たりの仕様を見るとタスクキューを使うんだなってことがわかりますが、Runtime API (node/deno のAPI )の方はもちろん各ランタイムのドキュメントにあったりします。

答えとしては、各 API の仕様を探して見る、という形式になるでしょうか。