【入門編】非同期処理における変数の生存期間:Promiseの解決を待つ間に変数はどう変化するか – JavaScriptコア文法・モダン言語仕様と変数・関数解析バイブル

こんにちは!フロントエンドからNode.jsの深層まで、日夜JavaScriptと向き合っているシニアアーキテクトです。

今回は、JavaScriptの学習において多くの人が最初に直面する大きな壁、そして中級者へのステップアップの鍵となる「非同期処理と変数の生存期間(ライフサイクル)」について、じっくりとお話ししていきますね。

ここをクリアできれば、JavaScriptの心臓部である「クロージャ」と「メモリの仕組み」が手に取るようにわかるようになりますよ。バッチリマスターしていきましょう!

—

1. 変数はどこに消える?(同期と非同期の決定的な違い)

まずは、普段私たちが何気なく書いている変数が、メモリ上でどう扱われているかという基本の「き」から確認しましょう。

通常の同期処理では、関数が実行されると、その関数の中で宣言された変数たちは「コールスタック(Call Stack)」と呼ばれる一時的な作業台の上に積み上げられます。そして、関数の実行が終わると、その作業台から綺麗さっぱり消し去られます(解放されます)。

では、ここで問題です。「非同期処理(例えば `setTimeout` や `Promise`)」が絡んできたとき、その作業台の上にいた変数はどうなるのでしょうか?

ちょっと次のコードを見てみてください。

function createTimer() {
let message = “非同期処理の世界へようこそ!”;

setTimeout(() => {
console.log(message);
}, 1000);

console.log(“関数はもう終わったよ”);
}

createTimer();
// 出力順:
// 1. 関数はもう終わったよ
// 2. 非同期処理の世界へようこそ!(1秒後)

このコードを実行すると、「関数はもう終わったよ」が先に出力され、その1秒後に `message` の中身が表示されます。
おや? `createTimer` という関数はすでに実行を終えてコールスタックから消滅しているはずなのに、1秒後にタイマーのコールバック関数はちゃんと `message` を思い出して出力できていますよね。

「あれっ? 関数が終わったのに、中の変数が生き残ってる……?」
そうなんです。ここにJavaScriptの美しくも少し特殊なメモリ管理の秘密が隠されています。

—

2. コールスタックから「ヒープメモリ」への大移動

JavaScriptのエンジン(V8など)は、非常に賢くできています。

先ほどのコードで `setTimeout` の中に渡されたアロー関数(コールバック関数)は、1秒後に実行されるために未来への予約をされます。この時、アロー関数は「自分は外側にある `message` という変数を使いたいんだ」という絆(スコープチェーン)を心に秘めています。

V8エンジンは、次のように判断します。
> 「おっと、この関数はまだ実行が終わっていない(未来に実行される)のに、外側の関数スコープは消えようとしているぞ。もしこのまま変数を消したら、1秒後にタイマーが動いたときにお手上げになってしまう。よし、この `message` 変数をスタックから救い出して、安全な『ヒープメモリ領域』に退避させておこう!」

この、「関数がスコープの寿命を終えて消滅した後も、内側の関数から参照され続けている変数がヒープ領域で生き残る仕組み」こそが、世に言うクロージャ(Closure)の本質です。

イメージとしては、こんな感じです。

[コールスタック (Call Stack)]
└ createTimer()
└ 実行終了!本来なら消えるはずの変数 `message` が……

[ヒープメモリ (Heap Memory)]
└ 💡 生存!タイマー(非同期処理)が参照し続けるため、
ここにガッチリ守られて居残り続ける!

変数は消されるどころか、主(親関数)が去った後も、未来の非同期処理のためにひっそりとヒープの上で守られているわけですね。

—

3. Promiseとasync/awaitにおける変数の生存期間

現代のJavaScript開発では、`setTimeout` よりも `Promise` や `async/await` を使う機会が圧倒的に多いはずです。では、Promiseの解決(resolve)を待つ間、変数はどう変化・生存するのでしょうか?

実践的なコードでその動きを追ってみましょう。

async function processUserData(userId) {
// ① ローカル変数として宣言
let auditLog = `User ID: ${userId} の操作ログ`;

console.log(“1. 非同期処理を開始します…”);

// ② ネットワークリクエストの擬似的な遅延 (2秒待つ)
// コールスタックはこの時点で一旦空になり、ブラウザ/Node.jsのイベントループに制御が戻る
await new Promise(resolve => setTimeout(resolve, 2000));

// ③ awaitの「後」の世界
// ここで auditLog 変数はまだ生きているか?
auditLog += ” -> 処理成功!”;

return auditLog;
}

processUserData(42).then(result => {
console.log(“4. 結果:”, result);
});

console.log(“2. processUserDataを呼び出し終えました”);

// 出力順:
// 1. 非同期処理を開始します…
// 2. processUserDataを呼び出し終えました
// (約2秒の静寂)
// 4. 結果: User ID: 42 の操作ログ -> 処理成功!

このコードの裏側で何が起きているか?

1. `processUserData(42)` が呼び出され、コールスタックに積まれます。
2. `let auditLog` が生成されます。
3. `await` に到達すると、JavaScriptエンジンは「このPromiseが解決するまで、この関数の続きの実行を一時停止(サスペンド)します」と判断します。
4. ここが重要です! 一時停止した瞬間、コールスタックから `processUserData` の実行コンテキストは一旦外れます。だから「2. processUserDataを呼び出し終えました」が先に実行されます。
5. しかし、関数内で使われていた `auditLog` などの変数は、スタックから消える代わりにヒープ上のサスペンド状態のデータ構造に保持(キャプチャ)されます。
6. 2秒後、Promiseが解決(resolve)すると、エンジンは保持していた変数の状態を復元し、`await` の次の行(③)から実行を再開します。

つまり、`await` を挟もうとも、非同期処理の完了を待つ間、スコープ内の変数は安全にメモリ上に保持され続けるのです。

—

4. 初学者が陥りがちトラップ:「変数の上書き」と「参照の罠」

非同期処理と変数の生存期間を語る上で、絶対に避けて通れないのが「ループ処理と非同期処理の組み合わせ」でよくあるバグです。

次のコードを見て、何が出力されるか分かりますか?

// ⚠️ ありがちなアンチパターン
function dangerousLoop() {
for (var i = 1; i <= 3; i++) { setTimeout(() => {
console.log(`カウント: ${i}`);
}, 1000 i);
}
}

dangerousLoop();
// 期待値: 1秒後に「カウント: 1」、2秒後に「カウント: 2」、3秒後に「カウント: 3」
// 実際の出力:
// 1秒後 -> カウント: 4
// 2秒後 -> カウント: 4
// 3秒後 -> カウント: 4 (えっ…?)

「あれっ? 1, 2, 3 と出てほしいのに、全部 `4` になってしまった……」
これは初学者の誰もが一度は通る洗礼です。なぜこうなるのでしょうか?

メモリとスコープの観点からの解説

1. キーワード `var` は、関数スコープを持ちます(ブロックレベルのスコープを持ちません)。
2. そのため、`for` ループの中で宣言された `i` は、`dangerousLoop` 関数内でたった1つだけ生成され、ヒープ(または関数スコープのメモリ)上で共有されます。
3. ループ自体は一瞬で回り切り、`i` の値は最終的に `4` になります。
4. 1秒後、2秒後、3秒後にタイマーのコールバックが実行されたとき、それらが参照しているのは「個別の `i`」ではなく、すでに値が `4` に書き換わってしまった、たったひとつの `i` なのです。

華麗なる解決策:`let` のブロッククタースコープ

この問題を現代のJavaScriptで一瞬で解決する方法は、`var` を `let` に書き換えるだけです。

// ✨ モダンで安全な書き方
function safeLoop() {
for (let i = 1; i <= 3; i++) { // letに変更! setTimeout(() => {
console.log(`カウント: ${i}`);
}, 1000 i);
}
}

safeLoop();
// 出力:
// 1秒後 -> カウント: 1
// 2秒後 -> カウント: 2
// 3秒後 -> カウント: 3

なぜ `let` だとうまくいくのでしょうか?
`let` はブロック `{}` ごとに新しい変数のバインディング(実体)を生成するブロックスコープを持ちます。
つまり、`for` ループの1回目、2回目、3回目で、それぞれ「別々の独立した `i` 変数」がヒープ上に新しく作られ、それぞれのタイマーがそれぞれの `i` を大切に保持(クロージャとして生存)するからなのです。V8エンジンの裏側では、ループの反復ごとに変数のインスタンスが巧みにカプセル化されています。

—

まとめ:変数のライフサイクルを支配する者、JSを制す

今回は、非同期処理における変数の生存期間について、コールスタックとヒープメモリの挙動を交えて深く掘り下げてみました。

  • 同期処理の変数はコールスタックで完結し、すぐに消える。
  • 非同期処理(`setTimeout`, `Promise`, `async/await`)が絡むと、必要に応じて変数はヒープメモリに退避し、生存し続ける(クロージャ)。
  • ループと非同期を組み合わせるときは、`var` のスコープの罠に気をつけ、`let` を使ってそれぞれの変数の寿命を独立させる。

このメモリの動きが頭の中でイメージできるようになると、非同期処理のバグで頭を悩ませることが激減し、自信を持ってクリーンなコードを書けるようになります。

ここをクリアしたあなたなら、もうJavaScriptの変数とスコープの基本はバッチリマスターできていますよ!
日々のコーディングで「今、この変数はどのメモリ空間に生きているだろう?」と少しだけエンジニアの視点を向けてみてくださいね。それでは、快適なJSライフを!

タイトルとURLをコピーしました