Qiita Teams that are logged in
You are not logged in to any team

Log in to Qiita Team
Community
OrganizationEventAdvent CalendarQiitadon (β)
Service
Qiita JobsQiita ZineQiita Blog
4
Help us understand the problem. What is going on with this article?
@hirokikondo86

JavaScriptのイベントループを理解する

イベントループとは?

JavaScriptはシングルスレッドです。一度に実行できるタスクは1つだけです。つまり、

  • 2つ以上の処理を並行して実行できない
  • 2つ以上の関数を同時実行できない

ということになります。

例えば、10秒掛かるタスクを実行すると、他のタスクは10秒間待機しなければなりません。
JavaScriptはデフォルトでブラウザのメインスレッドで実行されるため、UI全体が動かなくなります。

では、イベントループについて解説していきます。
まず、V8などのJavaScriptエンジンやブラウザには、下記の画像の様にコールスタック(Call Stack)ヒープ(Heap)タスクキュー(Task Queue)Web APIと呼ばれる4つのメカニズムを備えています。

JavaScriptエンジン

まずは、このメカニズムを簡単に説明していきます。

Web API

Web APIにはDOM Event, setTimeout, Ajaxなどが含まれます。これらの機能は非同期(ノンブロッキング)に実行されます。
ブラウザが提供する機能です。

ヒープ(Heap)

動的に確保と解放を繰り返せるメモリ領域です。
オブジェクトはヒープに割り当てられています。
JavaScriptエンジンの内部に実装されています。

コールスタック(Call Stack)

関数は呼び出されるとコールスタックに追加されます。
コールスタックはLIFO(後入れ先出し)で機能します。
JavaScriptエンジンの内部に実装されています。

タスクキュー(Task Queue)

コールバック関数はタスクキューで待機します。
タスクキューはFIFO(先入れ先出し)の配列で、イベントループは、コールスタックが空になる度に、タスクキューからコールバック関数を取り出して実行します。
JavaScriptエンジンの外部に実装されています。

MDNでは、なぜ「ループ」という名前が付いたか、下記の様な仮想コードで説明しています。

while(queue.waitForMessage()){
  queue.processNextMessage();
}

上記のwaitForMessage()は、現在実行中のタスクが存在しない場合、次のタスクがタスクキューに追加されるまで待機します。このようにイベントループは、「現在実行中のタスクがないこと」と「タスクキューにタスクがあるか」を繰り返し確認します。簡単にまとめると次のようになります。

  • すべての非同期APIは作業完了後、コールバック関数をタスクキューに追加する。
  • イベントループは、現在実行中のタスクがない(コールスタックが空になった)場合、FIFOでタスクキューから取り出して実行する。
  • 現在実行中のコールタスクがある(コールスタックに関数がある)場合、LIFOでコールスタックから取り出して実行する。

これらがイベントループの簡単な説明になります。
では、このメカニズムを実際のコードを踏まえて理解していきましょう。

イベントループをコードで理解する

プログラム1

まずは簡単に下記のコードの実行結果を考えてみましょう。


const foo = () => console.log("First");
const bar = () => setTimeout(() => console.log("Second"), 1000);
const baz = () => console.log("Third");

foo();
bar();
baz();

実際に実行してみましょう。実行結果は下記の様になったと思います。


"First"
"Third"
"Second"

このプログラムの流れとしては

  1. fooがコールスタックに追加されます。そして、"First"を返し、コールスタックからポップされます。
  2. barがコールスタックに追加されます。
  3. コールバック関数の() => console.log("Second")がWeb APIに追加され、1000ms待機します。
  4. その瞬間setTimeoutはコールスタックからポップされ、値を返します。
  5. 1000ms待機している間に、bazがコールスタックに追加さます。そして、"Third"を返し、コールスタックからポップされます。
  6. 1000ms後、() => console.log("Second")は直ぐにコールスタックに追加されるのではなく、タスクキューに渡されます。
  7. そして、イベントループで、コールスタックが空なことを確認し、タスクキューにある() => console.log("Second")がコールスタックに渡されます。
  8. () => console.log("Second")"Second"を返し、コールスタックからポップされ、プログラムは終了します。

となります。視覚的に理解したい方は、こちらで上記のプログラムを実行してみると面白いと思います。
では、次のプログラムにいきましょう。少し難易度が上がります。

プログラム2

下記のプログラムを実行し、ボタンをクリックしてみましょう。


<!DOCTYPE html>
<head>
    <title>event loop</title>
</head>
<body>
    <button id="heavyCount">click</button>
    <div id="counter">0</div>
    <script>
        const button = document.getElementById('heavyCount');
        const counter = document.getElementById('counter');
        button.addEventListener('click', function() {
            let count = 0;
            let times = 1000;
            function loop() {
                if (count++ < times) {
                    counter.innerHTML = count;
                    loop();
                } else {
                    alert("Done");
                }
            }
            loop();
        });
    </script>
</body>
</html>

恐らく、アラートが表示された後、下記の様にページ内の0が1000に変化したと思います。

a6cb49d5b20cc69f661d337d8b57cf47.gif

では、このプログラムを下記の様に動かしたいときはどうでしょう。ソースコードを少し修正してあげるだけで、この動きは実現できます。

26ad84952b606021e1665555cb5dcfff.gif

その前に、イベントループにおける、最初の動きの流れを追っていきたいと思います。

  1. まず、addEventListenerをコールスタックに追加。
  2. addEventListenerのコールバック関数をWebAPIに追加。
  3. ボタンをクリックすると、loopがコールスタックに追加されます。
  4. countが++されます。
  5. counter.innerHTMLがタスクキューに追加されます。
  6. 次に、loopの中で更にloopを再帰的に呼び出しています。
  7. loopがコールスタックに追加されます。
  8. countが++されます。
  9. counter.innerHTMLがタスクキューに追加されます。
  10. 次に、loopの中で更にloopを呼び出します。
    …という風に、今回のプログラムでは、コールスタックにcountが1000になるまで、つまり、1000個のloopがコールスタックに追加されます。
  11. そして、countが1000になったところで、else内のalert("Done")が実行されます。
  12. コールスタックが空になったところでタスクキューで待機しているcounter.innerHTML達を実行していきます。
  13. 最後に1000が表示されてプログラムは終了します。

プログラム2-2の解答

では、プログラム2-2の解答です。


// ...省略

function loop() {
    if (count++ < times) {
        counter.innerHTML = count;
        setTimeout(loop, 0); // setTimeoutを追加
    } else {
        alert("Done");
    }
}

setTimeoutのコールバックとしてloopを渡していますね。
では、何故これだけで上記の動きが実現できるのでしょうか。

今までは、コールスタックにloopがどんどん追加されていき、その間にinnerHTMLがタスクキューにどんどん追加されていきました。
これは、タスクにあるloopを全て処理仕切ったとき、つまり、countが1000になった後、タスクキューで待機していたinnerHTMLでレンダリング処理をするという流れになります。ここはさっき説明したので、問題ないと思います。

しかし、再帰呼び出しでsetTimeoutを挟むことによってloopはコールスタックではなく、WEB APIを経由した後、タスクキューに追加されていきます。
つまり、タスクキューには、innetHTMLloopinnetHTMLloop → ... という風に、追加されていきます。では、この部分の処理の流れを追っていきましょう。

  1. まず、innerHTMLが実行されます。countは1なので、1が画面にレンダリングされます。
  2. loopをコールスタックに追加します。countを++します。
  3. コールスタックが空になりましたのでタスクキューで待機しているinnerHTMLを実行します。countは2なので、2が画面にレンダリングされます。
  4. これをcountが1000になるまで実行します。

という風に、setTimeoutを使用することで、loopの間にinnerHTMLによるレンダリング処理を追加することができます。これで、2枚目の動きが実現できる訳ですね。

スタックオーバーフロー

このコールスタックは、無限に関数を積み重ねることはできません。何事にも限界はあります。コールスタックの許容量を超えてしまうと、スタックオーバーフロー(StackOverflow)というエラーが発生します。これは関数の再帰的な実行を行うときによく出てきます。

今回話せなかったこと

  • マイクロタスク(micro task)
  • ウェブワーカー(Web Worker)
  • setTimeoutの誤差

この3つもイベントループに関係してきますので、気になる方は勉強してみると面白いですよ。
僕も機会があればまた記事を書こうと思います。

参考文献

4
Help us understand the problem. What is going on with this article?
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
4
Help us understand the problem. What is going on with this article?