徹底解説: マイクロタスクと JavaScript ランタイム環境
タスクやマイクロタスクのタイミングとスケジューリングに関する問題をデバッグするとき、あるいは解決方法を選ぶときには、JavaScript ランタイムが内部でどのように動作するかを理解しておくと役立つことがあります。
JavaScript は本質的にシングルスレッドの言語です。一般向けに利用できるマルチプロセッサーコンピューターがほとんどなく、JavaScript が処理することになるコードの量も比較的少ないと予想されていた時代には、これは好ましい選択でした。
時が経つにつれ、コンピューターは強力なマルチコアシステムへと進化し、JavaScript はコンピューター分野で最も広く使われる言語の一つになりました。最も人気のあるアプリケーションの多くは、少なくとも一部で JavaScript コードを利用しています。これに対応するため、プロジェクトがシングルスレッド言語の制限を超えられるようにする方法が必要になりました。
Web API にタイムアウトとインターバル(setTimeout() および setInterval())が追加されたことを皮切りに、ウェブブラウザーが提供する JavaScript 環境には、タスクをスケジューリングしたり、マルチスレッドアプリケーションを開発したりできる強力な機能が段階的に追加されてきました。ここで queueMicrotask() がどのように関わるかを理解するには、コードをスケジューリングして実行するときの JavaScript ランタイムの動作を知っておくと役立ちます。
JavaScript の実行コンテキスト
メモ: ここで説明する詳細は、ほとんどの JavaScript プログラマーにとって一般に重要ではありません。この情報は、マイクロタスクがなぜ役立ち、どのように機能するかを説明するためのものです。必要なければ読み飛ばし、必要になったときに戻ってきてもかまいません。
JavaScript コードの一部が実行されるとき、それは実行コンテキスト内で実行されます。新しい実行コンテキストを作成するコードには、次の 3 種類があります。
- グローバルコンテキストは、コードの本体(つまり、JavaScript 関数の外にあるコード)を実行するために作成される実行コンテキストです。
- 各関数は、それぞれ独自の実行コンテキスト内で実行されます。これは「ローカルコンテキスト」と呼ばれることもよくあります。
- 推奨されない
eval()関数を使用した場合も、新しい実行コンテキストが作成されます。
それぞれのコンテキストは、本質的にはコード内のスコープの一段階です。コードの一部の実行が始まると、それを実行するための新しいコンテキストが作られ、コードの実行が終了すると、そのコンテキストは破棄されます。次の JavaScript プログラムを考えてみましょう。
const outputElem = document.getElementById("output");
const userLanguages = {
Mike: "en",
Teresa: "es",
};
function greetUser(user) {
function localGreeting(user) {
let greeting;
const language = userLanguages[user];
switch (language) {
case "es":
greeting = `¡Hola, ${user}!`;
break;
case "en":
default:
greeting = `Hello, ${user}!`;
break;
}
return greeting;
}
outputElem.innerText += `${localGreeting(user)}\n`;
}
greetUser("Mike");
greetUser("Teresa");
greetUser("Veronica");
この短いプログラムには 3 種類の実行コンテキストが含まれており、その一部はプログラムの実行中に何度も作成と破棄が繰り返されます。各コンテキストは作成されると実行コンテキストスタックに積まれます。終了すると、そのコンテキストはスタックから取り除かれます。
-
プログラムの開始時に、グローバルコンテキストが作成されます。
-
greetUser("Mike")に到達すると、greetUser()関数のコンテキストが作成され、この実行コンテキストが実行コンテキストスタックに積まれます。greetUser()がlocalGreeting()を呼び出すと、その関数を実行する別のコンテキストが作成されます。この関数が返ると、localGreeting()のコンテキストは実行スタックから取り除かれ、破棄されます。プログラムの実行はスタック上の次のコンテキスト、つまりgreetUser()で再開されます。この関数は中断した箇所から実行を続けます。greetUser()関数が返ると、そのコンテキストはスタックから取り除かれ、破棄されます。
-
greetUser("Teresa")に到達すると、その関数のコンテキストが作成され、スタックに積まれます。greetUser()がlocalGreeting()を呼び出すと、その関数を実行する別のコンテキストが作成されます。この関数が返ると、localGreeting()のコンテキストは実行スタックから取り除かれ、破棄されます。greetUser()は中断した箇所から実行を続けます。greetUser()関数が返ると、そのコンテキストはスタックから取り除かれ、破棄されます。
-
greetUser("Veronica")に到達すると、その関数のコンテキストが作成され、スタックに積まれます。greetUser()がlocalGreeting()を呼び出すと、その関数を実行する別のコンテキストが作成されます。この関数が返ると、localGreeting()のコンテキストは実行スタックから取り除かれ、破棄されます。greetUser()関数が返ると、そのコンテキストはスタックから取り除かれ、破棄されます。
-
-
メインプログラムが終了すると、その実行コンテキストは実行スタックから取り除かれます。スタック上にコンテキストが残っていないため、プログラムの実行も終了します。
このように実行コンテキストを使うことで、それぞれのプログラムや関数は独自の変数やその他のオブジェクトを持てます。各コンテキストは、次に実行するプログラムの行や、そのコンテキストの動作に不可欠なその他の情報も追跡します。このようにコンテキストとコンテキストスタックを使うことで、ローカル変数やグローバル変数、関数の呼び出しや返り値など、プログラムの動作に関する多くの基本的な要素を管理できます。
再帰関数、つまり自分自身を呼び出す関数についても注意が必要です。再帰の深さが複数段階になる場合もあります。関数を再帰的に呼び出すたびに、新しい実行コンテキストが作成されます。これにより、JavaScript ランタイムは再帰の深さや、再帰を通じた結果の返却を追跡できます。一方で、関数が再帰するたびに、新しいコンテキストを作成するためのメモリーも必要になります。
JavaScript を実行する
JavaScript コードを実行するため、ランタイムエンジンは JavaScript コードを実行するエージェントの集合を維持します。各エージェントは、実行コンテキストの集合、実行コンテキストスタック、メインスレッド、ワーカーを処理するために作成される追加スレッドの集合、タスクキュー、マイクロタスクキューで構成されています。メインスレッドを除き(ブラウザーによっては複数のエージェントで共有されます)、エージェントの各構成要素はそのエージェント固有のものです。
ここでは、ランタイムの動作をもう少し詳しく見ていきます。
イベントループ
各エージェントはイベントループによって駆動され、その処理が繰り返されます。各反復では、保留中の JavaScript タスクを最大 1 つ実行し、次に保留中のマイクロタスクを実行してから、必要なレンダリングと描画を行い、次の反復に進みます。
ウェブサイトやアプリのコードは、ウェブブラウザー自体のユーザーインターフェイスと同じ スレッド で実行され、同じイベントループを共有します。これは メインスレッド です。サイトのメインコードを実行するだけでなく、ユーザーなどからのイベントの受信とディスパッチ、ウェブコンテンツのレンダリングと描画なども処理します。
したがって、イベントループはユーザーとの対話に関するブラウザー内のすべての動作を駆動します。ここで重要なのは、イベントループがそのスレッド内で実行されるあらゆるコードのスケジューリングと実行を担うことです。
イベントループには次の 3 種類があります。
- ウィンドウイベントループ
-
ウィンドウイベントループは、同じオリジンを共有するすべてのウィンドウを駆動します(ただし、後述のように、これにはさらなる制限があります)。
- ワーカーイベントループ
-
ワーカーイベントループはワーカーを駆動します。これには、基本的なウェブワーカー、共有ワーカー、サービスワーカーなど、すべての種類のワーカーが含まれます。ワーカーは「メイン」コードとは別のエージェントに置かれます。ブラウザーは、同じ種類のすべてのワーカーに単一のイベントループを使う場合も、複数のイベントループを使って処理する場合もあります。
- ワークレットイベントループ
-
ワークレット イベントループは、各エージェントのワークレット用コードを実行するエージェントを駆動します。これには
Worklet型のワークレットとAudioWorkletが含まれます。
同じ オリジンから読み込まれたいくつかのウィンドウは、同じイベントループ上で動作することがあります。それぞれがイベントループにタスクをキューイングし、タスクはプロセッサーを順番に使って交互に実行されます。ウェブ用語での「ウィンドウ」は、実際のウィンドウ、タブ、フレームなど、ウェブコンテンツを実行するブラウザーレベルのコンテナーを指すことに注意してください。
共通のオリジンを持つウィンドウ間でイベントループを共有できる具体的な状況には、次のようなものがあります。
- 一方のウィンドウがもう一方のウィンドウを開いた場合、同じイベントループを共有する可能性があります。
- ウィンドウが実際には
<iframe>内のコンテナーである場合、そのウィンドウは、それを含むウィンドウとイベントループを共有する可能性があります。 - マルチプロセスのウェブブラウザー実装で、ウィンドウが同じプロセスを共有している場合。
詳細は、ブラウザーの実装方法に応じて異なることがあります。
タスクとマイクロタスク
タスクとは、スクリプトの初回実行やイベントの非同期ディスパッチなど、標準的な仕組みによって実行がスケジューリングされるものです。イベントを使う以外に、setTimeout() または setInterval() を使ってタスクをキューイングできます。
タスクキューとマイクロタスクキューの違いは単純ですが、とても重要です。
- イベントループの新しい反復が始まると、ランタイムはタスクキューから次のタスクを実行します。それ以外のタスクや、反復の開始後にキューへ追加されたタスクは、次の反復まで実行されません。
- タスクが終了して実行コンテキストスタックが空になるたびに、マイクロタスクキュー内のすべてのマイクロタスクが順番に実行されます。マイクロタスクの実行は、途中で新しいものがスケジューリングされても、キューが空になるまで続きます。つまり、マイクロタスクは新たなマイクロタスクをキューイングでき、それらの新しいマイクロタスクは、次のタスクが実行を開始する前、かつ現在のイベントループの反復が終了する前に実行されます。
問題
コードはブラウザーのユーザーインターフェイスと同じスレッドで、同じイベントループを使って実行されるため、コードが処理をブロックしたり無限ループに入ったりすると、ブラウザー自体が停止します。バグや複雑な処理による性能低下でも、ブラウザーの動作が遅くなり、ユーザーに影響することがあります。
複数のプログラムや、それらのプログラム内の複数のコードオブジェクトが同時に動作しようとすると、プロセッサー時間を必要とするブラウザーとの間で、処理が容易に詰まってしまいます。ブラウザーはサイトや自身のユーザーインターフェイスのレンダリングと描画、ユーザーイベントの処理などにも時間を必要とします。
解決策
ウェブワーカーを使うと、メインスクリプトは新しいスレッドで他のスクリプトを実行できるため、この問題を軽減できます。適切に設計されたウェブサイトやアプリでは、複雑な処理や長時間かかる処理をワーカーに任せ、メインスレッドではウェブページの更新、レイアウト、レンダリング以外の作業をできるだけ減らします。
また、プロミスなどの非同期 JavaScript の手法を使うと、リクエストの結果を待つ間もメインコードの実行を続けられるため、問題をさらに軽減できます。ただし、ライブラリーやフレームワークを構成するコードのように、より根本的なレベルで動作するコードでは、個々のリクエストやタスクの結果とは無関係に、メインスレッド上で実行しながら、安全なタイミングでコードを実行する方法が必要になる場合があります。
マイクロタスクもこの問題の解決策の一つです。イベントループの次の反復まで待つのではなく、その反復が始まる前にコードを実行できるため、より細かく制御できます。
マイクロタスクキューは以前からありますが、歴史的にはプロミスなどを動かすための内部用途に限られていました。queueMicrotask() が追加され、ウェブ開発者にも公開されたことで、JavaScript の実行コンテキストスタックに実行コンテキストが残っていないときに、安全にコードをスケジューリングする必要がある場面で使える、マイクロタスク用の統一されたキューができました。複数のインスタンス、すべてのブラウザー、JavaScript ランタイムにまたがって標準化されたキューの仕組みを使えば、マイクロタスクは常に同じ順序で確実に動作し、発見が難しいバグを避けられます。