はじめに
JavaScriptのイベントループについて、自分なりになるべくわかりやすく説明してみました。JavaScript初心者から中級者の方までが対象です。わかりにくいところや間違いがあった場合には、気軽にコメントで言ってもらえると嬉しいです!
JSConf EUでJavaScriptのイベントループについてとても分かりやすく説明されている動画があったので、それを元にまとめました。この記事を参考にイベントループを理解して、JavaScript上級者を目指しみましょう!
JavaScriptとは
まず始めにJavaScriptがどういうプログラミング言語かを知るところから始めてみましょう。以下がJavaScriptの説明です。
A single-threaded non-blocking asynchronous concurrent language. Has a call stack, an event loop, a callback queue some other apis and stuff
ちょっと英語だと何言ってるかよくわからないですよね。なので、これを日本語に翻訳すると、
JavaScriptとは、シングルスレッドでノンブロッキングかつ非同期処理、並行処理が可能な言語です。コールスタック、イベントループ、コールバックキューやその他諸々のAPIを保持しています
翻訳しても、ちょっと何言ってるかわからないですよね。僕も動画を見終わるまで同じ状態でした。少しずつこの文章で出てくる用語を理解しつつ、JavaScriptが一体どういう言語なのかを一緒に理解していきましょうね。
JavaScriptのruntime
まず、最初のステップとしてJavaScriptのruntime(runtime engine)がどのように構成されているかを理解しましょう。JavaScriptのruntime(例えばChrome内にあるV8)は、シンプルにすると以下のような構成になっています。大雑把に言うと、Heap Memoryはメモリのアロケーションを行い、Call Stackはスタックフレームなどの管理を行います。次にV8を理解するために、V8のソースコードを少しだけ見てみると、setTimeout
やDOM
やHTTP request
の実装が、ここにはないことが分かります。では、これらの実装はどこにあるのでしょうか。
ブラウザが提供する機能としてWebAPIsというものがあります。それにsetTimeout
やDOM
やHTTP request
などの実装は含まれています。「本当にV8のソースコードにこれらの実装はないの?」って、気になる方は是非V8のソースコードを見て、確かめてみてください!
また、今回この記事では、Heap Memoryの説明は行いませんので、ご了承ください。
Call Stack
次のステップとして、Call Stackを理解しましょう。Call Stackとは大雑把にいうと現在実行中のプログラムのどこにいるかを記録しているデータ構造です。どの関数が現在実行されていて、その関数の中でどの関数が呼び出されたかを記録しています。関数を呼び出すときに、Call Stackにその関数をpush(追加)し、関数からreturnされるときや関数の最後まで実行が終わったときに、Call Stackから処理がpop(追い出される)されます。今回の記事はざっくりとした説明でイベントループを理解するのが趣旨なので、厳密なCall Stackの説明は省きますが、気になる方はこのサイトにて分かりやすく説明されているので、読んでみてください!
説明に戻ると、JavaScriptはシングルスレッドで動きます。1つのスレッドで動くのであれば、1つのCall Stackしか持ちません。つまりは、一回に一つのことしかできません。
以下のコードの実行ステップを考えながら、Call Stackがどのように変化していくか見てみましょう。main関数は今回、このプログラム自体を実行する関数を指すこととします。
function multiply(a, b) {
return a * b
}
function square(n) {
return multiply(n, n);
}
function printSquare(n) {
const squared = square(n);
console.log(squared);
}
printSquare(4)
まずはじめに、main
がCall Stackへpushされます。
次にmain
からprintSquare
が呼び出され、Call StackにprintSquare(4)
がpushされます。
更にそこからsquare
が呼び出され、Call Stackにsquare(n)
がpushされます。
次にsquare
からmultiply
が呼び出され、Call Stackにmultiply(n, n)
がpushされます。
そしてmultiply(n, n)
の最後の行まで行きreturnまで辿り着くと、multiply(n, n)
が、Call Stackからpopされます。
次にmultiply(n, n)
の実行結果を受けて、square(n)
の最後の行まで行きreturnまで辿り着くと、square(n)
が、Call Stackからpopされます。
square(n)
の実行結果を受けて、printSquare(4)
が実行を続けると、console.log(squared)
にぶち当たります。そこで、Call Stackに今度はconsole.log(squared)
がpushされます。
console.log(squared)
が終わると、Call Stackからpopされます。
console.log(squared)
が実行し終わるとその先に、printSquare
の処理は残っていないので、Call StackからprintSquare
がpopされます。
そして、全ての実行が終わり、Call Stackが空になりコードが終了します。
例1
以下のコードでは、baz()
をまず始めに呼び、そこからbar()
が呼ばれ、更にそこからfoo()
が呼ばれ、最終的にfoo()
の中で新たに生成したエラーをthrowしています。
function foo() {
throw new Error('Oops!');
}
function bar() {
foo();
}
function baz() {
bar();
}
baz();
これをブラウザ上で動かしてみると、エラーが起きた時のCall Stackの状態を見ることができます。このエラーでは、baz
からbar
が呼び出され、bar
から更にfoo
が呼び出され、そこでエラーが出たということが示されています。
Uncaught Error: Oops!
at foo
at bar
at baz
例2
以下の例では、foo
が関数の中でfoo
を呼びそれをreturnしています。
function foo() {
return foo();
}
foo();
実行するといつまで経っても、foo()
の呼び出しがCall Stackからpopされないので、無限にfoo
がCall Stackにpushされ、スタックオーバーフローが起きてしまい、以下のようなエラーが出てしまいます。
Uncaught RangeError: Maximum call stack size exceeded
at foo
at foo
at foo
at foo
at foo
at foo
at foo
at foo
at foo
at foo
Blocking
次のステップとしてblockingについて理解してみましょう。ある処理をしていることで、その処理が実行中に他の処理が実行できなくなってしまうような(例えば実行に時間がかかってしまい、後続の処理に進めなくなるような)処理のことをblockingであると言います(blockingであるかどうかの、厳密な定義は存在しません)。例えば、console.log('hello')
を実行するのには大した時間は必要がないですが、while loopで100万回HTTPリクエストを送るようなコードであれば、時間はとてもかかってしまいます。while loopで100万回HTTPリクエストを送るようなコードは、blockingであると言えるでしょう。
以下の擬似コードでは、$.getSync
により指定されたURLにHTTP requestを送ります。これらは一行一行、上から順番に実行されていくので、foo.com
へリクエストを送り、そのリクエストに対してのレスポンスが返ってきてfoo
への代入が終わるまで、次のbar
の代入へ行きません。これだとすべてが実行されるまでにとても時間がかかってしまいます。
var foo = $.getSync('//foo.com');
var bar = $.getSync('//bar.com');
var qux = $.getSync('//qux.com');
console.log(foo)
console.log(bar)
console.log(qux)
上のコードのように同期的に上から順番に、ただただ処理を行っている事には問題があります。それは、このように実行結果を待つ(Call Stackの中身が一向に処理されない)ようなコードだと、ブラウザ自体の操作ができなくなってしまう(renderもできなければ、後続のコードを動かすこともできない)ことです。具体的に、それがどういうことなのかは、イベントループが深く関わってくる話なので、この記事の後半でイベントループと一緒に説明したいと思います。
並行性・イベントループ
冒頭でも言ったように、JavaScriptのruntimeは一回に一つのことしかできません。setTimeout
もAJAXリクエストを送ることも何かを実行している(Call Stackが空ではない)ときには、実行できません。しかしブラウザはruntime以外にも機能を持っています。その機能の一つがWebAPIです。setTimeout
はV8の中にあるわけではなく、WebAPIしてブラウザから提供されているものです。以下のコードでは、タイマーを5000msとして、setTimeout
を呼んでいる例です。以下の例を用いてイベントループについて説明していきます。
console.log('hi');
setTimeout(() => {
console.log('there');
}, 5000);
console.log('Done')
まず、WebAPIは勝手にコードを書き換えることができません。したがって、Call Stackに実行結果を直接入れることはできません。そこで登場するのがイベントループです。イベントループの仕事はとても単純で、Call StackとTask Queueを監視し、もしCall Stackが空であれば、Task Queueの先頭の要素をCall Stackにpushします。上のプログラムを例に具体的に説明すると、まず最初にこのコードを実行すると、一番最初の例と同様にmain()
がCall Stackにpushされます。
そして、次にconsole.log('hi')
がCall Stackへpushされます。
console.log('hi')
を実行し終えると、次にsetTimeout
を実行する行へ到達します。今まで通りここでは、Call StackへsetTimeout
をpushします。
setTimeout
はWebAPIsが提供する機能なので、Call StackからWebAPIsにsetTimeout
を呼び出すようにいいます。すると、timerメソッドが動き、指定された時間、コールバック(() => {console.log(there)}
)を受け取り、待ちます。
setTimeout
はWebAPIsのtimerを呼ぶという役割を果たしたので、ここで、Call Stackからpopされます。
次の行のconsole.log('Done')
をCall Stackに入れ、実行します。実行後にpopされます。
そしてmain()
だけが残ります。WebAPIs側では、まだ5000msが経っていないので、それまで待機しています。
main()
も最後にpopされ、この時点でCall Stackは空になります。
そして、timerが指定された時間が経つと、Task Queueにコールバック(() => {console.log(there)}
)を渡します。
現在、Call Stackは空で、Task Queueの先頭には、コールバック(() => {console.log(there)}
)があるので、それを今度はイベントループによって、Call Stackにpushします。
最後にコールバック(() => {console.log(there)}
)の実行が終わり、Call Stackが空になり、すべてのコードの実行が終了します。
setTimeoutの注意点
先程説明したイベントループでは、Call Stackが空であるときにTask Queueから一つだけ処理を取り出し、Call Stackへpushして実行を行います。このことから分かるのは、setTimeout
の第二引数で指定した時間が経っても、setTimeout
の第一引数で指定した処理を実行できない場合があります。第二引数で指定した時間後に、確実に実行できるわけでないことを覚えておきましょう。最短だと指定した時間後に実行可能であるってことを是非覚えておいてください。
以下のプログラムを実行した場合、console.log('there')
はすぐに実行されません。Call Stackが空になってから、console.log('there')
は実行されます。今回のプログラムの場合であれば、一番最後にconsole.log('there')
は実行されます。
console.log('hi');
setTimeout(() => {
console.log('there');
}, 0);
console.log('Done')
実行結果
hi
Done
there
なぞなぞ
もうそろそろ読むのが嫌になってきているかもしれないので、なぞなぞを出します。以下のコードを実行すると、どのような実行結果になるか予想してみてください。LoupeというWebサイトでプログラムを実行すると、プログラムの実行中にどのようにTask QueueやCall Stackが変化していくかが見れるので、是非試してみてください!
setTimeout(function timeout() {
console.log('chrome')
}, 1000)
setTimeout(function timeout() {
console.log('firefox')
}, 1000)
setTimeout(function timeout() {
console.log('safari')
}, 1000)
setTimeout(function timeout() {
console.log('opera')
}, 1000)
Render Queue
Call Stackに処理がある場合には、ブラウザの画面にrender(ブラウザの画面上に描画すること)はできません。Call Stackの中の値が空になって初めて、renderingは行われます。例えば、750ms毎にrenderingを行うような処理の実行と同時に、以下のようなプログラムを実行すると、下に示すgifのような結果が得られます。Render QueueはCall Stackが空になっているときだけ、先頭から一つだけ処理を取り出し、renderingを行い、それ以外の場合はずっと待機しています。注意としては、Render Queueから取り出された処理は、Call Stackへはpushされないといことです。ここはTask Queueとは違うので覚えておきましょう。Task QueueはCall StackとRender Queueの両方が空になっているときだけ、処理をCall Stackにpushします。実際にこのプログラムを実行したときに、どのような処理になるかはloupeを動かしてみてみると、とてもわかりやすいです。
$.on('button', 'click', function onClick() {
setTimeout(function timer() {
console.log('You clicked the button!');
}, 2000);
});
console.log("Hi!");
setTimeout(function timeout() {
console.log("Click the button!");
}, 5000);
console.log("Welcome to loupe.");
まとめ
イベントループ
JavaScriptはシングルスレッドで動き、一つのCall Stackを持ちます。Call Stackだけだと、重たい処理があったときにブラウザの表示や後続の処理をblockしてしまいます。これを解決するためにイベントループという仕組みがあります。イベントループによって、Call Stack、WebAPI、Task Queue、Render Queueを用いて、非同期的に並行で処理を行えます。
優先順位
Call Stack > Render Queue > Task Queue
の順番で優先されると覚えておいてください。また、以下のことを覚えておきましょう。
- Call Stackが空であれば、Render Queueから処理が一つ取り出されrenderingが行われる
- Call StackとRender Queueが空であれば、Task Queueの先頭から処理が一つだけ取り出され、Call Stackへpushされる
おわりに
英語 Version
A single-threaded non-blocking asynchronous concurrent language. Has a call stack, an event loop, a callback queue some other apis and stuff
日本語 Version
JavaScriptとは、シングルスレッドでノンブロッキングかつ非同期処理、並行処理が可能な言語です。コールスタック、イベントループ、コールバックキューやその他諸々のAPIを保持しています
冒頭にあった、JavaScriptの説明を理解できましたか? 是非コメントやフィードバック、LGTMをお願いします!これからの記事作成のモチベーションに繋がります!