はじめに
早速ですが Node.js で次のコードを実行したときの出力がどうなるのかわかるでしょうか?
console.log("start");
setTimeout(function() {
console.log("Hello!!!");
}, 0); // 第2引数は 0 ミリ秒 = 0 秒待つ?
console.log("end");
実は、出力は以下の順になります。
start
end
Hello!!!
「なんで?」と思った人向けに、順を追って解説します。
「あたりまえじゃないか」と思った人はブラウザバックしてしまいましょう。1
1. イベントループ
この記事では JavaScript の実行環境を「ワンオペで黙々と作業をこなす従業員」に例えて説明します。
まずは次の図を見てください。
オフィスの片隅でベルトコンベアに乗って運ばれてくる資料を黙々と処理しつづけています。
作業内容は資料に書かれているので席を立つ必要もありません。
もちろん休憩時間は与えられないので、ひとつのタスクが片付いたらすぐに次の作業に取り掛かります。
ポイント
ここではとりあえず、以下の点だけ認識しておいてください。
イベントループとは何か?
では、イベントループとはなんなのか?図に書いたとおり、JavaScript の処理というのは以下のステップを繰り返しています。この繰り返しのことを イベントループ
と呼ぶのです。メインスレッドではひたすらループを回し、キューに溜まっているタスクを取り出しては実行していきます。
- タスクを処理する
- タスクが完了したら次のタスクを取り出す
2. イベントハンドリング
ブラウザでのイベントハンドリングを考えます。
ボタン <button id="btn">
をクリックしたときにログ出力する、というのは次のようにして実現できます。
document.getElementById("btn").addEventListener("click", function ()
console.log("ボタンがクリックされました");
});
このコードが実行された時点では click
イベントにコールバック関数が紐付けられただけです。ボタンをクリックすると下図のようにコンベア(キュー)の最後尾にその関数が追加されます。重要なのは、クリックしたときに関数が実行されるのではなく クリックしたときに関数をキューに追加する4 ということなのです。
イベントハンドラはブロックされる
click
イベントが発火した時点ではコールバック関数(イベントハンドラ)はキューに追加されるだけなので、それより前にあるタスクが片付かないと実行されません。
このことを確認するためのサンプルコードを載せておきます。
デベロッパーツールでコンソールを見ながら操作してみるとわかりますが、イベントハンドラを登録しました
と表示されてから 5 秒経つまでは、何度ボタンをクリックしても時刻は表示されません。そして、5秒経過したらボタンをクリックした回数分だけハンドラが実行され、ログ出力が行われます。
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<script>
window.onload = function () {
document.getElementById("btn").addEventListener("click", function () {
console.log(new Date().toISOString()); // 現在時刻を出力
});
console.log(`${new Date().toISOString()} : イベントハンドラを登録しました`);
// 5 秒間ループ
var start = Date.now();
while (Date.now() - start < 5000) {
// ループ中はボタンをクリックしてもログ出力されない
}
console.log(`${new Date().toISOString()} : タスク完了`);
};
</script>
</head>
<body>
<button id="btn">クリックすると現在時刻がログ出力されます</button>
</body>
</html>
このことからわかるように、JavaScript ではひとつのタスクに時間をかけると他のすべてのタスクに影響が出てしまいます。ブラウザで動かす場合も、Node.js などでサーバーサイドで動かす場合も、イベントループは絶対に止めてはいけません。
3. setTimeout
さて次は setTimeout
です。
これもさっきの click
イベントのハンドラと似たことなのですが、時間に関係するイベントは管理方法が少し異なります。
setTimeout
の第一引数で渡されたコールバック関数は、キューに追加されるのではなく実行予定の時刻をメモして箱にしまわれます。実はタスクが終了して次のループに入るとき、タスクを取り出す前に時刻がチェックされます。
時刻をチェックしたときに「すでに実行時刻を過ぎているもの」が存在すれば、そのタスクが実行されます。5
setTimeout は「○○秒後に関数を実行する」のではない
さきほどの click
イベントの例と同じですが、イベントループが進まなければ時刻のチェックも行われません。たとえば次のコードを実行すると、1秒後に実行したい処理が5秒経たないと実行されません。処理が時間通りに行われなくなってしまうので、やはり何があってもループを止めてはならないのです。
console.log("start");
setTimeout(function() {
console.log("1秒経ちました");
}, 1000); // 1 秒後に実行したい
// 5 秒間ループ
var start = Date.now();
while (Date.now() - start < 5000) {
}
console.log("5秒経ちました");
はじめの問題
ここで、はじめの問題に戻ってみましょう。
console.log("start");
setTimeout(function() {
console.log("Hello!!!");
}, 0); // 第2引数は 0 ミリ秒 = 0 秒待つ?
console.log("end");
start
end
Hello!!!
ここまで読んだ方なら、何故この順番に出力されるのか理解できるのではないかと思います。この場合の処理をイラストにしてみたので、ひとつずつ処理を確認してみてください。
おわりに
もともとは Node.js の勉強中に書いていた個人的なメモを書き直したものなので、他人が見て理解できる図になっているのか不安です。
それでも、自分と似た感性の人にとって理解の一助となれたら嬉しいです。
検証環境
この記事では、特に断りのない場合以下の環境で動作確認を行っています。
- Node 15.0.1
- Firefox 82.0
参考
-
What the heck is the event loop anyway?
2014年の JSConf でのセッション。英語ですが日本語字幕もあり非常にわかりやすいです -
The Node.js Event Loop, Timers, and process.nextTick()
Node.js の公式ドキュメント -
並行モデルとイベントループ
MDNの解説