Zenn
🤖

JavaScriptの実行メカニズムをまとめる

に公開
15

JavaScriptの非同期処理を調べていると、度々イベントループという用語を目にします。なんとなく概要は知っていても、具体的に何が行われているのかといった仕組みを詳細に理解しているとは言い切れませんでした。そもそもJavaScriptがどのように実行されているかがあやふやです。

気になって調べていると、イベントループとプロミスチェーンで学ぶJavaScriptの非同期処理という記事を見つけました。この記事は非同期処理を理解するために最初にイベントループを学びます。その部分ではイベントループだけではなく、それ以外のJavaScriptの実行環境や実行のメカニズムなども解説されていて、とても参考になりました。

この投稿は、上述の記事をベースにして非同期処理やイベントループを含むJavaScriptの実行メカニズムを調べ、自分の理解のためにまとめたものです。以下はJavaScriptの実行メカニズムの概要で、これに沿ってまとめていきます。

概要


JavaScriptの実行環境

実行環境

ここでは、JavaScriptの実行環境の全体像を見ていきます。

「JavaScriptはシングルスレッドで実行される」と言われていますが、ブラウザやNode.jsなどのJavaScritpの実行環境全体で見たときにはシングルスレッドではありません。シングルスレッドで実行されている部分は、JavaScriptエンジンによるJavaScriptの実行であり、JavaScriptの実行環境の中にはそれ以外のコンポーネントも存在します。ここではそれらをJavaScriptエンジンの外部にあるという意味で外部環境と呼びます。JavaScriptの実行環境はホスト環境とも呼ばれます。

JavaScriptエンジンはJavaScriptを実行するプログラムで、一般的にはECMAScriptを実装しています。ChromeやNode.jsではV8と呼ばれるものが使用されており、JavaScriptの言語仕様であるECMAScriptを実装しています。広く利用されているJavaScriptエンジンは一般的にECMAScriptを実装しており、ECMAScriptで定義された機能を使用することができます。JavaScriptエンジンでJavaScriptを実行しているスレッドは基本的に一つであり、これをメインスレッドと呼びます。

JavaScriptでは、ECMAScriptによって定義されていない機能は外部環境によって提供されます。ECMAScriptでは定義されていないものとしてはconsole.log()fetch()setTimeout()などがあります。多くの実行環境で同じ名前でAPIが提供されていることが多いのですが、ECMAScriptでは定義されておらず、外部環境に固有のものになっています。

外部環境とは、JavaScript実行環境のうちJavaScriptエンジンではないもので、イベントループや、ECMAScriptで定義されていないAPIが提供されています。イベントループはJavaScriptの実行のようにシングルスレッドで動作していますが、それ以外のものはマルチスレッドで動作することがあります。例えば実行環境であるNode.jsでは、外部環境でファイルI/Oが提供されていますが、これは現時点ではマルチスレッドで実装されています。ブラウザでは外部環境が提供するAPIをWeb APIと呼びますが、外部環境によって提供されるAPIの総称がなさそうだったので、ここでは外部環境APIと呼ぶことします。

JavaScriptエンジンが外部環境APIを呼び出すためには、バインディングと呼ばれる仕組みを使用します。JavaScriptの実行環境であるブラウザやNode.jsは、このバインディングを使用して外部環境APIをJavaScriptから呼び出せるようにしています。Chrome(Chromium)の内部では、Web IDLと呼ばれる言語でインターフェースを宣言することで、ブラウザのビルド時にバインディングコードを生成できるようになっています。バインディングコードは、JavaScriptエンジンのAPIを使用してJavaScriptの関数名と外部環境APIを紐づけ、JavaScriptから呼び出せるようにします。

実行コンテキストとコールスタック

JavaScriptエンジン

ここでは、JavaScriptエンジンがJavaScriptを実行する仕組みを見ていきます。

実行コンテキストとは、JavaScriptエンジンがコードの実行時評価を追跡するために作成し、コールスタックに積まれていくものです。実行コンテキストは、関数や最初のJavaScriptコードを実行する前に作成され、それを使ってコードを実行します。実行コンテキストの中には、環境(Environment)と呼ばれる、コードで使用している変数や関数が保存されている領域があります。環境は、後述するように外部の環境の参照を含むケースがあります。

一般的に使用される実行コンテキストは以下の2種類が存在します。

  • グローバル実行コンテキスト(GEC)
    • JavaScriptコードの最初の実行前に作成される
    • 自身の環境には、グローバルに存在する変数や関数、Built-inのオブジェクトが保存される
  • 関数実行コンテキスト(FEC)
    • 関数が呼び出されるたびに作成される
    • 自身の環境には、その関数で定義された変数や関数が保存される

実行コンテキストには、作成フェーズ実行フェーズという2つのフェーズが存在します。

作成フェーズでは、JavaScriptエンジンが実行コンテキストを作成して環境をセットアップします。関数実行コンテキストの場合、関数で定義されている変数や関数などを環境に保存して、実行フェーズで使えるようにします。

実行フェーズでは、JavaScriptエンジンが実行コンテキストでコードを実行します。スコープにある変数や関数はすべて実行コンテキストの環境に保存されているので、これを使用してコードを実行します。例えばJavaScriptのコードが関数を呼び出したとき、関数の識別子を使って環境から関数の実体を取得して実行します。

JavaScriptの実行は、コールスタックに実行コンテキストを積み重ねることによって行います。コールスタックの一番上の実行コンテキストが、実行中のコンテキストになります。関数やすべてのコードの実行が終了すると、実行コンテキストがコールスタックから取り除かれます。

同期的なJavaScriptの実行の流れは以下のようになります。

  1. 一番最初のJavaScriptコードの実行前にグローバル実行コンテキストを作成する
  2. 1のグローバル実行コンテキストをコールスタックに積んで実行する
  3. 関数が呼び出されると以下を行う
    1. 関数実行コンテキストを作成する
    2. 1の関数実行コンテキストをコールスタックに積んで実行する
    3. 関数が終了したら、コールスタックから実行コンテキストを取り除く
  4. コールスタックからグローバル実行コンテキストを取り除く

また、実行コンテキストの環境は、レキシカルスコープに基づいて外部の実行コンテキストの環境を参照する必要があります。JavaScriptはレキシカルスコープと言って、関数がソースコード上でどこに定義されているかによって、参照できる変数や関数が決定されるスコープを使用しています。関数実行コンテキストの環境には、実行している関数で定義された変数や関数しか保存されていないため、外部の実行コンテキストにアクセスする必要が出てきます。

これを実現するために、実行コンテキストの作成フェーズでスコープチェーンを作成します。スコープチェーンは、実行コンテキストの環境が、一つ外側のスコープの実行コンテキストの環境を参照する形で作成されます。一つ外側のスコープの環境は、さらに一つ外側の環境を参照しており、参照のチェーンの最後はグローバル実行コンテキストの環境になっています。関数の外部の変数を参照するときには、実行コンテキストの環境が参照している環境を探しに行きます。

タスクとマイクロタスク

タスクとマイクロタスク

ここでは、外部環境APIに渡されるコールバック関数を実行する仕組みを見ていきます。

外部環境APIの多くは、処理が完了したあとに実行するためのコールバック関数を受け取ります。例えばsetTimeout()はタイムアウトで実行するコールバック関数を渡せますし、fetch().then()はネットワークI/Oの完了で実行するコールバックを渡せます。Promiseベースの外部環境APIでは、Promise.then()の代わりに、awaitを使うことはできますが、内部的にはawait以降の処理はコールバックと同じように扱われます。

タスクマイクロタスクとは、実行がスケジュールされたJavaScriptのコードです。タスクはJavaScriptの実行開始やイベントのディスパッチなどによってスケジュールされたプログラムで、マイクロタスクは一般的にはPromiseによってスケジュールされたプログラムです。

外部環境APIが完了すると、渡したコールバックはタスクマイクロタスクとして外部環境にあるキューに詰められ、適切なタイミングでコールスタックに積まれて実行されます。setTimeout()はタイムアウト後に、渡されたコールバック関数をタスクとしてタスクキューに詰めます。そのあとにタスクから実行コンテキストが作成され、コールスタックに積まれて実行されます。また、Promise.then()にはPromiseインスタンスがfulfilledになったときに実行する関数を渡すことができ、こちらはマイクロタスクになります。

Promiseベースの処理ではマイクロタスク、それ以外だとタスクが実行されることが多いです。

タスクとマイクロタスクの違いは、マイクロタスクのほうがタスクよりも優先して処理されるということです。一つのタスクの実行が終わったあと、マイクロタスクキューに存在しているマイクロタスクがすべて実行されます。なので、マイクロタスクを実行中にマイクロタスクを作成し続けると、後続のタスクはその分だけ遅れることになります。

先述しましたが、JavaScriptの実行開始でもタスクが作成されてタスクキューに詰められます。コンソールやscriptタグでJavaScriptのコードを読み込んで実行するときには、その実行がタスクとしてタスクキューに詰められています。これはJavaScriptの実行において一番最初のタスクと呼ぶことができ、コンソールやscriptタグでJavaScriptを実行する際に同期的な処理はまとめてタスクとして実行されています。そのため、そのタスクでマイクロタスクとタスクを発行した場合、次に実行されるのはマイクロタスクになります。

JavaScriptの最初の実行がタスクであることが分かっていないと、実行順で混乱するかもしれません。プログラムを実行してタスクとマイクロタスクを発行すると、マイクロタスクが先に実行されるため、マイクロタスクが処理されたあとにタスクが実行されるのだと勘違いしてしまう可能性があります。実際には、最初のプログラムの実行がすでにタスクになっているため、実行の順番はタスクのあとにマイクロタスクになります。

JavaScriptの最初の実行がタスクなので、JavaScriptのコードはすべてタスクかマイクロタスクになると言えます。最初の実行がタスクということは、同期的なコードはすべて最初のタスクで処理され、イベントやネットワーク通信のコールバックなどの非同期なコードはタスクやマイクロタスクになります。それ以外でコードが実行されることがないので、すべてのJavaScriptのコードはタスクかマイクロタスクになります。

マイクロタスクキューは1つなのですが、タスクキューは1つ以上でも良いことになっています。どのタスクキューからタスクを取得するかは実装依存になっているのですが、タスクキューが複数あることで種類別の優先度をつけられます。タスクにはタスクソースという属性があり、同一タスクソースのタスクは同じキューに送られます。

マイクロタスクが実行される適切なタイミングとは、コールスタックが空になったタイミングです。マイクロタスクはタスクよりも優先されるので、コールスタックが空になったタイミングでマイクロタスクキューにマイクロタスクが存在すれば、それを必ず実行します。例えばPromise.resolve().then(callback)を呼び出した場合、マイクロタスクをキューに詰める処理を外部環境APIに委譲して、JavaScriptエンジンは後続の処理を進めます。その後コールスタックが空になると、実行コンテキストが作成されてcallbackが実行されます。

タスクが実行される適切なタイミングとは、コールスタックとマイクロタスクキューが空になったタイミングです。例えばsetTimeout()を呼び出した場合、タイマー処理を外部環境APIに委譲して、JavaScriptエンジンは後続の処理を進めます。タイマーが終了するとコールバックがタスクとしてタスクキューに詰められます。その後コールスタックとマイクロタスクキューが空でなると、実行コンテキストが作成されてsetTimeout()のコールバックが実行されます。

イベントループ

イベントループ

ここでは、タスクやマイクロタスクが実行される仕組みについて見ていきます。

これまでに、JavaScriptエンジンはコールスタックの中に実行コンテキストを積んでコードを実行することや、外部環境APIがタスクやマイクロタスクを作成してキューに詰めるということを説明してきました。一方で、タスクやマイクロタスクが実行される仕組みについては具体的に説明していませんでした。

イベントループとは、タスクキュー/マイクロタスクキューからタスク/マイクロタスクを取得して実行するループアルゴリズムのことです。イベントループはJavaScriptエンジンではなく外部環境で実装されており、タスクキューやマイクロタスクキューはイベントループが所有しています。マイクロタスクキューは1つであると書きましたが、イベントループごとに1つという意味です。また、イベントループはメインスレッドで実行されており、このスレッドでユーザーのJavaScriptも実行されます。

イベントループは外部環境ごとに違いはあるのですが、基本的には1つのループで以下のような処理を順番に実行します。

  1. 単一のタスクの実行
  2. すべてのマイクロタスクの実行

擬似コードで書くと以下のようになります。タスクの実行はifですが、マイクロタスクの実行はwhileでループさせていることに注意してください。

while (eventLoop.waitForTask()) {
    const taskQueue = eventLoop.selectTaskQueue();
    if (taskQueue.hasNextTask()) {
        taskQueue.processNextTask();
    }

    const microtaskQueue = eventLoop.microtaskQueue;
    while (microtaskQueue.hasNextMicrotask()) {
        microtaskQueue.processNextMicrotask();
    }
}

マイクロタスクキューはイベントループごとに1つですが、タスクキューは複数存在するため、どのタスクキューを処理するかを選択する必要があります。例えばブラウザでは、マウスクリックやキー入力などのユーザーが操作したあとに実行する必要のあるタスクを優先的に処理します。

イベントループは外部環境ごとに違いがあるのですが、共通しているのは「単一のタスクを処理したあとに、すべてのマイクロタスクを処理する」ということです。外部環境ごとに異なるのは、複数あるタスクキューの優先度の決め方や、画面へのレンダリング作業の有無などです。

外部環境ごとの違いとして、ブラウザでは画面へのレンダリングをイベントループの中で行うというのがあります。レンダリングは60fpsを目安にして、1/60秒(16.7ms)毎に実行されます。イベントループの中では、前回のレンダリングから16.7ms経過していればレンダリングを実行します。レンダリングはタスクやマイクロタスクの実行と同じようにイベントループの中で処理されるため、メインスレッドで実行されることになります。そのため、タスクやマイクロタスクの処理時間が16.7msを超えてしまうとフレームレートが60fpsから落ちてしまいます。

Node.jsのイベントループでは、各タスクキューがフェーズに結びついており、ループの1周で一つのフェーズのタスクを一定数実行します。フェーズは6つ存在しており、それぞれのフェーズで各タスクを実行したあと、すべてのマイクロタスクを実行します。上の擬似コードでは一つのタスクしか処理していませんが、Node.jsではループ1周でフェーズのタスクを一定数実行します。

また、Node.jsにはもう一つのタスクキューであるnextTickQueueが存在しており、マイクロタスクキューよりも優先されます。nextTickQueueにマイクロタスクを詰めるためには、process.nextTick()APIを使用します。この仕組みはPromiseが導入される前に追加されたもので、現在は通常のマイクロタスクキューに詰めるqueueMicrotask()が推奨されています。

イベントループはあらゆるJavaScriptの実行をトリガーするため、JavaScript実行環境の中心にあると言えます。上でも書きましたが、JavaScriptの最初の実行はタスクになり、それ以降のコードもタスクかマイクロタスクになります。イベントループはタスクやマイクロタスクを実行するので、JavaScriptのコードはすべてイベントループが実行をトリガーすることになります。

非同期処理の仕組み

ここでは、イベントループによって実現される非同期処理の仕組みを見ていきます。

非同期処理の説明の前に、まずは並列処理並行処理について考えます。

並列処理とは、複数の処理を同時に実行させることです。同時に実行できる数の上限はCPUのコア数と同じになります。ある時点を見たときに、複数の処理が同時に実行されている場合、並列処理が行われているといいます。

並行処理とは、複数の処理を同時に実行しているように見せることです。ある時点を見たときは一つの処理しか実行されていなくても、ある時間の範囲を見たときに複数の処理が実行されており、実行途中で処理が切り替わっている場合、並行処理が行われてるといいます。並行処理は、短い時間で複数の処理を切り替えることで実現できます。

並行処理は並列処理を包含していると言えるかもしれません。並行処理は同時に実行しているように見せることですが、同時に実行しているときにもそう見えてはいます。一方で、並列処理は並行処理と違ってスループット向上の文脈で使われることが多いため、そういった意味で並行処理と呼ぶのは不自然かもしれません。

ここでは、非同期処理とは「並行して処理を実行し、完了後に対応する処理を行うこと」とします。setTimeout()は、指定された時間を待機する処理を他の処理と並行して実行し、待機が完了したらコールバックの処理を実行することができます。非同期処理は、「並行して行う処理」と「完了後に行う処理」に分けて考えることができます。setTimeout()であれば、前者がsetTimeout()で後者が渡したコールバックです。Promiseを使用するfetch()の場合は、前者がfetch()で後者がawait以降の処理です。

非同期処理の定義にある並行は、並列を包含した意味で使っています。setTimeout()で時間を待機したり、fetch()でネットワーク通信を行う処理は、外部環境によってマルチスレッドで実行されるかもしれませんし(並列)、I/O多重化によってシングルスレッドで実行されるかもしれません(並行)。ここでは両者を区別する必要がないため、並列を包含した並行を使っています。ただ、JavaScriptはシングルスレッドであるという思い込みから、マルチスレッドで処理されることがないと思い込む可能性はあります。JavaScriptの実行環境でも説明しましたが、シングルスレッドなのはJavaScriptエンジンによる実行だけです。

JavaScriptでの非同期処理の目的は、時間のかかる処理の待機中に他の処理を実行して応答性を維持することです。時間のかかるネットワークI/Oやタイマー処理を同期処理で行うと、その間はメインスレッドがブロックされて他の処理を実行できなくなります。そうすると画面が固まってしまい、ユーザーからの操作に応答できません。一方で非同期処理では、それらの時間のかかる処理と並行して別の処理が実行できるようになるため、応答性が落ちにくいです。

JavaScriptの非同期処理は、外部環境APIとイベントループによって実現されています。非同期処理には「並行して行う処理」と「完了後に行う処理」があると書きましたが、前者を外部環境APIによって、後者をイベントループによって実現しています。

非同期処理において外部環境APIは、マルチスレッドが許された環境で並行処理を実現します。JavaScriptの実行とは違ってシングルスレッドという制限がないので、別のスレッドで処理を実行できます。JavaScriptエンジンから見たとき、外部環境に処理を委譲しているため、JavaScriptの実行と外部環境APIを使用した処理は並行処理と言えます。

非同期処理においてイベントループは、シングルスレッドの環境で並行処理を実現します。イベントループはタスクやマイクロタスクといった単位で処理を切り替えることで並行処理を実現しています。

このように、JavaScriptでは外部環境APIとイベントループを使用して非同期処理を実現しています。具体的な非同期処理の流れは以下のようになります。

  1. JavaScriptが非同期の外部環境API (setTimeout()など) を呼ぶ
  2. 他の処理を実行する
  3. 外部環境APIが処理を並行で実行する
  4. 外部環境APIが完了すると、完了後の処理をタスク/マイクロタスクとしてキューに詰める
  5. イベントループが適切なタイミングでタスク/マイクロタスクを実行する

さいごに

JavaScriptの実行メカニズムについてまとめました。

JavaScriptはイベントループから始まると言っても過言ではなく、実行メカニズムの中心に位置していると考えています。あらゆるJavaScriptのコードはイベントループによって実行がトリガーされるため、イベントループの理解が実行メカニズムの理解に繋がります。

また、JavaScriptの実行環境にはJavaScriptエンジンの他にも様々なコンポーネントがあることは意識しておく必要があります。シングルスレッドなのはJavaScriptを実行するJavaScriptエンジンであり、他のコンポーネントはマルチスレッドであることが一般的です。そのため、実行環境全体で見たときには並列処理が実現される可能性はあります。あと、外部環境や外部環境APIは僕が作った造語なので、一般的な用語ではないことに注意してください・・・。

この投稿がJavaScriptの実行メカニズムの理解の助けになることを願っています。

参考資料

15

Discussion

ログインするとコメントできます