仕組みから学ぶ Web
応用読了目安 14#イベントループ#非同期#Promise#メインスレッド

イベントループ — 1本のスレッドで待たずに捌く

JavaScript が1スレッドなのに、なぜ複数の処理を同時に進められるのか。そして、なぜ「重い処理」が画面ごと止めるのか。

この記事の進み方

What — 1本のスレッドが何を順番に処理しているか

JavaScript の実行環境は、原則としてスレッドが1本です。その1本が、こういう順番で仕事を回しています。

Figure1回のループで起きること
コールスタック実行中マイクロタスクPromise などマクロタスクsetTimeout など描画レンダリング同期コードを最後まで実行マイクロタスクを全部取り出すマイクロタスク実行必要なら描画するマクロタスクを1つ取り出すまた最初へ
1/6
同期コードを最後まで実行関数を呼ぶとスタックに積まれ、返ると降りる。スタックが空になるまで、他の何も割り込めない。ここが長いと全部が止まる。

マイクロタスクは「今のタスクの続き」として全部処理される。マクロタスクは1つずつ。この非対称さが、Promise と setTimeout の順序の違いを生む。

マイクロタスクとマクロタスクの違い

Compareいつ実行されるか

今の同期処理が終わった直後、溜まっているものを全部実行する。描画より先。

Promise.then / catch / finally
queueMicrotask
async 関数の await 以降
MutationObserver
取り出し方
空になるまで全部
描画との順序
描画より前
無限に積むと
画面が完全に固まる
  • 実行中に新しいマイクロタスクを追加すると、同じ回で処理される。だから再帰的に追加すると抜けられない。
  • 「次のタスクより前に、確実に実行される」ことが保証される。状態の整合性を保ちたい処理に向く。

優先度の違いではなく「取り出し方」の違い。マイクロタスクは全部、マクロタスクは1つずつ。この差が実行順序を決める。

順序を実際に追うと、こうなります。

console.log('1');
setTimeout(() => console.log('2'), 0);
Promise.resolve().then(() => console.log('3'));
console.log('4');

// 出力: 1 → 4 → 3 → 2

同期コード(1, 4)が最後まで走り、スタックが空になってからマイクロタスク(3)、そしてマクロタスク(2)。setTimeout(…, 0) が最後に来るのは、優先度が低いからではなく、取り出される順番がそこだからです。

確認 — ここまで読めたか

10万件の配列を回す同期処理に async を付けると、画面の固まりは解消しますか?

まず選ぶ(解答例は a〜d の記号で説明します)

Why — なぜ1スレッドという設計を選んだのか

ブラウザの JavaScript が単一スレッドなのは、DOM を複数スレッドから触らせないためです。

複数のスレッドが同時に DOM を書き換えられるとしたら、ロックが必要になります。「この要素を触る間、他のスレッドは待て」という排他制御を、Web ページを書くすべての人が正しく扱わなければなりません。デッドロックも競合状態も日常的に起きるでしょう。

単一スレッドなら、この問題が定義上発生しません。ある処理が動いている間、他の処理は絶対に割り込まない。だから element.textContent を読んで書き換えるまでの間に、誰かが横から変えることはありえない。

この単純さと引き換えに受け入れたのが、「1つの重い処理が全部を止める」という性質です。

非同期は「並列」ではない

ここが最も誤解されるところです。fetch が非同期なのは、別スレッドで JavaScript が動いているからではありません。

ネットワーク通信そのものはブラウザ(C++ で書かれた部分)が担当し、JavaScript のスレッドは関与しません。通信が終わると、ブラウザが「完了しました」というタスクをキューに積む。JavaScript のスレッドは、手が空いたときにそれを取り出して then を実行する。

つまり JavaScript は待っていないのではなく、待つ仕事を他人に渡しているだけです。渡せる相手がいる仕事(ネットワーク・タイマー・ファイル読み込み)は非同期にできますが、計算そのものは渡す相手がいないので非同期にできません。

確認 — ここまで読めたか

setTimeout(fn, 0) と Promise.resolve().then(fn)。先に実行されるのはどちらですか?

まず選ぶ(解答例は a〜d の記号で説明します)

演習 — まず自分で判断する

解説を読む前に、まず自分で判断してみてください。ここで一度詰まっておくと、 次の節の判断軸が「なるほど」ではなく「そう来たか」に変わります。

演習 — 設計判断を問う

この処理を、体感を損なわずに実装するにはどうしますか?

与えられた条件
  • 管理画面で「レポート生成」ボタンを押すと、以下を実行する
  • ① API から売上データ 5 万件を取得する(約 1.2 秒)
  • ② API からユーザーマスタ 8 千件を取得する(約 0.4 秒)
  • ③ ①と②を突き合わせて集計する(純粋な JS 計算で約 1.5 秒)
  • ④ 集計結果を表にして DOM に描画する
  • ①と②に依存関係はない
  • 現在の実装は、await で①→②→③→④を順に実行している
この軸で考える
  • · どの処理がメインスレッドを占有し、どれが占有しないか
  • · 同時に始められるものを直列にしていないか
  • · 占有を避けられない処理をどう扱うか
まず選ぶ(解答例は a〜d の記号で説明します)

演習 — 説明できるか

「スピナーを出したのに回らない」と言う同僚に、何が起きているかを説明してください

与えられた条件
  • 相手は重い集計処理の前にスピナーの DOM を追加している
  • 相手は async/await を付ければ非同期になると考えている
  • 処理は 10万件のループで 800ms かかる

読み終わりましたか?

読了にすると、これを前提とする記事がロードマップで開放されます。

この記事を前提にしている記事