JavaScriptとイベントループ

JavaScriptの大きな特徴の1つは、「シングルスレッド」基盤の言語だという点です。スレッドが1つということは、同時に1つの作業だけを処理できるということです。しかし、実際にJavaScriptが使われる環境を考えてみると、多くの作業が同時に処理されていることが分かります。例えば、Webブラウザは、アニメーション効果を見せながら、マウスの入力を受けて処理をし、Node.js基盤のWebサーバーでは、同時に複数のHTTPリクエストを処理したりします。スレッドが1つなのに、どうしてこのようなことができるのでしょうか?質問を変えると、「JavaScriptはどのように同時実行(Concurrency)をサポートしているのでしょうか?」

このとき登場する概念が「イベントループ」です。Node.jsを導入した際、イベントループ基盤の非同期方式でNon-Blocking IOに対応して…」のようなフレーズを見たことがあるでしょう。つまり、JavaScriptはイベントループを用いて非同期方式で同時実行をサポートします。多言語の同期方式を使用している方、Node.jsなどからJavaScriptに初めて接する方は、この「イベントループ」の概念に慣れておらず苦労することが多いでしょう。また一方で、JavaScriptを長年使用している方、非同期方式のプログラミングに精通している方でも、イベントループが実際にどのように動作するかについては詳しく分からない場合が多いようです。

少し前の動画ですが、Help, I’m stuck in an event-loopをたまたま見たとき、自分がイベントループについて誤って理解している部分が多いことが分かりました。そこでこの機会に整理も兼ねて、イベントループの重要な事実をいくつか共有したいと思います。

ECMAScriptは、イベントループがない

ぶ厚いJavaScriptの関連書籍を調べてみても、イベントループの説明は意外と検索しにくいですね。その理由は、おそらくECMAScriptスペックにイベントループの内容がないからでしょう。もう少し具体的に言うと「ECMAScriptは、同時実行や非同期に関連するものに言及されていません」(実際はES6から少し変更されましたが、後で説明します)。実際にV8のようなJavaScriptエンジンは、単一のコールスタック(Call Stack)を用いて、要請されると当該要請を順次呼び出し、スタックに入れて処理するだけです。非同期要請はどのように行われ、同時実行の処理は、誰が行うのでしょうか?それはまさに、このJavaScriptエンジンを駆動する環境、すなわちブラウザやNode.jsが担当します。

まず、ブラウザ環境を簡単に図で表します。

上図から分かるように、実際に私たちが非同期呼び出しで使用するsetTimeout、またはXMLHttpRequestのような関数は、JavaScriptエンジンではなく、Web APIの領域に別途定義されています。また、イベントループとタスクキューのような装置もJavaScriptエンジンの外部に実装されているのが分かります。次はNode.js環境を見てみましょう。

(出典:http://stackoverflow.com/questions/10680601/nodejs-event-loop

この図でも、ブラウザ環境と同じような構造であることが分かります。よく知られている通り、Node.jsは非同期IOをサポートするために、libuvライブラリを使ってlibuvがイベントループを提供します。JavaScriptエンジンは、非同期操作のためNode.jsのAPIを呼び出し、このとき渡されたコールバックは、libuvのイベントループからスケジュールされて実行されます。

もうある程度の感覚が得られたでしょう。それぞれについて詳しく調べる前に、1つだけ確実に確かめておきましょう。JavaScriptが「シングルスレッド」基盤の言語であるという言葉は、「JavaScriptエンジンが単一の呼び出しスタックを使用する」という観点で見たときだけ有効です。実際のJavaScriptの駆動環境(ブラウザ、Node.jsなど)では、主に複数スレッドが使用されます。こうした駆動環境が単一呼び出しスタックを使用するJavaScriptエンジンと相互連動するために用いられる装置が、「イベントループ」です。

単一呼び出しスタックとRun-to-Completion

イベントループを学習する前に、JavaScript言語の特徴を見てみよう。JavaScriptの関数が実行される方法を、通常「Run to Completion」と言います。これは1つの関数が実行されると、この関数の実行が終了するまでは、他の作業も途中で割り込めないという意味です。前述したように、JavaScriptエンジンは1つの呼び出しスタックを使用し、現在のスタックに積まれているすべての関数が実行を終え、スタックから削除されるまでは、他のどのような関数も実行ができません。

function delay() {
    for (var i = 0; i < 100000; i++);
}
function foo() {
    delay();
    bar();
    console.log('foo!'); // (3)
}
function bar() {
    delay();
    console.log('bar!'); // (2)
}
function baz() {
    console.log('baz!'); // (4)
}

setTimeout(baz, 10); // (1)
foo();

JavaScriptの経験者は、いくらdelay関数が10msより長くかかるとしても「baz!」が「foo!」より先にコンソールに表示されることはないことを知っています。つまり、foo内部でbarを呼び出す前に10msが経過しても、bazが最初に呼び出されることはないということです。したがって、上記のサンプルを実行すると、コンソールには「bar!」-> 「foo!」->「baz!」の順に表示されます。上記のコードは、グローバル環境で実行されると仮定し、コード内のコメントに数字が書かれた各時点のコールスタックを図にすると、次のようになります。

(グローバル環境で実行されるコードは、一単位のコードブロックとして仮想の匿名関数で包まれていると考えた方が良いでしょう。したがって、上記のコードの最初の行が実行されるときに呼び出し、スタックの一番下に匿名関数が1つ追加され、最後の行まで実行されてスタックから除去されます。)

setTimeout関数は、ブラウザにタイマーイベントを要請した後、すぐにスタックから除去されます。その後、foo関数がスタックに追加され、foo関数が内部的に実行する関数が順番にスタックに追加された後、除去されます。最後に、foo関数が実行を終えてコールスタックが空になり、その後、baz関数がスタックに追加されてコンソールに「baz!」が表示されます。

(結果的にbazは10msよりも遅く実行されるでしょう。つまり、JavaScriptのタイマーは正確なタイミングを保証しません。これについては、John Resigの記事が参考になります。)

タスクキューとイベントループ

ここで1つの疑問が生じます。setTimeout関数を使って渡したbaz関数は、どのようにfoo関数が終わると実行されるのでしょうか?どこで待機して、誰から実行されるのでしょうか?まさにこの役割をするのがタスクキューとイベントループです。タスクキューは文字通り、コールバック関数が待機するキュー(FIFO)型の配列で、イベントループは、コールスタックが空になる度にキューからのコールバック関数を取り出して実行する役割を担います。

前述のサンプルを見てみよう。コードが最初に実行されると、このコードは「現在実行中のタスク」になります。コードを実行中に10msが経過すると、ブラウザのタイマーがbazをすぐには実行せずにタスクキューに追加します。イベントループは「現在実行中のタスク」が終了すると、すぐにタスクキューで待機している最初のタスクを実行します。fooが実行を終えてコールスタックが空になると、現在実行中のタスクは終了し、その際イベントループがタスクキューに待機している最初のタスクであるbazを実行して、コールスタックに追加します。

MDNのイベントループの説明を見ると、なぜ「ループ」という名前が付いたか、非常に簡単な仮想コードで説明しています。

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

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

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

もっと明確に理解するために、前のサンプルを少し変えてみよう。

function delay() {
    for (var i = 0; i < 100000; i++);
}
function foo() {
    delay();
    console.log('foo!');
}
function bar() {
    delay();
    console.log('bar!');
}
function baz() {
    delay();
    console.log('baz!');
}

setTimeout(foo, 10);
setTimeout(bar, 10);
setTimeout(baz, 10);

このコードを実行すると、何の遅延もなくsetTimeout関数が3回呼び出された後に実行を終え、コールスタックが空になるでしょう。そして10msが通る瞬間、foobarbaz関数が順次タスクキューに追加されます。イベントループはfoo関数がタスクキューに入って来ると、コールスタックが空のため、すぐにfooを実行して、コールスタックに追加します。foo関数の実行が終わり、コールスタックが空になると、イベントループが再びキューから次のコールバックのbarを取得して実行します。barの実行が終了したら、同じようにキューに残っているbazのキューから取得して実行します。そしてbazまで実行が完了すると、現在進行中のタスクもなく、タスクキューも空であるため、イベントループは新しいタスクがタスクキューに追加されるまで待機することになります。

(コードは異なりますが、図で表現すると、おおよそ以下のようになります*)


(出典:http://www.2ality.com/2014/09/es6-promises-foundations.html)*

非同期APIとtry-catch

setTimeoutだけでなく、ブラウザの他の非同期関数(addEventListenerXMLHttpRequest…)やNode.jsのIO関連関数など、すべての非同期方式のAPIは、イベントループを通じてコールバック関数を実行します。次のようなコードがなぜエラーを特定できないか、今ならはっきり分かるでしょう。

$('.btn').click(function() { // (A)
    try {
        $.getJSON('/api/members', function (res) { // (B)
            // エラー発生コード
        });
    } catch (e) {
        console.log('Error : ' + e.message);
    }
});

上記のコードでボタンがクリックされ、コールバックAが実行されると、$.getJSON関数は、ブラウザのXMLHttpRequestAPIからサーバーに非同期要請を送信した後、直ちに実行を終え、コールスタックから除去されます。その後、サーバーからの応答を受信したブラウザは、コールバックBをタスクキューに追加し、Bはイベントループによって実行されて呼び出しスタックに追加されます。しかし、このとき、Aはすでにコールスタックが空になった状態であるため、呼び出しスタックには、Bのみ存在します。すなわち、BはAが実行されるときと全く異なる独立したコンテキストで実行され、これによってAは内部のtry-catchステートメントに影響を受けません。

(同様の理由でエラーが発生したとき、ブラウザの開発ツールでコールスタックを参照しても、Bのみ置かれていることが分かるだろう。)

(このような理由から、Node.jsの非同期APIは、ネストされたコールバック呼び出しに対するエラー処理のため「最初の引数はエラーコールバック関数」というコンベンションを受けています。)

これを解決するためには、コールバックBの内部でtry-catchを実行する必要があります。(もちろん、このようにしてもネットワークエラーやサーバーエラーはキャッチできません。そのためにはエラーコールバックを別途提供する必要があります。)

$('.btn').click(function() { // (A)
    $.getJSON('/api/members', function (res) { // (B)
        try {
            // エラー発生コード
        } catch (e) {
            console.log('Error : ' + e.message);
        }
    });
});

setTimeout(fn, 0)

フロントエンド環境のJavaScriptコードを見ると、setTimeout(fn, 0)のようなコードを頻繁に目にします。慣用的に使われているコードですが、初めて見る方は直感的に理解するのが難しいコードです。0秒以降に実行するというのは、実際にそのまま実行することと変わらないからです。しかし実際にこのコードは、単にfnを実行したときと、大きく異なる結果をもたらします。上記サンプルからも分かりますが、setTimeout関数は、コールバック関数を直ちに実行せず(コールスタックではない)タスクキューに追加します。そのため、以下のコードでは、コンソールにB -> Aの順に出力するようになります。

setTimeout(function() {
    console.log('A');
}, 0);
console.log('B');

フロントエンド環境では、レンダリングエンジンと関連して、このようなコードが特に重要な要素として使われることがあります。ブラウザ環境では、JavaScriptエンジンだけでなく、他の様々なプロセスが一緒に駆動されています。レンダリングエンジンもその一部で、このレンダリングエンジンのタスクは、ほとんどのブラウザでJavaScriptエンジンと同じ単一のタスクキューを用いて管理されます。これにより、時折予期しない問題が生じることがあります。次のコードを見てみよう。

$('.btn').click(function() {
    showWaitingMessage();
    longTakingProcess();
    hideWaitingMessage();
    showResult();
});

longTakingProcessが長い時間を要する作業であるため、その前にshowWaitingMessageを呼び出してロードメッセージ(「ロード中…」のような)を表示しようとします。しかし、実際にこのコードを実行してみても、画面にロードメッセージが表示されることはないでしょう。理由は、showWaitingMessage関数の実行が完了して、レンダリングエンジンがレンダリング要請を送信しても、当該要請はタスクキューで実行されているタスクが完了するのを待っているからです。実行中のタスクが完了する時点はコールスタックが空になる時点ですが、そのときはすでにshowResultまで実行が終わっているはずで、最終的にレンダリングが行われる時点では、hideWaitingMessgaeによってロードメッセージが隠されている状態になっているでしょう。これを解決するには、次のようなsetTimeoutを使用します。

$('.btn').click(function() {
    showWaitingMessage();
    setTimeout(function() {
        longTakingProcess();
        hideWaitingMessage();
        showResult();
    }, 0);
});

この場合は、longTakingProcessが直ちに実行されず、タスクキューに追加されます。しかしshowWaitingMessageによりタスクキューには、レンダリング要請が先に追加されるため、longTakingProcessは、次の順序でタスクキューに追加されるでしょう。これでイベントループはタスクキューのレンダリング要請を先に処理するようになり、ロードメッセージが最初に画面に表示されます。

レンダリング関連がなくても、実行に時間がかかるコードをsetTimeoutを使って適切に他のタスクに分ければ、アプリケーション全体が停止したり、スクリプトが遅いとアラートウィンドウが表示されてしまう状況は回避できるでしょう。

ここで重要なのは、「0」という数字が、実際のところ「直ちに」を意味しないということです。ブラウザは、内部的にタイマーの最小単位(Tick)を定めて管理するため、実際には、その最小単位を超えた後にタスクキューに追加されるようになります。そしてこの最小単位は、ブラウザごとに少しずつ異なります。例えばChromeブラウザの場合、最小単位として4msを使うため、ChromeでsetTimeout(fn, 0)は、setTimeout(fn, 4)と同じ意味を持つようになるでしょう。

このような問題を解決するため、setImmediateというAPIが提案されていますが、残念ながら標準にはなれず、IE10以降にだけ含まれています。このメソッドはsetTimeoutのような最小単位の遅延がなく、直ちにタスクキューに対応するコールバックを追加します。EsLintで有名なNCZakasもこのメソッドが標準化されていないことについて批判記事を掲載したことがあります。似たような効果として、postMessageMessageChanelを使うこともありますが、関連内容は、setImmediatePolyfillを実装したライブラリのページによく整理されています。

(Node.jsでは、このような用途としてnextTickという関数がありますが、0.9バージョンからやや異なる概念で使用されています。次章で詳しく説明します。)

プロミス(Promise)とイベントループ

このようなイベントループの概念は、実際にHTMLスペックに定義されています。文書からイベントループ、タスクキューの概念について明確に定義されていることが分かるでしょう。ところが、文書の中でマイクロタスク(microtask)という見慣れない用語が出てきました。次のコードを見てみよう。

setTimeout(function() { // (A)
    console.log('A');
}, 0);
Promise.resolve().then(function() { // (B)
    console.log('B');
}).then(function() { // (C)
    console.log('C');
});

コンソールに表示される順序はどうなるでしょうか?プロミスも非同期で実行されることがあるので、タスクキューに追加され、順番にA -> B -> Cとなるのでしょうか?それともプロミスはsetTimeoutのように最小単位の遅延がないため、B -> C -> Aになるでしょうか?チェーンの形で連続して呼び出されたthen()関数は、どのように動作するのでしょうか?結論から言うと、正解は、B -> C -> Aで、その理由は、プロミスがマイクロタスクを使用するからです。では、マイクロタスクとは一体何だろう?

マイクロタスクは簡単に言うと、一般タスクよりも高い優先順位を持つタスクです。つまり、タスクキューに待機しているタスクがあっても、マイクロタスクが最初に実行されます。上記のサンプルで、さらに詳しく調べてみよう。setTimeout()関数は、コールバックAをタスクキューに追加し、プロミスのthen()メソッドは、コールバックBをタスクキューではなく、別のマイクロタスクキューに追加します。上記のコードの実行が終了したら、タスクのイベントループは(一般的な)タスクキューの代わりにマイクロタスクキューが空であることを最初に確認し、キューにあるコールバックBを実行します。コールバックBが実行されると、2回目のthen()メソッドがコールバックCをマイクロタスクキューに追加します。イベントループは再びマイクロタスクを確認し、キューにあるコールバックCを実行します。その後、マイクロタスクキューが空であることを確認し、(一般的な)タスクキューからコールバックAを取り出して実行します。(このような一連の作業はHTMLスペックにおいて、perform a microtask checkpointという項目に記載されています。)

よく分からない方は、これに関連して、こちらに非常によく整理された記事があるので確認してみてください。原文では、ブラウザ毎にプロミスの呼び出し手順が異なると指摘されていますが、その理由は、プロミスがECMAScriptに定義されているのに対し、マイクロタスクはHTMLスペックに定義され、両方の関連付けが明確でないためです。(ECMAScriptはES6からプロミス向けにジョブキュー(Job Queue)という項目が追加されましたが、HTMLスペックのマイクロタスクとは別の概念です。)しかし、最近のLiving Standard状態であるHTMLスペックを見ると、JavaScriptのジョブキューをどのようにイベントループと連動するかについての項目が含まれています。また現在では、ほとんどのブラウザで問題が修正されていることを確認できます。

プロミスA+スペック文書を見ると、実装時に一般タスク、マイクロタスクの両方とも使用できていると書かれています。実際にプロミスが初めてJavaScriptに導入された時点では、プロミスをどのような手順で実行するかについて多く議論されたようです。しかし前述したように、現在ではプロミスをマイクロタスクと定義しても無理がなさそうです。)

マイクロタスクか一般タスクかによって実行されるタイミングが異なるため、両方を正しく理解し、区別して使用することが重要です。例えば、マイクロタスクが継続して実行される場合、一般タスクであるUIレンダリングが遅延する現象が発生することもあるでしょう。関連文書によく整理されたスタックオーバーフローの応答があるので、ぜひご参考ください。

マイクロタスクを使用している他のAPIも軽く見てみましょう。

  • MutationObserverは、DOMの変化を検出できるクラスで、es6-promiseのようなPolyfillでマイクロタスクを実装するために使用されることもある。
  • 前章で言及したNode.jsのnextTickは、従来の一般タスクを利用して実装されたが、0.9バージョンからマイクロタスクを使用するように変更された。

おわりに

イベントループは、実際にはJavaScript言語のスペックよりも、駆動環境とより関連性があるため、他のプロセス(レンダリング、IOなど)と密接に関連していることから、よく整理された資料を探すのが難しいようです。また、Node.jsのlibuvは、HTMLスペックに完全に準拠していないため、ブラウザ環境のイベントループと詳細実装が少しずつ異なります(さらにブラウザでも実装が少しずつ異なります)。最近では、ES6のプロミスとジョブキューで項目が追加されたことで、マイクロタスクの概念と混同され、理解するのがさらに複雑になりました。

しかし、JavaScriptの非同期的特性を十分に活用するには、イベントループを正しく理解することが重要です。特に(この記事では説明していませんが)、WebワーカーやNode.jsのクラスタを使用するマルチスレッド環境では、イベントループをしっかり理解していなければ、予期せぬバグを招く可能性があります。この記事が少しでも役立つことを願い、今後も関連リンクを時々チェックしながらイベントループを正しく理解できるように努力したいと思います。

参考リンク

TOAST Meetup 編集部

TOASTの技術ナレッジやお得なイベント情報を発信していきます
pagetop