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

非同期処理における変数の生存期間:Promiseの解決を待つ間に変数はどう変化するか

フロントエンドのコードレビューにおいて、私はしばしば「`await` の行でJavaScriptの実行が一時停止(ポーズ)している」と誤解しているエンジニアを見かけます。シングルスレッドであるJavaScriptエンジン(V8等)において、スレッドが物理的に停止することなどあり得ません。

`await` や `.then()` を通過した瞬間、現在のコールスタックは完全に解放(Pop)されます。では、Promiseが解決(Resolve)するまでの数百ミリ秒から数秒間、呼び出し元関数に存在していた変数たちは一体どこへ消え、どのようにして値を保持し、あるいは変質していくのでしょうか?

本稿では、V8エンジンのヒープメモリ空間、クロージャ、そしてイベントループのマイクロタスクキューの観点から、非同期待ちにおける変数の「真の生存期間(Lifetime)」を完全解剖します。メモリリークを防ぎ、競合状態(Race Condition)を排除する堅牢な設計パターンまで徹底解説します。

—

1. コールスタックの脱出とヒープメモリへの退避メカニズム

同期処理において、関数内で宣言されたローカル変数はコールスタック(Stack Frame)上に確保されます。関数が終了すれば、スタックポインタが移動し、変数は即座に破棄されます。

しかし、関数内で非同期処理(Promise)を生成した場合、挙動は根本から異なります。

async function fetchUserData(userId) {
// 1. スタック上に生成される変数
const requestTimestamp = Date.now();
const largeBuffer = new Array(1000000).fill(‘data’);

// 2. 非同期API呼び出し(Promiseの発生)
const response = await fetch(`/api/user/${userId}`);

// 3. await後の処理
console.log(`Latency: ${Date.now() – requestTimestamp}ms`);
return response.json();
}

上記のコードにおいて、`await fetch(…)` が評価された瞬間、以下の内部挙動が発生します。

V8内部で起きていること

1. コールスタックの退避 (Stack Frame Evacuation)
`await` に到達すると、V8は関数の実行を中断し、Promiseオブジェクトを返却します。この時点で `fetchUserData` のスタックフレームはコールスタックから完全に取り除かれます(Pop)。
2. Context(スコープ空間)のヒープ昇格
コールスタックが消失しても、`await` 以降の行(継続処理: Continuation)で `requestTimestamp` を参照する必要があります。そのため、V8エンジンはスタック上にあった変数群のうち、後続の処理で参照される変数を抽出(コンテキストキャプチャ)し、ヒープ領域に `Context` オブジェクトとして再配置します。
3. GC Rootからの参照チェーン構築
イベントループの「マイクロタスクキュー(Microtask Queue)」に登録されたPromiseの `PromiseReactionJob` が、このヒープ上の `Context` への参照を保持します。これにより、Garbage Collector (GC) は「このヒープ領域はまだ生きている(Unreachableではない)」と判断し、回収を保留します。

—

2. 変数の変質:`var` と `let/const` が引き起こす非同期クロージャの罠

非同期待ちの間に変数がどう評価されるかは、変数宣言子による「レキシカル環境(Lexical Environment)の生成単位」に決定的に依存します。

実務で最も壊れやすいのは、ループ処理と非同期処理が絡み合うロジックです。

反面教師:`var` による単一スコープ共有の惨劇

// ❌ 非推奨:varによる非同期ループ(バグの温床)
function processItemsBad(items) {
for (var i = 0; i < items.length; i++) { // 100ms後に実行されるタスク setTimeout(() => {
// コールスタックが空になった後、ヒープ上の単一の Context.i を参照する
console.log(`[Bad] Index: ${i}, Item: ${items[i]}`);
}, 100);
}
}

processItemsBad([‘A’, ‘B’, ‘C’]);
// 出力結果:
// [Bad] Index: 3, Item: undefined
// [Bad] Index: 3, Item: undefined
// [Bad] Index: 3, Item: undefined

なぜこうなるのか?

`var` で宣言された変数 `i` は関数スコープ(またはグローバル)に1つしか存在しません。`setTimeout` のコールバック(非同期処理)がマイクロタスク/マクロタスクキューで待機している間に、同期処理である `for` ループは高速で完走します。
ヒープ領域に退避された単一の `Context` 内の `i` は `3` に書き換わっているため、100ms後にコールバックが実行される頃には、すべての非同期処理が「評価完了後の最終値 `3`」を参照してしまいます。

正解:`let` による反復ごとのレキシカル環境隔離

// ✅ 推奨:letによるブロック判定と個別のContext生成
function processItemsGood(items) {
for (let i = 0; i < items.length; i++) { setTimeout(() => {
// 各反復(Iteration)ごとに独立してヒープ上に作られた BlockContext.i を参照する
console.log(`[Good] Index: ${i}, Item: ${items[i]}`);
}, 100);
}
}

processItemsGood([‘A’, ‘B’, ‘C’]);
// 出力結果:
// [Good] Index: 0, Item: A
// [Good] Index: 1, Item: B
// [Good] Index: 2, Item: C

`let` や `const` をループ内で使用すると、JavaScriptエンジンはループの各反復ごとに新しいブロックレベルのレキシカル環境を生成します。それぞれの非同期コールバックは、自分自身の反復時に固定された個別のヒープ領域(`BlockContext`)を参照するため、値の汚染が発生しません。

—

3. 隠れたメモリリーク:「不必要なコンテキスト保持(Context Capturing Overhead)」

Webアプリケーションを長時間稼働させた際、メモリ使用量が右肩上がりに増えていく原因の1つが、非同期処理の実行待ち中に巨大なデータがヒープ領域に拘束され続ける現象です。

V8エンジンの最適化アルゴリズムは進化していますが、同一スコープ内にある変数が1つでもクロージャ(または `await` 以降)から参照されている場合、そのスコープ内の他の巨大変数まで連鎖的にヒープに残り続けるリスクが生じます。

メモリを食いつぶすアンチパターン

// ❌ 危険:巨大データが非同期処理の完了までGCされない
async function handleUserUploadBad(fileBuffer) {
// 1. 巨大なデータ(100MBのバッファ)
const heavyParsedData = parseHugeBuffer(fileBuffer);

// 2. 小さなIDメタデータだけを取得
const metadata = { id: heavyParsedData.id, name: heavyParsedData.name };

// 3. 非常に時間がかかる非同期通信(例: 5秒間の外部API呼び出し)
// この 5秒間、heavyParsedData はメモリから消去されない!
await sendAnalyticsPayload(metadata.id);

// 4. await 以降で heavyParsedData を使っていなくても、
// 関数スコープがヒープ上に解放されずに残るケースがある
return metadata;
}

解決策:明示的なスコープ分離と参照の破棄(Context Sanitization)

実務において巨大な配列やバッファ、大量のDOM参照を扱う場合は、非同期の待機状態に入る前に、必要な値だけを抽出し、スコープを意図的に切る(独立させる)設計が必要です。

// ✅ 堅牢:不要な大容量オブジェクトを早期にGC対象にするパターン
async function handleUserUploadClean(fileBuffer) {
// 1. 即時実行関数 (IIFE) または別関数でスコープを閉じ込め、必要な値だけを抽出
const metadata = (() => {
const heavyParsedData = parseHugeBuffer(fileBuffer);
return { id: heavyParsedData.id, name: heavyParsedData.name };
// ここで heavyParsedData のスコープは終了し、スタックから消滅する
})();

// 2. この時点で heavyParsedData はどこからも参照されていないため、
// await の非同期待ちの最中にGC(ガベージコレクション)が回収可能になる
await sendAnalyticsPayload(metadata.id);

return metadata;
}

—

4. プロダクション環境で使える設計パターン

ここまでの理論を踏まえ、フロントエンドでの非同期データフェッチおよび状態管理において、「バグが発生せず」「メモリ効率が極めて高く」「キャンセル可能」 な実践的なコード例を示します。

ReactやVueなどのコンポーネントアンマウント時、または連続リクエスト発生時に非同期の「解決待ち変数」が旧状態を汚染する事故を、`AbortController` と不変性(Immutability)を用いて完全に防ぎます。

/

  • 高負荷な非同期データ処理を安全かつ高パフォーマンスに行うタスクランナー

/
class SafeAsyncProcessor {
/