「JavaScriptはシングルスレッドの言語です」
初めてJavaScriptに出会ったとき、入門書にはそう書いてあった。
しかし、しばらくするうちに順番通りに動いてくれない現象にぶち当たる
どう考えてもマルチっぽい
いろいろ調べていくうちに、イベントループだのノンブロッキングだの、コールスタック、キュー、マイクロタスク、、、と専門用語の軍事オンパレード。
そこで、ざっくり関係を掴めるようにJavaScriptを浮気性な女にたとえてみました。
はじめに:「あなただけよ」という彼女
「JavaScriptはシングルスレッドの言語です」
僕が初めて彼女(JavaScript)に出会ったとき、入門書にはそう書いてあった。 シングルスレッド。つまり「独り身」。 一度に一つのことしかできない。並行処理なんて器用なことはできない。
なんて誠実で、一途な言語なんだろう。 そう思った。彼女となら、複雑な排他制御やデッドロックに悩まされることのない、平穏な関係が築けるはずだ、と。
……しかし、それはとんでもない「詐欺」だった。
付き合い始めてすぐに違和感はあった。決定打はこのコードだ。
console.log('私、今フリーだよ(A)');
// 1秒後に実行される約束
setTimeout(() => {
console.log('(裏の男からの通知 B)');
}, 1000);
console.log('今度デートしようよ(C)');
もし彼女が本当にシングルスレッド(体が一つ)なら、setTimeout で「1秒待つ」という約束をした間、彼女はそこで立ち止まって僕だけを見ているはずだ。
なのに、現実はどうだ?
A と言った直後、彼女は平気な顔をして、次の行の C(デートの約束) を僕と交わしている。 そして1秒後、忘れた頃に B(通知) が届く。
おい待てよ。 僕とデートの約束をしている間、裏で同時に「1秒待つ」という処理を進行させていたのは誰だ? お前、シングルじゃなかったのかよ。
今日は、この JavaScriptの「シングルスレッド詐欺」の巧妙な手口 を暴いていきたい。
詐欺のカラクリ:共犯者「ブラウザ」の存在
結論から言おう。 JavaScript(正確にはJSエンジン)という彼女自体は、嘘偽りなく ガチのシングルスレッド(独り身) だ。彼女の体(コールスタック)は一つしかない。
しかし、彼女には 「Web API」という強力な裏のパートナーたち(マルチスレッド集団) がいたのだ。
僕たちが普段相手にしている「ブラウザ」という環境は、実は彼女のワンルームマンションではない。彼女と、屈強な男たちが共同生活を送るシェアハウスだったのだ。
彼女(JSエンジン): 表向きの対応係。シングルスレッド。計算やロジックを担当。
裏の男たち(Web API): マルチスレッド。タイマー係、通信係、力仕事担当。
巧妙すぎる「浮気」の手口
先ほどの setTimeout のコードが実行されるとき、水面下ではこんなやり取りが行われている。
1. 依頼(Handoff)
君(メインコード)が setTimeout(..., 1000) を実行した瞬間。 彼女は笑顔で君を見つめながら、テーブルの下で素早くスマホを操作する。
彼女(JS):「(LINE送信)ねえタイマーくん、1秒測っといて。あとで連絡して」
裏の男(Web API):「了解。(別スレッドでカウント開始)」
2. 偽装(Non-blocking)
ここが一番の詐欺ポイントだ。 彼女はメッセージを送った直後、何食わぬ顔で君に向き直る。
彼女(JS):「おまたせ。で、次のデートの話(console.log('C'))だけどさ〜」
君は思う。「ああ、彼女は僕との会話を続けてくれている。やっぱりシングルなんだ」と。 完全に騙されている。裏では男が動いているのに。
3. 再会(Callback Queue & Event Loop)
1秒後、裏の男から「測り終わったよ」と連絡が来る。たいていアニメや映画ではここからが修羅場のシーンになる。しかし、この裏の男たちは意外と紳士だ。君と彼女が話している最中(スタックが埋まっている間)は、決して割り込んでこない。
裏の男たちは「連絡待ち行列(キュー)」という名のロビーでおとなしく待機する。 そして、君と彼女の話が完全に終わり、彼女の手が空いた瞬間を見計らって(イベントループ)、スッと現れるのだ。
裏の男(コールバック):「やあ。1秒経った件だけど」
彼女(JS):「あら、来てたのね(console.log('B')実行)」
これが、「シングルスレッドなのにノンブロッキング(同時に動いているように見える)」の正体だ。 面倒なことや時間のかかることは、全部裏の男たちに丸投げしていただけ なのだ。
なぜそんな「設定」にしたのか?
なぜ彼女はこんな回りくどいことをするのか? 最初から「私、マルチな関係(マルチスレッド)なの」と言ってくれればよかったじゃないか。
それは、彼女なりの(あるいは設計者の)歪んだ優しさだ。
もし彼女が本当にマルチスレッドで、複数の男と同時に付き合えるタイプだったら、君はどうなる?
君が変数を書き換えている最中に、別の男がそれを読み取ったら?(競合状態)
「俺とあいつ、どっちが大事なんだ!」と喧嘩が始まったら?(デッドロック)
君は、彼女と付き合うために「排他制御(ロック)」という非常に面倒な駆け引きを学ばなければならなくなる。
彼女はそれを避けたかったのだ。 「君の前ではシングルでいるわ(コードを書く場所はシングルスレッドにする)。その代わり、裏のことは上手くやっておくから、君は難しいことを考えずに私だけを見ていて」
この「都合のいい嘘」のおかげで、僕たちは難しい並列処理を意識せず、気楽にコードを書けていたわけだ。
彼女が浮気を隠し通せる「完璧なルール」~
ここまでで彼女(JSエンジン)には裏の男たち(Web API)がいることが発覚した。 しかし、なぜ彼女は彼らの存在を僕(メインスレッド)に悟らせないのか? そこには、「コールスタック」と「キュー」 を使った、あまりにも冷徹で完璧なルールが存在していた。
1. コールスタック:彼女の「一点集中」という演技
彼女の最大の武器は、「コールスタック」 だ。 これは彼女の脳内の「タスクリスト」のようなものだが、ここには絶対的なルールがある。
「私は一度に一つのことしか考えない」
彼女は、僕と話している(同期処理を実行している)間、絶対に 視線を逸らさない。 たとえ裏でタイマー男(setTimeout)との1秒の約束が終わったとしても、僕との会話が途切れるまでは、スマホの通知すら見ないのだ。
// 僕との長い長い会話
function heavyTalk() {
console.log('ねえ聞いて、昨日のことなんだけど…');
// 彼女の脳(スタック)を占有する重い処理
while(true) {
// 無限ループ:彼女はもう僕しか見えない
}
}
もし僕が彼女を独占して話し続けた場合、彼女のスタックは埋まりっぱなしになる。 こうなると、裏の男たちがいくら「終わったよ!」「会いたいよ!」と連絡してきても、彼女は完全無視を決め込む。 これが、ブラウザがフリーズする(画面が固まる) という現象の正体だ。
(君が彼女を独占しすぎて重い計算処理をさせ続けると、彼女は裏の男たちからの連絡を一切無視してフリーズするので注意が必要だ。愛もほどほどに。)
彼女の「一途さ(シングルスレッド)」は、時として狂気じみている。
2. コールバックキュー:男たちの「待合室」
では、彼女の手が空いたとき、裏の男たちはどうやって割り込んでくるのか? 彼らは、無秩序に部屋に入ってくるわけではない。 「コールバックキュー(Callback Queue)」 という名の、見えない待合室でお行儀よく並んでいるのだ。
タイマー男: 「1秒経ったんで来ました」→ 列の最後尾へ
通信男(Fetch): 「データ取ってきたよ」→ 列の最後尾へ
クリック男(DOM): 「ユーザーがクリックしたよ」→ 列の最後尾へ
彼らは知っている。 彼女が 「今やっていること(スタックにある処理)」 を完全に終えて、ふっと一息ついた(スタックが空になった)瞬間しか、自分たちの相手をしてくれないことを。
3. イベントループ:残酷な「選別係」
そして、この修羅場を回している黒幕が 「イベントループ」 だ。 彼は、彼女と男たちを見張るドアマンのような存在で、機械的にこう判断し続けている。
「彼女(スタック)は今、誰かと話してるか?」
YES → 「お前ら(キューの男たち)、まだ待ってろ。絶対に入るな」
「彼女の話が終わった(スタックが空になった)か?」
YES → 「よし、列の先頭の男、入れ。ただし一人だけな」
この「一人だけ」というのがミソだ。 列の先頭の男(例えば setTimeout のコールバック)が部屋に入ると、彼女はその男の対応でまたスタックが埋まる。 するとイベントループは再びドアを閉め、2番目の男を待たせる。
このループが、秒間何千回も行われているのだ。
- マイクロタスク:実は「本命」がいた
さらに絶望的な事実がある。 待合室(キュー)には2種類あるのだ。
マクロタスクキュー: 普通の男たち(setTimeoutなど)
マイクロタスクキュー: VIP待遇の男たち(Promiseなど)
イベントループ(ドアマン)は、明らかに「VIP(Promise)」を優遇する。 もしVIPルーム(マイクロタスクキュー)に誰かがいるなら、普通の男たちが何億人待っていようと、先にVIPを全員通す という非情なルールがある。
setTimeout(() => console.log('元カレ(setTimeout)'), 0);
Promise.resolve().then(() => console.log('今カレ(Promise)'));
たとえ元カレ(setTimeout)が先に待っていたとしても、彼女は迷わず今カレ(Promise)を先に部屋に入れる。 「0秒で駆けつける」と言った元カレの言葉なんて、彼女には届かないのだ。
🟨 マイクロタスク(Macrotask / Task Queue)
| コマンド / API | 説明 |
|---|---|
| Promise.then() | Promise 成功時の続き |
| Promise.catch() | Promise 失敗時の続き |
| Promise.finally() | 成否に関係ない後処理 |
| queueMicrotask() | 明示的にマイクロタスクへ投入 |
| await の「続き」 | 内部的に Promise.then と同じ |
| MutationObserver | DOM 変更監視(描画前に必ず実行) |
特徴
同期処理が終わった直後に実行
キューが空になるまで全部実行
描画より必ず先
🟨 マクロタスク(Macrotask / Task Queue)
| コマンド / API | 説明 |
|---|---|
| setTimeout() | 一定時間後に実行 |
| setInterval() | 一定間隔で実行 |
| setImmediate() | Node.js 専用(即時実行枠) |
| requestAnimationFrame() | 次の描画直前 |
| DOM イベント | click / keydown など |
| ネットワーク完了 | fetch 完了後の callback |
| I/O 完了通知 | ファイル・通信など |
特徴
1回のループで 1つだけ実行
実行の合間に描画・UI更新が入る
実行順のルール(超重要)
① 同期コード
② マイクロタスク(空になるまで)
③ 描画(レンダリング)
④ マクロタスク(1個)
結論
JavaScriptの非同期処理とは、 「今の会話(スタック)が終わるまで待合室(キュー)で待機させられ、ドアマン(イベントループ)の許可が出た順に、一瞬だけ彼女と会える」 という、男たち(Web API)の涙ぐましい努力の結晶だったのだ。
彼女が涼しい顔で「シングルスレッドです」と言えるのは、裏の男たちがこの完璧なルールを守り、決して彼女の「今の会話」を邪魔しないよう徹底されているからに他ならない。
復習のポイント
コールスタック=一点集中: 「僕との会話」が終わるまでは、裏の通知を一切見ない(ブロッキングの正体)。
キュー=待合室: 非同期処理の結果は、即座に実行されるのではなく、一旦「行列」に入れられる。
イベントループ=ドアマン: スタックが空かどうかを常に監視し、空いた瞬間だけキューから人を招き入れる。
マイクロタスク=VIP: 同じ「待機」でも、Promiseなどは優先順位が圧倒的に高く、割り込みが発生する。
まとめ:それでも彼女を愛せるか
JavaScriptの「シングルスレッド詐欺」。 その実態は、「シングルスレッドの気楽さ」と「マルチスレッドのパワー」を両立させるための、高度な分業システム だった。
彼女の心(エンジン)はシングルだが、生活(ランタイム環境)はマルチだ。
setTimeout や fetch を使うとき、君は彼女を通じて、裏の男たちに仕事を依頼している。
「騙された!」と思うかもしれない。 でも、この仕組みを理解した今、改めて彼女の動き(イベントループ)を見てみてほしい。 ワンオペで君の相手をしながら、裏の男たちからの連絡も絶妙なタイミングでさばいていくその手腕。
そう考えると、なんだかこの「詐欺」も、少し愛おしく思えてこないだろうか?
おまけ Canvas「描画コマンド」そのものは全部同期
代表例:
ctx.fillRect(0, 0, 100, 100);
ctx.clearRect(0, 0, 100, 100);
ctx.drawImage(img, 0, 0);
ctx.fillText("Hello", 10, 20);
ctx.beginPath();
ctx.arc(50, 50, 20, 0, Math.PI * 2);
ctx.stroke();
これらはすべて呼び出した瞬間にJS スレッド上で即座に処理されます(ブロックする) Promise も callback も一切関係なしの同期処理になります。なのでctxでゲームを書くときは、pygameのような感じで書けます。
おまけ2
JavaScriptのシングルスレッドをわかりやすくするため、いつものように強引にアドレスを割り当てて解説します。
10000番台:同期コード実行エリア
いま実行しているJavaScript(関数など)の命令が並んでいる場所。
20000番台:イベントループ命令エリア
スタックが空になったときにPCが戻る、監視用の固定ループプログラム。
30000番台:マクロタスク・キュー
setTimeout やネットワーク完了などにより、あとで呼び出す関数(関数オブジェクト)の先頭アドレスが並ぶ場所。
40000番台:マイクロタスク・キュー
Promise.then など、JS内部が次に実行したいアドレスを書き込む場所。
命令ポインタ(PC)が動く「優先順位」とは
20000番地(イベントループ)にいるPCは、単にキューを見るだけでなく、以下の「優先順位」に従ってジャンプ命令を実行します。
① まず 40000番地(マイクロ)を全消化する
PCが 20000(ループ開始)に到達。40000番地(マイクロ)の内容を確認。関数へのアドレスがあればそこへジャンプして実行。
終わったら 20000 に戻る。
40000番地のマイクロタスクが空になるまで、何度でもこれを繰り返す。(マクロは後回し)
② 次に 30000番地(マクロ)を「一つだけ」やる
40000番地 が空であることを確認。30000番地(マクロ)の内容を確認。
関数へのアドレスがあればそこへジャンプして 一つだけ 実行。終わったら再び 20000(ループ開始)に戻る。
10000番台(実行中) にPCがいる間は、20000番地には戻れません。
10000番台が空になって 20000番地 に戻った瞬間、PCはまず 40000番地(マイクロ) を空にするまでジャンプを繰り返し、その後にようやく 30000番地(マクロ) を一つ処理します。
たとえば「Promise(40000)は setTimeout(30000)より先に実行される」とは
20000番地にあるループ命令の中で、40000番地をチェックする IF 文が、30000番地をチェックする IF 文より先に書かれている」 というだけの単純な話に還元できます。
覚え方
JSのAPIはだいたい登録は同期、動くのは非同期で、Promise系は“マイクロタスク
この理解で、順序の疑問はほぼ全部解けます。
