JavaScriptの同時実行モデルについて


概要

JavaScriptが多くの言語と異なる点のひとつに、イベントループベースの同時実行モデルがあります。これは、JavaScript自体というよりも、ブラウザの仕組みにも関わりがあります。

今回はそれについて書いてみようと思います。


ラインタイム

MDNの図を拝借すると、JavaScriptのラインタイムは以下のような要素で構成されています。ひとつずつ説明してゆきます。

runtime.png


ヒープ

ヒープはヒープですね。今回のテーマとは少し外れるので省略します。


スタック

JavaScriptで処理を呼び出すと、コールスタックが積み上がります

JavaScriptはシングルスレッドなので、一度にひとつの処理しか実行できません。例えば、以下のように関数を呼び出すような処理を考えます。

function subA() { console.log('A'); }

function subB() { console.log('B'); }

function main() {
subA();
subB();
}

main();

これは、以下のようなスタックを形成します。

stack.gif



  1. mainのフレーム


  2. mainから呼び出されたsubAのフレーム


  3. subAの実行が完了するとsubAのフレームはスタックからポップアウト


  4. mainから呼び出されたsubBのフレーム


  5. subBの実行が完了するとsubBのフレームはスタックからポップアウト

  6. すべての実行が完了するとmainのフレームはスタックからポップアウト

上記のように同期的な関数実行では、処理がひとつずつ行われてゆくことがわかります。


キュー

JavaScriptにおけるキューとは、次に実行すべき処理の一覧といえます。

ここではsetTimeoutを使用した以下のコードを考えてみます。

function setTimeoutCallback() {

console.log('A');
}

function subA() {
setTimeout(setTimeoutCallback, 7000)
}

function subB() { console.log('B'); }

function main() {
subA();
subB();
}

main();

これは、以下のようなスタックとキューを形成します。

queue.gif



  1. mainのフレーム


  2. mainから呼び出されたsubAのフレーム


  3. subAから呼び出されたsetTimeoutのフレーム

  4. setTimeoutの実行が完了するとsetTimeoutのフレームはスタックからポップアウト。ブラウザ側でタイマーの計算開始。


  5. mainから呼び出されたsubBのフレーム


  6. subBの実行が完了するとsubBのフレームはスタックからポップアウト

  7. 4のタイマーが終了すると、ブラウザがイベントをキューに追加する

  8. キューに追加された処理をイベントループで検知、処理を実行する

  9. すべての実行が完了するとmainのフレームはスタックからポップアウト

4でタイマーの処理がJavaScriptのメインスレッドを離れており、キューの仕組みがあることでノンブロッキングに処理が進んでいます。

さらに、setTimeoutを連続で呼び出す場合も考えてみます。

function setTimeoutCallback() {

console.log('A');
}

function subA() {
setTimeout(setTimeoutCallback, 7000)
setTimeout(setTimeoutCallback, 7000)
setTimeout(setTimeoutCallback, 7000)
setTimeout(setTimeoutCallback, 7000)
setTimeout(setTimeoutCallback, 7000)
}

function subB() { console.log('B'); }

function main() {
subA();
subB();
}

main();

これは、以下のようなスタックとキューを形成します。

Qiita、size>10MBアップできなかった.gif

上記のように、呼び出し自体は関数ひとつずつですが、処理自体はブラウザAPIによって同時実行され、完了したものからJavaScript側のキューにたまってゆくという仕組みです。

これはスレッドなどで実現できる並列(parallel)処理とはいえませんが、実際の処理をJavaScriptランタイムの外にだすことで、並行(concurrent)に処理を実行していると言えます。

(並行/並列/非同期/ノンブロッキングあたりは勘違いされやすいので、違いをおさえておくといいと思います)。


イベントループ

上記でも言及した、キューを検知できる仕組みをイベントループと呼びます。イベントループは、イベントを待機する実装のひとつで、MDNの例を借りるなら、以下のような実装に似ています。

while(queue.waitForMessage()){

queue.processNextMessage();
}

シングルスレッドでのイベントループは、同時多発的に発生するイベントを、軽量に扱うのに向いています。これはプロセスが大量に立ち上がり、メモリやコンテキストスイッチで処理が重くなるのを防ぐことができるからです。(ただし、大量の計算など時間のかかる処理は苦手で、処理が終わるまで後続処理をブロックしてしまいます)。

JavaScript以外でシングルスレッドのイベント駆動アーキテクチャを採用しているものとしては、nginxが有名です。

nginxは、上記のような方針でC10K問題を解決しようとしていて、Node.jsもその文脈で話されているのをたまにみかけます。しかし、もともとJavaScriptは、90年代のウェブブラウザという、十分なリソースがない環境のために開発された言語です。限られたCPUで少しでもまともに動くように実装されたのがシングルスレッド+イベントループというアーキテクチャだった、というのが正しい背景かと思います。

つまり、イベントループのアーキテクチャは、ブラウザで発生する大量のイベントを処理するのに(少なくとも当時は)最適なユースケースであったと考えることができます。現在では、Goのように軽量スレッドを使用した方が線がいい場合も多いかもしれません。


応用例

ここで少し実際に役に立ちそうな例を見てみます。

有名な例ではありますが、setTimeout(func, 0)を使って処理をキューに外出しし、描画タイミングを早くするという手法があります。例えば、以下のように、DOMの変更処理のあとに、かなり時間のかかる処理があったとします。

// DOMの変更処理

var target = document.getElementById('target');
target.textContent = 'updated';

// 時間のかかる処理
var calcResult;
function longTask() {
for (var i = 0; i < 1000000000; i++) {
calcResult = i * 3;
}
}
longTask();

このようなコードを書くと、実際の画面変更はすぐには行われません。ブラウザで描画の処理が走る前に、10億回のループでスレッドが埋まってしまうので、描画が後回しにされてしまいます。メインスレッドを見るとlongTaskに約4秒かかっているので、その分ユーザーはインタラクションを待つことになるでしょう。

long-task.png

描画を早くする方法のひとつとして、以下のように「setTimeout(func, 0)で処理を一旦キューに押し込める」などがあります。

// DOMの変更処理

var target = document.getElementById('target');
target.textContent = 'updated';

// 時間のかかる処理
var calcResult;
window.setTimeout(function longTask() {
for (var i = 0; i < 1000000000; i++) {
calcResult = i * 3;
}
}, 0)

こうすることで、描画処理を先に走らせることができるため、パフォーマンスを改善することができます。

long-task-queued.png

ただし、後続の処理をブロックしてしまうことには変わりないので、処理中の表示にしたり、Workerを使ったりするなども検討したほうがよさそうです。いずれにせよ、仕組みを理解しておくことは思わぬ事故へのリスクヘッジにもなると考えます。


まとめ

今回はJavaScriptの同時実行モデルについて紹介しました。以下の言葉を聞いてスッとイメージができればこの記事のまとめになっているかなと思います。


  • スタック、キュー

  • シングルスレッド、イベントループ

  • 並列、並行

お役に立てれば幸いです。


参考