コールスタックとは何か(まずイメージから)
コールスタックは、
「今、どの関数を実行中で、その関数からどの関数を呼び出しているか」を記録している
“関数呼び出しの積み重ねメモ” です。
JavaScript エンジンは、このコールスタックを見ながら、
「いまどこを実行しているか」
「次にどこへ戻るべきか」
を決めています。
ここが重要です。
JavaScript はシングルスレッドなので、「今実行中の場所」は常に1箇所だけ。
その「場所の順番」を管理しているのがコールスタックです。
関数を呼び出したとき、コールスタックの中で何が起きているか
シンプルな例で「積む」と「戻る」を見る
次のコードを見てください。
function c() {
console.log("c");
}
function b() {
console.log("b start");
c();
console.log("b end");
}
function a() {
console.log("a start");
b();
console.log("a end");
}
a();
JavaScript実行されるログはこうなります。
a start
b start
c
b end
a end
このとき、コールスタックの中では何が起きているかを、順番に追ってみます。
- プログラム開始時は、まず「グローバルコード」がコールスタックに積まれます。
ここでa()が呼ばれた瞬間、「a の実行フレーム」がスタックに積まれます。 aの中でb()を呼ぶと、「b の実行フレーム」がその上に積まれます。
つまり「a の中で b 実行中」という状態です。bの中でc()を呼ぶと、さらに「c の実行フレーム」が一番上に積まれます。cが終わると、cのフレームがスタックから取り除かれ、
「b の続き(console.log("b end"))」に戻ります。bが終わると、bのフレームが取り除かれ、
「a の続き(console.log("a end"))」に戻ります。aが終わると、aのフレームも取り除かれ、
最後にグローバルコードも終わって、コールスタックは空になります。
「呼び出された順に上に積まれ、終わったものから順に上から取り除かれる」
この「積み重ね」がまさにスタック(stack)の動きです。
「スタックオーバーフロー」はコールスタックの限界突破
有名なエラーで「Maximum call stack size exceeded」というものがあります。
これは、コールスタックに積める数の限界を超えたときのエラーです。
典型的なのは、終わらない再帰呼び出しです。
function loop() {
loop();
}
loop();
JavaScriptloop() が loop() を呼び、さらにまた呼び…と永遠に続きます。
コールスタックはどんどん積み重なっていき、
ある時点で「もうこれ以上積めません」となってエラーになります。
これが「スタックオーバーフロー」です。
ここが重要です。
コールスタックは「現在の呼び出し状況を覚えておくメモ」です。
無限に積めるわけではなく、無限再帰などをすると限界に達してエラーになります。
コールスタックとエラーメッセージ(スタックトレース)
エラーが起きたときに「どこから来たか」が分かる
エラー時に表示される「スタックトレース」は、
まさに「エラーが起きた時点のコールスタックの中身」です。
次のようなコードを考えます。
function c() {
throw new Error("何かおかしい");
}
function b() {
c();
}
function a() {
b();
}
a();
JavaScript実行すると、コンソールにはだいたいこんな感じで出ます(環境によって多少違います)。
Error: 何かおかしい
at c (script.js:2)
at b (script.js:6)
at a (script.js:10)
at script.js:13
これは、
「c の中でエラーが起きた
c は b から呼ばれて
b は a から呼ばれて
a はグローバルから呼ばれた」
という「呼び出しの積み重ね(コールスタック)」をそのまま表示したものです。
コールスタックが分かると、
「エラーが起きた関数だけでなく、その関数を呼んだのはどこか」
「どんな順番でここまで来たのか」
が、一目で追えるようになります。
非同期処理とコールスタックの関係(ここがよく誤解されるポイント)
非同期のコールバックは「別のタイミングで、空のスタックから始まる」
次のコードを見てください。
console.log("A: start");
setTimeout(() => {
console.log("B: timeout");
}, 0);
console.log("C: end");
JavaScript出力は必ず
A: start
C: end
B: timeout
になります。
なぜかというと、
- グローバルコードがスタックに積まれ、「A: start」を出力
setTimeoutを呼び出し、「0 ミリ秒後にこのコールバックを実行して」と予約- 「C: end」を出力
- グローバルコードが終わり、コールスタックが空になる
- イベントループが、タスクキューに溜まっていた「setTimeout のコールバック」をスタックに積んで実行
- 「B: timeout」を出力
という流れだからです。
大事なポイントは、
setTimeout のコールバックが実行されるとき、
その瞬間のコールスタックは「空っぽ」から始まって、
「グローバル → コールバック関数」のように積み直される、ということです。
つまり、非同期のコールバックは「元の関数のスタックの続き」ではない ということです。
async/await でも「スタックは一度いったん切れる」
例えばこんなコードがあります。
async function main() {
console.log("1");
await new Promise((resolve) => setTimeout(resolve, 0));
console.log("2");
}
main();
console.log("3");
JavaScript出力順は
1
3
2
になります。
await の行で一見「ここで止まって、終わったら続きから再開」しているように見えますが、
コールスタック的には、
mainの中でawaitに到達した時点で、一度「メインの処理を抜ける」- コールスタックが空になったあと、Promise の完了時に「続きの処理」が新しいタスクとしてスタックに積まれて実行される
という形です。
ここが重要です。
非同期の世界では「見た目のコードの上下」と「実際のコールスタックの積み方」がズレます。
await や then のあとに書かれた処理は、「元のスタックの続き」ではなく、「新しいタスクとして別タイミングで実行される」と理解すると混乱が減ります。
コールスタックが分かると何がうれしいか
1つめに、「どこでブロッキングが起きているか」が見える
重い処理を同期的に書いてしまうと、その処理はコールスタックの一番上に長時間居座り続けます。
その間、他のタスクは一切実行できません。
「画面が固まる」「クリックに反応しない」といった現象は、
コールスタックの一番上に長時間の処理が乗りっぱなしになっているサインです。
コールスタックのイメージを持っていると、
「ここで重い while を回してるから、スタックが詰まってイベントが処理されないんだな」
と、原因を構造的に理解できます。
2つめに、「同期と非同期の頭の切り替え」がしやすくなる
同期コードは、「1 本のコールスタックを上から下へ辿る」イメージでそのまま理解できます。
非同期コードは、「コールスタックがいったん空になり、あとで別タスクとして再開される」イメージを添えて読む必要があります。
この切り替えができるようになると、
「ここから先は then の中だから、“別のタイミングで実行される話”なんだな」
「await の先は、メイン関数の続きに見えるけど、実際には次のイベントループのタイミングなんだな」
といった感覚が自然に身についていきます。
小さな練習:自分で「頭の中コールスタック」を追ってみる
練習として、次のようなコードで「今スタックに何が乗っているか」を紙に書き出してみてください。
function foo() {
console.log("foo start");
bar();
console.log("foo end");
}
function bar() {
console.log("bar start");
setTimeout(() => {
console.log("timeout in bar");
}, 0);
console.log("bar end");
}
console.log("global start");
foo();
console.log("global end");
JavaScript実行結果を並べると、
global start
foo start
bar start
bar end
foo end
global end
timeout in bar
になります。
それぞれのログが出た瞬間に、「コールスタックの中身は何が積まれているか」を想像してみると、
非同期部分(setTimeout のコールバック)が「あとで別タスクとして実行される」感覚が、かなりはっきりつかめるはずです。
まとめ
コールスタックの本質を一言で言うと、
「JavaScript が『今どの関数の中にいて、誰から呼ばれてここにいるのか』を覚えておくための積み重ねメモ」 です。
押さえておきたいポイントは次の通りです。
関数を呼び出すたびに「スタックに積まれ」、終わると「上から取り除かれる」
無限再帰などで積みすぎると「Maximum call stack size exceeded」(スタックオーバーフロー)になる
エラー時に表示されるスタックトレースは「その瞬間のコールスタック」の中身
非同期コールバックや await の「続き」は、元のスタックの延長ではなく、「別のタイミングで新しいタスクとして実行される」
重い同期処理は、コールスタックの一番上に長時間居座ることで、他の処理をブロックしてしまう
これを踏まえたうえでコードを読むとき、
「いまこの瞬間、スタックの中には何が乗っている?」と、
頭の中で軽くトレースしてみてください。
その癖がつくと、エラーの原因追跡も、非同期コードの理解も、
今よりずっと楽になります。
