メインコンテンツまでスキップ

イベントループ — 1 本の線で待ち時間を捌く

「通信の結果を待っている間、JavaScript は何をしているのか」。この問いに「別のスレッドで待っている」と答えると、いくつかの現象が説明できなくなります。重い計算を 1 回走らせるとボタンが押せなくなるのはなぜか。setTimeout(fn, 0) を書いたのに、ずっとあとで動くのはなぜか。await の直後の行が、呼び出し元の続きより後に動くのはなぜか。

答えはどれも同じ仕組みから出てきます。1 つのイベントループの上では、JavaScript を動かすスレッドは 1 本で、そこに積まれた仕事を順番に 1 つずつ片付けている、という仕組みです。

この章で学ぶこと

  • 1 本のスレッドで待ち時間を捌ける理由
  • タスクとマイクロタスクの違いと、実行される順序
  • await の続きがいつ動くか
  • setTimeout(fn, 0) が「すぐ」を意味しない理由
  • 長いタスクが描画と操作を止める仕組み
  • 重い処理を逃がす 3 つの方向
前提知識

Promiseasync / await の構文を書いた経験があれば読めます。構文そのものは JavaScript 入門 — 非同期処理 が扱います。指標の側から同じ現象を見た章として Core Web Vitals があります。

この章で扱わないこと

観点参照先
Promise / async / await の構文と Promise.all の使い分けJavaScript 入門 — 非同期処理
INP が悪いときの原因の絞り込みCore Web Vitals
別スレッド・別プロセスで動かす側の仕組みプロセスとスレッド
失敗した非同期処理をどう扱うかエラーハンドリング / リトライと冪等性

待っている間、何もしていない

まず押さえるべきは、待ち時間そのものは JavaScript の外にあるということです。

fetch を呼ぶと、実際の通信はブラウザ (あるいは Node.js) の内部が引き受けます。JavaScript 側がするのは「終わったらこの関数を呼んでほしい」と登録して、すぐ次の行へ進むことだけです。通信が終わると、内部の仕組みが登録された関数をキューへ積みます

つまり並行に見えているのは、待つ相手が JavaScript の外にいるからです。1 つのイベントループの上では、JavaScript のコードはどの瞬間も 1 つしか動いていません。別のスレッドを明示的に起こせば話は変わります (プロセスとスレッド)。

この「手が空いたら 1 つ取る」を延々と繰り返しているのがイベントループです。

タスクとマイクロタスク

キューは 1 種類ではありません。タスクマイクロタスクの 2 系統があり、扱いが違います。

HTML Standard のイベントループ処理モデルは、この繰り返しを次のように定めています (HTML Standard — 8.1.7.3 Processing model)。

  1. タスクキューから 1 つ選び、oldestTask としてそのステップを実行する
  2. 実行し終えたら Perform a microtask checkpoint

そして「マイクロタスクチェックポイント」の定義はこうです。

While the event loop's microtask queue is not empty: Let oldestMicrotask be the result of dequeuing from the event loop's microtask queue... Run oldestMicrotask.

HTML Standard — 8.1.7.3 Processing model

「空でない間」の繰り返しである点が要点です。タスクは 1 回に 1 つしか処理しませんが、マイクロタスクは空になるまで全部流します。マイクロタスクの中で新しいマイクロタスクを積めば、それも同じチェックポイントの中で動きます。

積まれるもの1 回の周回で処理する量
タスクsetTimeout / setInterval のコールバック、イベントのハンドラ、受け取ったメッセージの処理1 つだけ
マイクロタスクPromisethen / catch / finallyawait の続き、queueMicrotask空になるまで全部

実行順を見ます。

console.log('1 同期');

setTimeout(() => console.log('4 タスク'), 0);

Promise.resolve().then(() => {
console.log('3 マイクロタスク');
Promise.resolve().then(() => console.log('3.5 マイクロタスクの中で積んだマイクロタスク'));
});

console.log('2 同期');
1 同期
2 同期
3 マイクロタスク
3.5 マイクロタスクの中で積んだマイクロタスク
4 タスク

同期のコードが全部終わってからマイクロタスクが流れ、それが空になってからタスクが動きます。チェックポイントの途中で積んだマイクロタスクが、タスクより先に割り込むのが上の定義の直接の帰結です。

この性質には代償があります。マイクロタスクの中でマイクロタスクを積み続けると、チェックポイントの while が終わりません。次のタスクも描画も永久に来ないので、ページは固まったままになります。タスクで同じことをしても 1 周ごとに合間が入るので、ここはマイクロタスク固有の壊れ方です。

「キュー」だが集合 — 実行可能な最初のタスクが取られる

仕様は task queue を「キューではなく集合 (set)」と定義しています。処理モデルが「選んだキューの実行可能な最初のタスク」を取るのであって、先頭を機械的に取り出すわけではないためです。さらにどのタスクキューを選ぶかは実装依存 (implementation-defined) と定められています。同じキューに積まれたタスクは前から順に取られますが、どのキューを先に選ぶかは保証されません。種類の違うタスクどうしの順序に依存しないでください (HTML Standard — 8.1.7.1 Definitions)。

await の続きはマイクロタスク

await は「そこで止まる」ように見えますが、実際には関数を途中で抜けて、続きをマイクロタスクとして登録しています。

async function main() {
console.log('A await の前');
await null; // 値が何であれ、続きはマイクロタスクへ回る
console.log('C await の後');
}

main();
console.log('B main を呼んだあとの同期処理');
A await の前
B main を呼んだあとの同期処理
C await の後

await null は待つものが何もないのに、それでも B のほうが先に出ます。await を書いた時点で関数はいったん呼び出し元へ戻るからです。ここを取り違えると、「await の直後に変数を読んでいるのに、まだ書き換わっていない」種類の順序バグの原因が見えなくなります。

setTimeout(fn, 0) は「すぐ」ではない

setTimeout がするのはタスクを積むことだけです。積まれたタスクが動くのは、いま動いているタスクが終わり、マイクロタスクが空になってからです。

const started = Date.now();
setTimeout(() => {
const waited = Date.now() - started;
console.log(`0 を指定したタスクが動いたのは ${waited >= 500 ? '500 ms 以上あと' : '500 ms 未満'}`);
}, 0);

let sum = 0;
for (let i = 0; i < 3_000_000_000; i++) sum += i;
console.log('同期の処理が終わった');
同期の処理が終わった
0 を指定したタスクが動いたのは 500 ms 以上あと

同期のループが終わるまで、積まれたタスクは動きようがありません。0 が意味するのは「最短でこれくらい待つ」であって「すぐ」ではありません。

指定した値がそのまま使われるとも限りません。ブラウザ側の規則では、setTimeout の入れ子が深くなると下限が引き上げられます。

If nestingLevel is greater than 5, and timeout is less than 4, then set timeout to 4.

HTML Standard — 8.7 Timers

深さ (nestingLevel) は setTimeout のコールバックの中から setTimeout を呼ぶたびに 1 ずつ増えます。仕様では、深さが 5 を超えたところから 0 を指定しても 4 ミリ秒待たされます。

この 4 ミリ秒は Node.js の規則ではありません。Node.js は「delay1 未満なら 1 にする」とだけ定めています (Node.js — Timers)。

入れ子を 40 段重ねて確かめます。ブラウザの規則が効いていれば深い段は 1 本あたり 4 ミリ秒以上かかるので、合計は 100 ミリ秒を超えます。効いていなければ Node.js の 1 ミリ秒の下限で決まり、50 ミリ秒前後に収まります。100 ミリ秒に線を引けば、規則が効いていないことを 1 つの真偽で判定できます (false なら効いていない。true は環境が遅いだけの場合もあるので断定できません)。

// setTimeout(fn, 0) を 40 段重ねる
let n = 0;
const t0 = Date.now();
function step() {
n++;
if (n < 40) { setTimeout(step, 0); return; }
console.log(`40 段の合計が 100 ms を超えたか: ${Date.now() - t0 >= 100}`);
}
setTimeout(step, 0);
40 段の合計が 100 ms を超えたか: false

false なので、この実行環境ではブラウザの規則が効いていません。Node.js で走らせているからです。下限が効き始める深さは実装で違うので、ブラウザで測った合計値をそのまま見積もりに使わないでください。

環境ごとに下限の規則が違うので、間隔に依存した書き方をしないでください。

描画はタスクの合間にしか入らない

ブラウザが画面を更新する機会は、仕様ではレンダリングの機会 (rendering opportunity) と呼ばれ、ハードウェアのリフレッシュレートなどから決まります。

This specification does not mandate any particular model for selecting rendering opportunities. But for example, if the browser is attempting to achieve a 60Hz refresh rate, then rendering opportunities occur at a maximum of every 60th of a second (about 16.7ms).

HTML Standard — 8.1.7.3 Processing model

重要なのは、この機会が来ても、いま動いているタスクが終わるまでは何も起きないことです。1 つのタスクが 500 ミリ秒回り続ければ、その間の機会はすべて素通りします。クリックのハンドラも、そのタスクが終わるまで積まれたままです。

これが Core Web Vitals で「3 つともブラウザのメインスレッドの話」と述べた中身です。INP が悪いのは、操作から反応までの間に長いタスクが挟まっているからであって、サーバーの遅さとは別の層にあります。

重い処理を逃がす 3 つの方向

長いタスクを短くする方向は 3 つあります。

方向やること向く場面
分割する処理を区切り、合間にタスクを 1 つ通す件数の多い繰り返し。同じスレッドで済む
別スレッドへ出すWeb Worker / worker_threads へ渡す分割しても総量が減らない重い計算
サーバーへ寄せる計算済みの結果を返してもらう端末の性能に左右されたくない処理

分割は「区切りでタスクを 1 つ積み、それを await する」だけです。

// 重い処理を分割し、合間にタスクを 1 つ通す
const yieldToEventLoop = () => new Promise<void>((resolve) => setTimeout(resolve, 0));

let interrupted = 0;
const timer = setInterval(() => interrupted++, 1);

async function sumAll(count: number): Promise<number> {
let sum = 0;
for (let i = 0; i < count; i++) {
sum += i;
if (i % 1_000_000 === 0) await yieldToEventLoop(); // ここで他のタスクが動ける
}
return sum;
}

const total = await sumAll(20_000_000);
clearInterval(timer);
console.log('計算は完了した', total > 0);
console.log('計算の途中でタイマーが動いた', interrupted > 0);
計算は完了した true
計算の途中でタイマーが動いた true

譲るのは setTimeout であって Promise.resolve() ではありません。マイクロタスクで譲ってもチェックポイントの中に留まるので、他のタスクは動かず描画も入りません。合計時間は分割しないときより長くなります。ブラウザでは、await を挟んでも setTimeout の入れ子が途切れないので、前に見た 4 ミリ秒の下限がここでも効きます。譲る回数が増えるほど、その分の待ちが積み上がります。速くする手法ではなく、他の仕事を割り込ませる手法です。

分割しても総量が減らないなら、別スレッドへ出します。その先は プロセスとスレッド が扱います。

Node.js のループはフェーズに分かれている

サーバー側も 1 本のスレッドで回りますが、内訳が違います。Node.js のイベントループはフェーズを順に巡り、各フェーズが自分のキューを処理します (Node.js — The Node.js Event Loop)。

フェーズ処理するもの
timerssetTimeout / setInterval のコールバック
pending callbacks次の周回へ繰り延べられた I/O のコールバック
idle, prepare内部用
poll新しい I/O イベントの取得と、その大半のコールバック
checksetImmediate のコールバック
close callbackssocket.on('close', ...) などの後始末

process.nextTick はこのフェーズの一部ではありません。ドキュメントは「nextTickQueue は現在の操作が完了した後、イベントループのどのフェーズにいるかに関わらず処理される」と述べています。マイクロタスクと同じくフェーズの合間に割り込む位置づけなので、ここで重い処理を積むとフェーズが先へ進みません。

「タスクとマイクロタスクの 2 段」という基本形は共通で、タスク側の内訳が細分化されていると捉えると、ブラウザとの対応が付きます。

よくある誤解

async を付けると別のスレッドで動く」 — 動きません。async 関数の中身も同じ 1 本のスレッドで実行されます。並行になるのは、待つ相手が JavaScript の外にいる部分だけです。重い計算を async 関数に入れても、その計算はメインスレッドを塞ぎ続けます。

setTimeout(fn, 0) は今すぐ実行する」 — タスクを積むだけです。いま動いているタスクとマイクロタスクが片付くまで動きません。ブラウザでは、仕様上は入れ子の深さが 5 を超えると下限が 4 ミリ秒に引き上げられます。

Promise.resolve().then() を挟めば他の処理に順番が回る」 — 回りません。マイクロタスクは同じチェックポイントの中で流れるので、タスクは動かず描画も入りません。譲りたいならタスクを積みます。

「重い処理は await すれば画面が固まらない」await が待てるのは「外側が終わりを教えてくれるもの」だけです。同期の重いループを await しても、そのループはメインスレッドで回ります。

「イベントループがあるからデッドロックは起きない」 — 起きます。待ち合いは Promise の間でも作れます。それとは別に、マイクロタスクの中でマイクロタスクを積み続けると、チェックポイントが終わらず次のタスクへ進めません。排他制御の不在と、進行の保証は別の話です。

確認問題

問 1. 一覧画面に「並べ替え」ボタンを付けたところ、押してから反応するまで 2 秒かかるようになりました。ネットワークタブを見ると通信は発生していません。どこを疑い、どう直しますか。

解答と解説

通信が無いので、原因はメインスレッドで動いている同期の処理です。

並べ替えの計算そのものが 1 つの長いタスクになっていると考えられます。押した瞬間から反応まで 2 秒あるということは、その間ハンドラのタスクが回り続け、描画の機会も他のイベントも素通りしています。

切り分けの手順は次のとおりです。

  1. クリックのハンドラの中で、同期のまま回っている処理を探す。件数に比例する繰り返しが第一候補です
  2. 件数を減らして時間が比例して減るか見る。比例するなら計算量の問題なので、計算量配列を走査する型 の側で減らせないか検討します
  3. 減らせないなら逃がす。分割して合間にタスクを通すか、別スレッドへ出すか、あらかじめ計算した結果をサーバーから受け取るかの 3 択です

async を付けたり await を挟んだりしても、同期のループは短くなりません。分割するなら、繰り返しの途中で実際にタスクを積む必要があります。

問 2. 次のコードの出力を、順番も含めて答えてください。

console.log('a');
setTimeout(() => console.log('b'), 0);
Promise.resolve().then(() => console.log('c'));
queueMicrotask(() => console.log('d'));
console.log('e');
解答と解説

答え: a e c d b

  1. ae は同期のコードなので、現在のタスクの中でそのまま順に出ます
  2. 現在のタスクが終わるとマイクロタスクチェックポイントに入ります。thenqueueMicrotask はどちらもマイクロタスクなので、積まれた順に c d と流れます
  3. マイクロタスクが空になってはじめて、次のタスクとして b が動きます

setTimeout0 は順番を早めません。タスクとマイクロタスクの間に優先順位があり、指定した数値はその後の話です。

問 3. 集計画面で大量の明細を合計する処理があり、実行中はスクロールも止まります。「Promise で包んで非同期にしたので直るはずだ」と言われましたが、直りませんでした。なぜですか。

解答と解説

Promise で包んでも、中の同期処理は同じスレッドで一気に走るからです。

new Promise((resolve) => { /* 明細を回すループ */ resolve(); }) と書いても、executor の中身はその場で同期的に実行されます。Promise が非同期にするのは結果の受け取り方であって、処理の中身ではありません。

同じ理由で、async function にして await を付けても変わりません。await が譲るのは「そこで関数を抜けて続きをマイクロタスクにする」ところまでで、マイクロタスクは同じチェックポイントの中で流れます。描画は入らず、スクロールも動きません。

直すには次のどちらかが要ります。

  • ループを区切り、合間に setTimeout でタスクを積む。スクロールは動くようになりますが、合計時間は伸びます
  • 別スレッドへ出す。Web Worker へ配列を渡して合計だけ受け取れば、メインスレッドは空いたままです

どちらを選ぶかは、処理中に画面を操作させたいかで決めます。操作させるなら分割、結果だけ待たせてよいなら別スレッドのほうが単純です。

まとめ

  • 1 つのイベントループの上では、JavaScript を動かすスレッドは 1 本で、待ち時間そのものは JavaScript の外にあります。並行に見えるのはそのためです。別のスレッドを起こす話は プロセスとスレッド が扱います
  • イベントループはタスクを 1 つ実行し、そのあとマイクロタスクを空になるまで流す、を繰り返します
  • await の続きと Promisethenマイクロタスクです。同じチェックポイントの中で流れるので、譲ったことになりません
  • setTimeout(fn, 0)タスクを積むだけです。動いているタスクが終わるまで動きません。ブラウザでは、仕様上は入れ子の深さが 5 を超えると下限が 4 ミリ秒になります (Node.js の規則ではありません)
  • 描画の機会は定期的に来ますが、動いているタスクが終わるまで何も起きません。長いタスクが操作と描画を止めます
  • 逃がす方向は分割・別スレッド・サーバーの 3 つです。分割は総時間を短くしませんが、他の仕事を割り込ませられます
  • Node.js のループはフェーズを巡ります。タスク側が細分化された形で、2 段構造そのものは共通です
関連リファレンス

次に読む