🌊

JS基礎いろいろーイベントループ

2022/02/19に公開

イベントループ(Event Loop)とはなんでしょう。一言で言えば、JavaScriptという言語を理解する一つのキーであり、コード実行のモデルだと考えられます。今回はそのイベントループについて詳しく見ていきたいと思います。

同期と非同期

イベントループに入る前に、まずは同期実行と非同期実行の理解が必要です。

JSは基本的にシングルスレッドで非同期実行、というメカニズムになっています。

シングルスレッドだけでは、一つのタイミングに一つの仕事しかできないので、例えばリモートサーバーから何かデータをリクエストすると、シングルスレッドではその返事を待たなければならなく、画面のレンダリングが止まり、フリーズ状態になってしまいます。

ただ実際の開発中・使用中には、そういう状況がありません。これは、単純に非同期だから、というわけでもなく、ブラウザーでは複数のスレッドがあります(後節で説明)。つまり、実際にブラウザーの環境で言えば、マルチスレッドですが、開発者の書いたjsコードの実行は一つの「メインスレッド」に任されます。このメインスレッドが、シングルスレッドで非同期となります。

同期実行

同期実行というのは、ブロック実行とも考えられます。というのは、上から下、左から右へ順番通りで実行していくため、上の行が終わらない限り下まで行きません。まさにスレッドがブロックされている状態。次の例で考えてみよう:

let a = 1
let b = 2
let d1 = new Date().getTime()
let d2 = new Date().getTime()
while (d2 - d1 < 2000){
  d2 = new Date().getTime()
}
//次のコードが実行するまで少しページがロード中になる
console.log(a+b)

console.logは、whileループによってブロックされて、ループから脱出されるまではログされません。これは同期実行となります。

非同期実行

jsはシングルスレッドとなるので、もし同期実行だけであれば、あっちこっちで待たなければなりません。例えば、画像を別のサーバーから取得してロードしたり、アニメーションの演出をレンダしたり、時間のかかる操作がブラウザー環境にいっぱいあります。そのスレッドのブロックに対して、jsでは非同期実行によって解決しています。例えば:

let a = 1
let b = 2
setTimeout(function(){
  console.log('出力中')
},2000)
console.log(a+b)

setTimeout関数は、タイマーを始めて、時間となれば引数として渡された関数を実行する機能となります。setTimeoutの時点でタイマーが始まり、2秒後「出力中」が表示されますが、その前に、a+bの結果が表示されます。つまり、console.logはブロックされていません。

つまり同期と非同期って何?

例えば、ラーメン店で並んでいる列が同期実行、順番になるまで店に入れません。

ラーメン屋さんの店員さんテーブル毎に回って、各自の注文を聞いて行くのが非同期。客1の注文を聞いて、大将に「〇〇一丁」というのですが、客1のラーメンができるまで待つのではなく、次の客2の注文を聞きに行きます。ただ、必ず注文順番で出されるわけでもなく、例えば客2も客3もチャーハン注文したので、一緒に出されましたが、客1が注文したラーメンが手間かかるからまだ出来ていない状態とか。

このように、店員さんが一人(シングルスレッド)で順番に注文を聞いていく(リクエストを一つずつ送る)、最終的に料理が出される(レスポンスが帰ってくる)が、順番は注文内容や大将の都合次第なので必ず注文順とは限りません(レスポンスがランダム順に返ってくる)、というのがjsの実行環境とよく似ています。

同期と非同期の優先順位

シングルスレッド非同期の実行モデルの一つの特徴として、必ず同期コードが先に実行されることになります。

let a = 1
let b = 2
let d1 = new Date().getTime()
let d2 = new Date().getTime()

// 一秒後非同期タスクを実行
setTimeout(function(){
  console.log('非同期実行')
},1000)

// 二秒くらいブロックする
while(d2-d1<2000){
  d2 = new Date().getTime()
}
console.log('同期実行', a+b)

二つのコード例を一緒に並ぶと分かりやすい。同期のwhileループは二秒ほどブロックするのですが、setTimeoutのコールバックが先に実行することがなく、必ず同期実行の後になります。

ここで一つ疑問が出るかもしれません。スレッドが一つしかないのであれば、setTimeoutが実行するときに、中身のコールバック関数はどこに行ったのか。同じスレッドで「一時保存」的なことができればシングルスレッドとは言えないでしょうが。

JSのスレッド色々

冒頭にも言いましたが、ブラウザー環境では、複数のスレッドによる協同作業が行われています。例えば:

  • GUIレンダリングスレッド
  • JSエンジンスレッド
  • イベントリスニングスレッド
  • タイマースレッド
  • HTTPリクエストスレッド
  • 他いろいろ

アクティビティモニターを開くとすぐにわかるかもしれません。ブラウザーで1つのタブだけを開いても、スレッド数が数十ないし百まで行きます。

ますます分からん、かもしれません。ここのJSがシングルスレッドだ、というのは、上記の複数のスレッドを利用し、切り替えはしているが、同時に実行するスレッドが一つだけとのことです。

ここでシンプルに上記のスレッドを分けると:

  • メインスレッド:画面のレンダリング、jsコードの実行、イベント発火など
  • ワーカースレッド:よくバックグランドとか言われますが、主に非同期のタスクの処理

繰り返しになりますが、我々が書いたjsコードの実行は、メインスレッド(複数のスレッドの切り替え)に任されています。

イベントループ

ここで前置き条件が揃い、ようやく本題に入りました。

鳥瞰図

単刀直入に、図を貼ります。

少しデータ構造の基礎知識が必要かもしれませんが、キュー(First in first out: FIFO)とスタック(First in last out: FILO)が分かれば十分かと。変数などの値はメモリヒープに保存されているので、コールスタックでコードが実行されるときに必要なデータをそちらから参照しています。

処理順番で言えば:

  1. メインスレッドでコードを解釈(interpret)し始める
  2. 同期コードは直接コールスタックで実行
  3. 非同期コードはワーカースレッドに預ける
  4. 実行待ちの非同期タスクをタスクキューに投げる
  5. コールスタックの関数がなくなるまで実行
  6. コールスタックが空っぽになると、タスクキューから非同期のタスクを入れる
  7. 非同期タスクを順次に実行

例で言えば

それで、コード例から説明します。まずコードだけを見て、どの順番でコンソールに出るかは分かりますか?

function task1(){
  console.log('task1 run')
}

function task2(){
  console.log('task2 run')
}

function task3(){
  console.log('task3 run')
}

function task4(){
  console.log('task4 run')
}

task1()
setTimeout(task2, 1000)
setTimeout(task3, 500)
task4()

これを上記の図で当てはめていくと、次のようになります(ヒープは省略)。

言葉で説明すると、メインスレッドでコードを解釈しながら、コールスタックに投げるか、ワーカースレッドに処理してもらうかを決めていきます。それで、タスク1が同期コードなので、コールスタックで実行され、task1 runが表示されます。次にtask2と3は非同期なので、ワーカースレッドに処理してもらい、メインスレッドは同期コードの4に進みます。1がすでにスタックから出たので、4が次に入りまた出ます。2と3はタイマーがありますが、3の方が短かったので、先に時間となり、ワーカースレッドより、タスクキューに投げられます。その次に2がキューに入りますので、2と3の順番が逆になります。同期コードが全部実行終了となり、コールスタックが空の状態なので、2と3はまた順番に入り、実行されます。なので、コンソールでの表示順番は、1→4→3→2となります。

コールスタック

上記の例で一つ注意すべきなのは、タスク1と4は全部コールスタックに入ってから実行されていくわけではなく、入った途端に実行し、その後すぐにスタックから離れてなくなります(GC)。

ただ、コールスタックにいっぱい関数が貯まるケースもあります。それは、関数の中で関数を呼び出すケースです。ここではシンプルな例で言えば:

function task1(){
  task2()
  console.log('task1 run')
}

function task2(){
  task3()
  console.log('task2 run')
}

function task3(){
  console.log('task3 run')
}

task1()

図で当てはまると:

要するに、関数の中で関数を呼び出すと、親関数の実行が終わっていないから、コールスタックの中に残されます。子関数の実行に入るときに、親関数の上に来ますので、最後に入った子関数が実行されてスタックから離れていきます。

この例だと、直感で3-2-1でわかるかもしれません。この同期コードの実行パターンはほとんどの言語にも共通するでしょう。

コールスタックの限界

ここでもう一つの疑問が出るかもしれませんが、無限に子関数を呼び出すとどうなるの?

再帰のパターンでは検証出来ますが、コールスタックには、最大「深さ」があります。

let i = 0
function recursion() {
  i++
  console.log('recursion ' + i)
  task()
}
task()

この深さはブラウザーによって違いますが、1万ほどが最大のようです。開発中にはときにこのエラーにあるかもしれませんが、何を言ってるのか、今回の例でわかるようになるでしょう。

このスタックの制限を超えるために、コードを多少アレンジすることができます。

let i = 0
function recursion() {
  i++
  console.log('recursion ' + i)
  setTimeout(function(){
    task()
  })
}
task()

setTimeout関数で、taskの再帰実行をタスクキューに投げるように変更します。すると、task関数がコールスタックに溜まっていくのではなく、タスクキューに並べて順番にスタックに入ります。

さらに「タスクキューには限界がないの」って聞きたくなるかもしれませんが、脱線しそうなので一旦止めます。

マクロタスクとマイクロタスク

ここまでのおさらいとして、イベントループのモデルとは、コード解釈→同期はコールスタックで実行、非同期はワーカースレッド→ワーカースレッドが非同期タスクをキューに投げる→コールスタックが空の状態で、タスクキューから新しいタスクがスタックに入る、となります。このプロセスは、先ほどの再帰+setTimeoutで検証できたように、不可抗力がない限り永遠に続けて行けます。

鳥瞰図

非同期のタスクというのは、実際に種類と言えば、マクロタスクとマイクロタスクに分けられます。理解のためにまず図を貼ります。

この図では、タスクキューがより複雑になりました。マイクロタスクは原則的に、マクロタスクより先行されます。マクロタスクの中でマイクロタスクがある場合(コード例に参照)、該当マイクロタスクは、次のマクロタスクの順番の前まで優先に実行されます。もしマクロタスクのなかにさらにマクロタスクがある場合、それが次のマクロタスクとなります。

タスクキューを2つあると描くのも良いのですが、こちらの図で、マイクロとマクロが具体的にどのように実行順番が決められているのかがよりはっきりと表現できると思います。2つのキューで描く場合、毎回マクロキューのタスクを実行する前に、マイクロキューにタスクがあるかどうかのチェックが必要なので、その辺りの表現も不可欠でしょう。

具体的に何がマクロ何がマイクロ?

マクロタスクとは、今までの例のsetTimeoutを含める非同期タスクのことで、今までのイベントループの図で問題ありません。

関数など ブラウザー Node.js
I/O
setTimeout
setInterval
setImmediate
requestAnimationFrame
event listener/emitter

マイクロタスクはECMAスタンダードの更新で追加された新しい非同期タスクのことで、毎回マクロタスクの実行する前に、マイクロタスクがあるかどうかはチェックされます。つまり、マイクロタスクの実行は、基本的にマクロタスクより優先されます。ただ、この優先順位は、マクロ・マイクロだけで決められるわけではなく、タスクがワーカースレッドによって処理されたタイミングにも影響されますので、次の節で例で説明します。

関数など ブラウザー Node.js
process.nextTick
MutationObserver
Promise.then/catch/finally
queueMicroTask

一つ注意点として、古いjsコードではAJAXリクエストを送るときにXMLHttpRequestを使うのがありますが、XMLHttpRequestはマイクロではなく、マクロタスクとなります。マイクロタスクはPromiseの時代からの話なので、それ以前には存在していません。ちなみにAJAX(Asynchronous JavaScript and XML)はあくまでもJSの非同期処理の総称的なもので、XMLHttpRequestも、Promiseもその実現の手段の一つとなります。

コード例

フロントエンドでのマイクロタスクで言えば、Promiseオブジェクトですね。次の例では、クリック後どのような順番になるか、今まで構築されたメンタルモデルでわかるでしょうか。

document.addEventListener('click', function(){
  Promise.resolve().then(()=>console.log(1))
  console.log(2)
})

document.addEventListener('click', function(){
  Promise.resolve().then(()=>console.log(3))
  console.log(4)
})

Promise.resolve().then(()=>console.log(5))

ここで注意したいのは、イベントリスナーでレジストされたコールバックも、マクロタスクとなります。つまり、上記の例では、マクロタスクの中(コールバック)にマイクロタスクがある、というパターンとなります。開発中にもよく見られますが、イベントをレジストして、クリックイベントとかがあるときに、データをfetchAPIとかで取得する、という場面です。

先ほどの図で当てはめていくと、マイクロタスク→マクロタスク→マイクロタスク→マクロタスク→マイクロタスクがわかるので、出力が、5-2-1-4-3となります。

終わりに

ここまではイベントループ、jsの実行モデルについて説明しました。なんか難しそうなものに見えますが、jsの勉強においてかなり重要な基礎知識になるので、理解しておいた方が損はないでしょう。

JSの非同期プログラミングでは、Promiseが結構中心的になっているので、今度はPromiseについて詳しく書きたいと思います。

ではでは、良きプログラミングライフを。

Discussion