JavaScriptの非同期処理をじっくり理解する (1) 実行モデルとタスクキュー

2021/09/25に公開

対象読者と目的

  • 非同期処理の実装方法は知っているが、仕組みを詳しく知らないのでベストプラクティスがわからないときがある
  • 実行順序の保証がよくわからないので自信をもってデプロイできない変更がある
  • より詳しい仕組みを理解することでより計画的な実装をできるようになりたい

という動機で書かれた記事です。同様の課題を抱える人を対象読者として想定しています。

目次

  1. 実行モデルとタスクキュー
  2. Promise
  3. async/await
  4. AbortSignal, Event, Async Context
  5. WHATWG Streams / Node.js Streams (執筆中)
  6. 未定

入門記事へのリンク

JavaScript実行モデル

JavaScriptの実行モデルは以下のように Agent ClusterAgentRealm のネスト構造になっています。また、HTML仕様でこれらのWebブラウザ上での扱いが規定されています

図: JavaScriptの実行モデル

  • Realm
    • 個別のページはRealmに対応します。 <iframe>window.open などで作られたWindowは別のRealmになります。
    • ブラウザ拡張機能によって挿入されるContent Scriptにも専用のRealmが生成されると考えられます。 (要検証)
    • Realmはグローバル環境やライブラリ関数を共有します。
  • Agent
    • <iframe>window.open などによりJavaScriptオブジェクトを共有するページの集まりです (Similar-origin window agent)。
    • WorkerやWorkletは本体ページとは別のAgentに属します。 (Dedicated worker agent, Shared worker agent, Service worker agent, Worklet agent)
    • Agentはイベントループを共有するため、Agent内では常に高々ひとつのJavaScriptコードだけが実行されています。
  • Agent Cluster
    • SharedArrayBufferによりメモリを共有するAgentの集まりです。
    • ページ本体のJavaScriptとWeb WorkerのJavaScriptがSharedArrayBufferを共有しているときはAgent Clusterになります。
  • Agent Clusterの外
    • postMessageなどメッセージパッシングによるやり取りはJavaScriptの仕様としてモデル化する必要がないため、特に定義されていません。

Agent外ではスレッド並列性が使えますが、Agent内ではスレッド並列性はなくタスク並列性を用いて非同期処理を行います。以降はAgent内 (特に同一Realm内) での非同期処理の話題に特化します。

アトミック性

JavaScriptが (Agent単位では) 並列性を持たないことから、ほとんどの処理が自然にアトミックになるという強力な特徴があります。

// awaitやthenを含まない。これはアトミックに実行される。
this.counter++;

そのためMutexなどのお馴染みのパターンはJavaScriptでは他言語ほど頻繁には出てきません。

逆に、並列処理や並行処理を効率的に実装するにはこの制約が足枷になることもあります。

ジョブキュー

JavaScriptではイベントループを言語処理系が管理していて、アプリケーションはそれに従属する形で実行されます。イベントループから実行される1回分のJavaScriptコードをECMAScriptでは「ジョブ」と呼びます

window.onload = () => { console.log("loaded"); };
//            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ ロードが完了するとこれがジョブキューに追加される
setTimeout(() => console.log("1s has passed"), 1000);
//         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 1秒後にジョブキューに追加される

一度には1つのジョブしか実行されません。ジョブが終了 (一番外側の関数がreturn等で終了) すると、次のジョブが実行されます。

ジョブの優先度は一様ではなく、先にエンキューされたジョブに割り込んで実行されることもあります。

Host environments are not required to treat Jobs uniformly with respect to scheduling. For example, web browsers and Node.js treat Promise-handling Jobs as a higher priority than other work; future features may add Jobs that are not treated at such a high priority.

ただし、Promise関連ジョブは同一キューに入ることが規定されています

  • Jobs must run in the same order as the HostEnqueuePromiseJob invocations that scheduled them.

これにより以下のプログラムが0~9を順に出力することが保証されます。

(async () => { for(let i = 0; i < 10; i += 2) console.log(await i); })();
(async () => { for(let i = 1; i < 10; i += 2) console.log(await i); })();

Promise関連以外のジョブは2021年時点でのECMAScript標準では規定されていません。これらはHTMLなど他の仕様で追加定義されます。

図: ECMAScriptのジョブモデル

以降、Promiseのジョブキューが一周することを本稿独自の用語で1 microtickと定義します

Webブラウザのタスクキュー

ECMAScriptのジョブはHTML仕様ではタスクと呼ばれています。キューにはECMAScriptのジョブ以外のタスクも積まれます。キューは以下の2種類に分けられます。

  • (通常の)タスクキュー
    • (通常の)タスクキューはタスクの種類別に複数持つこともできる (実装依存)
  • マイクロタスクキュー
    • マイクロタスクキューにエンキューされたタスクをマイクロタスクと呼ぶ。

全てのタスクキューとマイクロタスクキューを合わせたものが、ECMAScript仕様におけるジョブキューとみなせます。

またrequestIdleCallbackで定義されるlist of idle callbacks / list of runnable callbacks もタスクキューの亜種とみなせます。

イベントループの処理は§8.1.6.3に規定されていますが、本稿に関係する範囲内で要約すると以下のようになります。

  • タスクキューにタスクがあれば、1つ取り出し実行する。
  • マイクロタスクキューのタスクが無くなるまで、1つずつ取り出し実行する。
  • 描画を更新する。
  • タスクキューに余裕がある場合は RequestIdleCallback の仕様に基づきバックグラウンドタスクをキューに積む。
  • 最初に戻る。

これは以下を意味します。

  • マイクロタスクは通常のタスクよりも優先される。
  • マイクロタスクは描画よりも優先される。

マイクロタスクが通常のタスクよりも優先されるということは、飢餓状態を起こすことができるということです。以下のawaitビジーループは描画をブロックしてしまうため、ブラウザ上では無限ループとほぼ同じ悪影響があります。

(async () => {
  while(true) {
    await null;
  }
})();

マイクロタスクをエンキューする代表的な方法は以下の2つです。

一方、通常のタスクを明示的にエンキューするのは簡単ではありません。タスクは通常何かしらのイベントに由来してエンキューされるからです。 Webブラウザ環境向けのsetImmediateのpolyfillでは、以下のような実装が使われています。

  • Window agent下では window.postMessage を自分に向けて送信することでイベントを発行
  • Worker agent下では MessageChannelpostMessage を利用
  • フォールバックとして setTimeout を利用

図: Webブラウザのタスクモデル

Webブラウザ環境でマイクロタスクキューが一周することを本稿独自の用語で1 microtickと定義します。これは前節の定義と整合します。また、 タスクキューが一周することを本稿独自の用語で1 tickと定義します

Node.jsのタスクキュー

Node.jsはChromeのJavaScriptエンジンであるV8を流用していることもあり、タスクの概念はWebブラウザのものと近いです。 (queueMicrotaskPromise.prototype.then が利用可能です)

マイクロタスクキューとは別に process.nextTick 用のキューがあるのがNode.jsの特徴です。原則として process.nextTick はマイクロタスクよりもさらに優先されますが、マイクロタスク内でエンキューされた場合は他のマイクロタスクよりも優先度を落として処理されます。これはタスクキューが以下の順番で処理されているからです。

while(true) {
  waitForAnyTask();
  if (taskQueue.length > 0) taskQueue.pop().run();
  do {
    while (nextTickQueue.length > 0) nextTickQueue.pop().run();
    while (microtaskQueue.length > 0) microtaskQueue.pop().run();
  } while (nextTickQueue.length > 0);
}

process.nextTick はNode.jsに組み込みのI/O処理のため、そして歴史的な経緯[1]のために存在するもので、現在は queueMicrotask を使うことが推奨されています。

ブラウザのJavaScriptと異なり、Node.jsには通常のタスクキューにエンキューするための関数 setImmediate も提供されています。使い方は setTimeout とほぼ同じです。

// 現在のI/Oタスク等を処理してから実行する
setImmediate(() => console.log("Hello!"));

Node.js環境ででマイクロタスクキューが一周することを本稿独自の用語で1 microtickと定義します。これは前節の定義と整合します。また、 タスクキューが一周することを本稿独自の用語で1 tickと定義します

⚠️tick / microtickの議論に関する注意⚠️

本稿では以降、Promise関連処理の処理タイミングの前後関係を説明するために「3microtickかかる」などの表現を用います。これらはどのマイクロタスクが優先して処理されるかという相対的な処理順序の議論にすぎず、microtick数が少ないほどパフォーマンスが良いというわけではないことに注意が必要です。

また現実的には1 microtickと2 microtickの違いを気にすることはないでしょう。本稿ではPromiseの設計に対する理解を深めるために細かいタイミングについても詳細に記述していますが、読み飛ばしても問題ありません。

setTimeout

タスクを直接エンキューするのではなく、一定時間待ってからエンキューするための昔からあるAPIとして setTimeout があり、WebブラウザNode.jsの双方で実装されています。

setTimeout(() => console.log("1 second has passed"), 1000);

WebブラウザとNode.jsでは以下のような違いがあります。

  • 最小秒数の挙動の違い (後述)
  • Node.jsではハンドラを文字列として渡すことができない。
  • Node.jsではタイマーID (number) ではなくタイマーオブジェクト (Timeout) が返される。
  • Node.jsではタイマーイベントに対してref/unrefという操作ができる (後述)

ブラウザ

  • 時間の下限値は初期状態では0msですが、 setTimeout / setInterval のネストが5段以上の場合は4msです。
  • 指定した時間よりも長く待つことが許されています。

ここでいうネストとは、 setTimeout のコールバックタスク内でさらに setTimeout を呼ぶことで加算される値です。

// ← nesting level = 0
setTimeout(() => {
  // ← nesting level = 1
  setTimeout(() => {
    // ← nesting level = 2
    setTimeout(() => {
      // ← nesting level = 3
      setTimeout(() => {
        // ← nesting level = 4
        setTimeout(() => {
          // ← nesting level = 5
          setTimeout(() => {
            // ← nesting level = 6

	    // nesting level > 5 のため、このtimeoutは4msとして扱われる
            setTimeout(() => {
              // ← nesting level = 7
            }, 0);
          }, 0);
        }, 0);
      }, 0);
    }, 0);
  }, 0);
}, 0);

歴史的には各ブラウザごとに少しずつ異なる規則で同様のルールが実装されてきたようです。 (2021年現在日本語版MDNに若干の言及あり)

Node.js

  • 時間の下限値は1msで、ミリ秒単位で整数値に丸められます (truncated)。
  • ちょうど指定した時間だけ待つ保証はなく、前後にずれる可能性があります。
// 1ミリ秒後に実行される
setTimeout(() => console.log("foo"), 0.5);
// 5ミリ秒後に実行される
setTimeout(() => console.log("foo"), 5.1);

また、Node.jsが返すタイマーオブジェクトはref/unrefという操作をサポートしています。WebブラウザではJavaScript環境の寿命はページの寿命によって決まりますが、Node.jsの場合はプロセスの寿命を別の方法で決める必要があります。具体的にはNode.jsのプロセスはI/Oイベントやタスクキューなど「残りのやるべきこと」が無くなったら終了します。ところがタイマーなどイベントによっては終了条件に入れないほうがいい場合があります。unrefを使うと、そのタイマーイベントの発火を待たずにプロセスを終了することができるようになります。

まとめ

  • JavaScriptの実行モデルは Agent Cluster → Agent → Realm という階層構造がある。ほとんどのワークロードでは単一Realm内の動作だけ考えていればよい。
  • 単一Agent内 (特に単一Realm内) では同時にひとつのJavaScriptコードしか実行されず、割り込みも起きない。このことはJavaScriptの並列・並行処理パターンを特徴的なものにしている。
  • JavaScriptのプログラムは処理系側が提供するイベントループによって駆動される。この実行単位をECMAScriptではジョブ、Webブラウザ/Node.jsではタスク/マイクロタスクと呼んでいる。マイクロタスクはタスクよりも優先的に実行される。
  • ほとんどのタスクはイベントハンドラを経由してエンキューされるが、タスクやマイクロタスクを直接エンキューするAPI (setImmediate, queueMicrotask, Promise.prototype.then, process.nextTick)もある。
  • setTimeout はタイマーイベントのAPIだが、時間の分解能に制限があり setImmediate の代替としては不十分である。

次回→Promise

関連資料

更新履歴

  • 2021/09/25 公開。
脚注
  1. process.nextTick が入ったのはv0.1.26 (2010年), setImmediate が入ったのはv0.9.1 (2012年), Promise が入ったのは v0.11.13 (2014年), queueMicrotask が入ったのは v11.0.0 (2018年) ↩︎

Discussion