JavaScriptのイベントループまわりは、どういう仕組みで動いているのか?
概要
JavaScriptのイベントループがどう動いているのかを自分の中で整理したいと思い、記事を書きました。図がかなり多めです👀
イベントループ
イベントループとは、タスクキューにあるタスク(関数)を順次実行していくための仕組みをいいます。
MDNは、下記の抽象的なプログラムでその内容を説明してくれています。
while(queue.waitForMessage()){
queue.processNextMessage();
}
queue.waitForMessageは、もしその時点でタスクキューにタスクが存在しないのであれば、タスクが到着するのを待つ、というメソッドです。
そしてqueue.processNextMessage()は、次のタスクを実行する、というメソッドです。
つまりイベントループは、タスクキューにタスクが存在していた場合は順次それぞれを実行していき、そうでない場合は、タスクキューにタスクが到着するのを待つ、という挙動をすることがわかります。
これだけでイベントループ自体の要点は説明完了してしまったので、次はその周辺の装置について触れていきましょう。まずはコールスタックという装置からです。
そして全貌として、こちらがイベントループとその周辺の装置の関係を表した図になります。
コールスタック
コールスタックとは、現在実行されている関数(タスク)、およびその関数の中でどんな関数が呼び出されたかを追跡している装置です。
後入れ先出し構造となっており、関数が呼び出されたらこのスタックに追加され、実行され終わったらスタックから除外される仕組みとなっています。
上図の通り、コールスタックはイベントループにより、タスクキューから順次関数が支給されていきます。
たとえばこのようなプログラムがあったとしましょう。
const hoge = () => {
fuga()
}
const fuga = () => {
piyo()
}
const piyo = () => {
return "piyo"
}
hoge()
このプログラムを実行すると、コールスタックはどのように推移していくのか、図と一緒に説明していきます。
(1) まずトップレベルでhogeが呼ばれているので、hogeをスタックに追加します。
(2) 次に、hogeの中ではfugaが呼ばれているので、fugaをさらに追加します。
(3) そして、fugaの中ではpiyoが呼ばれているので、その上にpiyoを追加します。
piyo内では別の関数は呼ばれておらず、またトップレベルで呼ばれているのはhogeのみなので、コールスタックに積まれるのはこれで全部ですね。
(4) さて、後入れ先出し方式なので、一番後に追加したpiyoから実行し、スタックから吐き出します。
(5) 次に、最後から2番目に追加したfugaを実行します。
(6) 最後に、初めに追加したhogeを実行します。
これでコールスタックが空になりましたね。
繰り返しますが、このようにコールスタックは、現在実行されているタスクと、そのタスクの中でどのようなタスクが呼ばれているかを教えてくれます。
しかしながら、上記は扱うタスクが同期処理だけのケースですが、JavaScriptではしばしば非同期処理のタスクが扱われます。
以下は先ほどのプログラムに非同期処理を足したものです。
const hoge = () => {
return "hoge"
}
const fuga = () => {
piyo()
}
const piyo = () => {
return "piyo"
}
setTimeout(hoge, 3000)
fuga()
それではこれを実行してみましょう。
(1) まず、setTimeoutを呼び出します。この関数が非同期処理であり、指定した秒数遅れで引数となる関数を実行してくれる関数です。
(2) setTimeoutがコールスタックから吐き出されます。
(3) 次にトップレベルでfugaが呼び出されます。
(4) そしてfugaの中でpiyoが呼ばれます。
piyo内では別の関数は呼ばれておらず、またトップレベルで呼ばれている関数も他にないので、今度は積んである関数をおろしていきましょう。
ここはpiyoとfugaを一気にやってしまいます。えい。
(5) スタックが空になりました。
(6) ここで、スタックにhogeが追加されます。hogeはsetTimeoutの引数として指定されていた関数ですが、なぜかこのタイミングで追加されました。
(7) そしてhoge内は他に呼び出している関数もないので、hogeを吐き出して終了です。
さて、setTimeoutの引数であるhogeが最後に突然現れ、そのタイミングで実行されたのは不可解な話ですね。
このように、非同期処理で扱う関数は、非同期処理ならではのルート、およびタイミングでコールスタックに追加される仕組みとなっています。
ここを理解するべく、次はWebAPIとタスクキューという装置を説明してきます。まずはWebAPIから。
WebAPI
WebAPIとは、ブラウザに搭載されている各種APIのことです。例えばDOMの作成・操作や、setTimeOutなどの処理を司っています。
なので厳密には、上述したsetTimeoutはコールスタックから吐き出された後、このWebAPIが実際の実行を担当していたのでした。
WebAPIが持っている機能は、上記以外にも多岐に渡ります。詳しくはMDNが説明してくれているので、こちらをどうぞ〜
続いてはタスクキューについて!
タスクキュー
タスクキューとは、実行待ちの関数(タスク)の順番を管理する装置です。ここにある関数はすべて、非同期処理の引数となる関数です。
ここで、冒頭の図を再掲します。
タスクキューはこのポジションにおり、WebAPIから非同期処理の引数となる関数を受け取ったのち、イベントループを通じてコールスタックにそれを渡しています。
そして、タスクキューにはMicrotask QueueとMacrotask Queueの2種類があります。
Promiseなどの処理の引数はMicrotaskに、setTimeoutなどの処理の引数はMacrotaskに該当し、Microtaskの方がMacrotaskより的に優先的に処理されます。
つまり、関数実行の優先順位を雑に言えば、「同期処理 > Microtask > Macrotask」となるわけですね。
非同期処理のサイクル
const hoge = () => {
return "hoge"
}
const fuga = () => {
piyo()
}
const piyo = () => {
return "piyo"
}
setTimeout(hoge, 3000)
fuga()
ここで改めて、コールスタックのところでご紹介した、上記のプログラムがどう処理されていくのか、イベントループの周辺装置すべてを含めた図で説明してみます。
(1) まずsetTimeoutがコールスタックに追加されます。
(2) コールスタックはsetTimeoutを吐き出されたあと、WebAPIがそれを実行します。実行というのはつまり、3000ミリ秒(3秒)間の待機をおこないます。
(3) setTimeoutの引数であるhogeがタスクキューに運ばれます。setTimeoutはMicrotaskなので、厳密にいえばMicrotask Queueに運ばれますが、かんたんのためタスクキューとしておきます。
(4) 同期処理であるfugaがコールスタックに運ばれました。処理の優先順位は「同期処理 > Microtask」なので、タスクキューにあるhogeはステイです。
(5) fugaの中にあるpiyoがスタックに追加されました。
(6) ここから「piyo -> fuga」の順に実行され、コールスタックは空になります。この間ももちろん、Microtaskであるfugaは実行されず、キューで待機しています。
(7) コールスタックが空になり、プログラムのトップダウンに他の関数も残っていないので、hogeがイベントループを通じて、スタックに運ばれます。
(8) hogeが実行され、無事doneです!
setTimeoutなどの非同期処理は、このように、同期処理とは異なるルートを回されたのち、実行されるわけです。
所感
まとめてはみたものの、非同期処理はややこしいですね😢
ただ、setTimeoutがPromiseとかに変わったとしても、基本的には上記と同じ考え方で大丈夫なはずなので、とりあえずJavaScript非同期処理の基礎のキは踏まえたと思いたいな・・・😢
参考記事
Discussion