JavaScriptの非同期処理を理解する: Promiseの内部構造とイベントループの仕組み

はじめに

システム開発第一事業部の奥田です。普段はフロント寄りのフルスタックエンジニアとして、Webアプリの開発を担当しています。

前回の記事では、Promiseの基本的な使い方を解説しました。

前回のおさらい(ざっくり)

  • Promiseの3状態(pending/fulfilled/rejected)と一度確定したら戻らない性質
  • thencatchfinallyの基礎と使い分け(値の受け渡し/エラー処理/後始末)
  • メソッドチェーンの基本(毎回「新しいPromise」を返すからつながる)
  • 便利な静的メソッドの入口(Promise.resolve/reject など)

詳しくは前回記事をご参照ください: 第2回:Promise完全入門

この記事ではさらに一歩踏み込んで、Promiseの内部では何が起きているのかを見ていきます。

こんな疑問を持ったことはありませんか?

  • 「thenはなぜ後で実行されるの?」
  • 「Promiseの内部では何が起きているの?」
  • 「チェーンメソッドの仕組みは?」

今回は、Promiseの内部構造動作原理を深掘りします。 仕組みを理解すると、挙動を自信をもって予測でき、バグを未然に防げます。

少し難しく感じるかもしれませんが、実例を交えながら丁寧に解説していくので、安心してついてきてください!

また、この記事はシリーズ第3回に当たる記事です。

変更があるかもしれませんが全体では以下のような流れで進める予定です。

続編の記事は随時書いていきますので楽しみに待っていてください!

内部スロットとは?

Promiseの動作を理解するには、まず内部スロットという概念を知る必要があります。

内部スロットの基礎

内部スロットは、ECMAScriptの仕様上で定義されている、オブジェクトの内部的なプロパティのようなものです。

ざっくりとまとめると

  • 内部メソッド(例: [[Get]], [[Set]], [[Call]], [[Construct]])は、仕様がオブジェクトの振る舞いを定義するための“仮想的なインターフェース”です。普通のプロパティや関数ではなく、エンジン実装が持つ振る舞いの入口だと捉えてください。
  • 内部スロット(例: [[PromiseState]] など)は、そのオブジェクト固有の「内部状態の保管場所」。必要なオブジェクトにだけ“存在する/しない”という前提で書かれます(すべてのオブジェクトが全スロットを持つわけではありません)。
  • スロットは継承されません。プロトタイプチェーンの検索対象にもなりません。なのでin演算子やhasOwnPropertyでは確認することはできません。
  • スロットの初期化は、仕様に定義された「生成の手順」で行われる(例: new Promise(executor) のアルゴリズム内で [[PromiseState]] などを設定)。後からユーザーコードが追加/削除することはできない。
  • ビルトインでも“エキゾチックオブジェクト”と呼ばれるものは、内部メソッドの挙動を通常オブジェクトと変えていることがあります(配列、関数、Promise など)。この違いも内部メソッド/スロットで記述されます。

という感じなんですが、なんか難しいですよね...

でも大丈夫です! 以下のポイントだけ押さえておけばOKです!

  • JavaScriptのコードから直接アクセスすることはできない
  • 仕様書内でPromiseの動作を定義するために使用される
  • ブラウザやNode.jsなどのJavaScript実行環境が、この仕様に従って実装している

では早速、内部スロットを覗いてみましょう! 開発者ツールのコンソールで以下のコードをコピペしてみてください!

const promise = new Promise((resolve) => {
  resolve('完了');
});

console.log(promise);
// ブラウザのコンソールで確認すると...
// Promise {<fulfilled>: "完了"}
//   [[PromiseState]]: "fulfilled"
//   [[PromiseResult]]: "完了"

内部スロットの確認

ブラウザの開発者ツールでは[[PromiseState]][[PromiseResult]]が見えますが、これが内部スロットです!

では、コンソールに出てるのにアクセスできないとはどういうことや... となるかと思いますので、次の章で説明します!

なぜJavaScriptのコードから直接アクセスできないのか?

まず重要なことは、内部スロットはJavaScriptの「プロパティ」ではありません。 [[PromiseState]]のような表記は、ECMAScriptの仕様の擬似メンバー名で、実装エンジンが保持する非公開の内部領域を指します。 したがって、promise["[[PromiseState]]"]Reflect.get(promise, Symbol("[[PromiseState]]")) のように触ることはできないのです。

ではなぜこのような内部スロットがあるのかというと、目的は「エンジンが保証したい不変条件(invariant)を壊されないようにする」ためです。 もしユーザーコード(私たちが書くコード)から状態を書き換えられると、thenの呼び出し順やエラー伝播などの基本性質が破綻するからです。(えらいこっちゃ)

ECMAScriptの仕様は「観察可能な挙動」だけを定め、内部の表現には干渉しません。 これにより各エンジンは内部表現を自由に最適化(隠しクラスや隠しフィールドなど)できます。

エンジンの最適化と実装自由度の確保のためにこの内部スロットは使われているということですね。

開発者ツールで見えるのは「デバッグ表示」であり、JS言語のオブジェクトモデルの一部ではありません。 画面には見えても、言語仕様としては“観察のみ”で変更はできないという点に注意してくださいね。

ミニ実験:プロパティではないことを確認

内部スロットはJavaScriptのプロパティではないとお伝えしましたが、次のコードで挙動を確認できます。

const p = Promise.resolve(1);
console.log(Object.keys(p));                 // [] など(内部スロットは列挙されない)
console.log(p["[[PromiseState]]"]);         // undefined
console.log("[[PromiseState]]" in p);       // false
console.log(Object.getOwnPropertyNames(p));  // [] か、実装依存の最小限

初心者向けポイント

色々と説明しましたが、まず初心者が押さえるべきポイントは以下の通りです!

  • ここでいう「内部スロット」は、あくまで仕様上の用語です。アプリから触れられない非公開の入れ物だと思ってください。
  • 開発者ツールで見えるのは「観察用の表示」。同じ名前が出ていても、JSのコードからpromise[[PromiseState]]のように参照はできません。
  • 役立ちどころは「なぜこのthenが走らないのか?」を考えるとき。状態(pending/fulfilled/rejected)と結果(値/理由)を頭の中で整理できると、原因特定が速くなります。

なぜ内部スロットを学ぶのか?

内部スロットを理解すると、以下のようなメリットがあります:

✅ Promiseの動作原理が分かる
✅ バグの原因を特定しやすくなる
✅ より効率的なコードが書けるようになる
✅ 面接やコードレビューで知識をアピールできる(超重要!)

つまるところ、

「Promiseの仕様はこうだから...この実装を仕様通りにするにはこうしたら組み立てられるな」 「組み立てたはいいが、なんかエラーが起きたな。ふむ、おそらくここに問題がありそう... お、ビンゴ!」

という感じで仕事をスムーズに進められるようになります。

また、ここまでしっかりと道筋を立てて考えられたらそりゃ社内評価も爆上がりするので、より挑戦的な仕事も任せてもらえるようになるというわけです。(経験談)

なので面倒なんですが、仕様から理解するというのはものすごく重要なことなんです。

それでは、Promiseのインスタンス化時に内部で何が起きているのか見ていきましょう!

Promiseインスタンス化の内部動作

Promiseオブジェクトは、new Promise(executor)という構文でインスタンス化されます。

この時、内部では以下のような処理が行われています。

インスタンス化の流れ

ステップ1: Promiseオブジェクトの生成

new Promise()が呼び出されると、新しいPromiseオブジェクトが生成されます。

const promise = new Promise((resolve, reject) => {
  // この時点でPromiseオブジェクトが生成されている
});

生成されたPromiseオブジェクトは、以下の内部スロットを持ちます:

  • [[PromiseState]]: Promiseの状態を管理(初期値: "pending"
  • [[PromiseResult]]: Promiseの結果を管理(初期値: undefined

ステップ2: executorの同期的な実行

過去の記事でも触れた重要なポイントなのですが、new Promise()に渡されたexecutor関数は、同期的に即座に実行されます。

console.log('1. 開始');

const promise = new Promise((resolve, reject) => {
  console.log('2. executor実行中');
});

console.log('3. 終了');

// 出力順序:
// 1. 開始
// 2. executor実行中
// 3. 終了

多くの人が誤解しているポイントですが、executor自体は非同期ではありません。 executor内で非同期処理(setTimeoutなど)を呼ぶことで、非同期な動作になるのです!

ここでつまずきがち

  • new Promiseを書いたのにログが出ない」は、executor内がまだresolve/rejectされていないだけというケースが多いです。
  • executorでthrowするとその場で例外が発生しますが、Promiseの実装はそれをrejectに変換します(このあと自作実装で体験します)。

ステップ3: resolveまたはrejectの呼び出し

executor関数内で非同期処理の成否に応じてresolveまたはrejectを呼び出します。

const promise = new Promise((resolve, reject) => {
  setTimeout(() => {
    resolve('成功!');
    // この時点で:
    // [[PromiseState]]: "fulfilled"
    // [[PromiseResult]]: "成功!"
  }, 1000);
});
  • resolve(value)が呼ばれると:

    • [[PromiseState]]"fulfilled"に変更
    • [[PromiseResult]]valueが格納
  • reject(reason)が呼ばれると:

    • [[PromiseState]]"rejected"に変更
    • [[PromiseResult]]reasonが格納

ステップ4: 状態遷移と結果の格納

Promiseの状態がfulfilledまたはrejectedに遷移するとそれ以降の状態遷移は発生しません

const promise = new Promise((resolve, reject) => {
  resolve('最初の成功');
  resolve('2回目の成功'); // 無視される
  reject('失敗'); // これも無視される
});

promise.then((result) => {
  console.log(result); // '最初の成功'
});

Promiseの状態は最初に確定したものが勝ちます。この場合はresolve('最初の成功')が適用されます。

なので2回目以降のresolve/rejectは無視されます。(resolve('2回目の成功')reject('失敗')

これは「状態は一度だけ確定(settled)する」というPromiseの大原則です。

Promiseの5つの内部スロット

Promiseに内部スロットとして2つ紹介しましたが、実際には5つの内部スロットを管理しています。

それぞれ見ていきましょう!

1. PromiseState - 状態管理

Promiseの現在の状態を保持します。

const pending = new Promise(() => {});
console.log(pending);
// [[PromiseState]]: "pending"

const fulfilled = Promise.resolve('成功');
console.log(fulfilled);
// [[PromiseState]]: "fulfilled"

const rejected = Promise.reject('失敗');
console.log(rejected);
// [[PromiseState]]: "rejected"

Promiseの状態は「待機中 → 成功」または「待機中 → 失敗」のどちらか一回きりであり、成功から失敗、失敗から成功への変化は起きません。 なので、[[PromiseState]]もPromiseの状態が確定したら更新されることはありません。

2. PromiseResult - 結果の保持

Promiseの結果(値またはエラー)を保持します。

const promise = new Promise((resolve) => {
  resolve('結果の値');
  // [[PromiseResult]]: "結果の値"
});

const errorPromise = new Promise((resolve, reject) => {
  reject(new Error('エラーの理由'));
  // [[PromiseResult]]: Error: エラーの理由
});

[[PromiseResult]]は「成功なら値」「失敗なら理由(多くはError)」を入れておく保管箱。
thencatchはこれを読み出してコールバックに渡します。

3. PromiseFulfillReactions - 成功時のコールバックリスト

Promiseが成功(fulfilled)したときに実行されるべきコールバック関数のリストを保持します。

const promise = new Promise((resolve) => {
  // この時点でpending状態
  // [[PromiseFulfillReactions]]: []
  
  setTimeout(() => resolve('完了'), 1000);
});

// thenを呼ぶとコールバックがリストに追加される
promise.then((v) => console.log('1番目:', v));
// [[PromiseFulfillReactions]]: [callback1]

promise.then((v) => console.log('2番目:', v));
// [[PromiseFulfillReactions]]: [callback1, callback2]

promise.then((v) => console.log('3番目:', v));
// [[PromiseFulfillReactions]]: [callback1, callback2, callback3]

// 1秒後、resolveが呼ばれると...
// リストに登録された全てのコールバックが順番に実行される

同じPromiseインスタンスに対してthenを呼んだ分だけリストにコールバックがたまります。

そして解決時に登録順でまとめて(マイクロタスクとして)実行されます。

「マイクロタスクってなんだ...!」と思った方がいるかと思いますが、マイクロタスクに関しては後半の方で説明しますのでお楽しみに!

4. PromiseRejectReactions - 失敗時のコールバックリスト

Promiseが失敗(rejected)したときに実行されるべきコールバック関数のリストを保持します。

const promise = new Promise((resolve, reject) => {
  setTimeout(() => reject('エラー'), 1000);
});

// catchを呼ぶとコールバックがリストに追加される
promise.catch((e) => console.log('catch1:', e));
// [[PromiseRejectReactions]]: [errorCallback1]

promise.catch((e) => console.log('catch2:', e));
// [[PromiseRejectReactions]]: [errorCallback1, errorCallback2]

[[PromiseFulfillReactions]]の時と同様にcatchも複数回登録できます。
未処理のままにすると「Unhandled Rejection」(※)の警告対象になるので、どこかで必ず一度はエラーハンドリングしましょう!

※「Unhandled Rejection」はPromiseがreject(拒否)された際に、それを処理するcatchメソッドなどが設定されていない場合に発生するJavaScriptのエラーです。このエラーはプログラムの実行中に予期せぬ問題が発生していることを示しており、Promiseを扱う際にcatchでのエラーハンドリングを忘れている場合に特に多く発生します。

5. PromiseIsHandled - 処理済みフラグ

Promiseの結果が少なくとも一度は処理されたかどうかを示すフラグです。

初期状態はfalseで、then(), catch(), finally()のメソッドが呼び出された時にtrueに設定されます。

このスロットは、未処理のPromise拒否(Unhandled Promise Rejection)を検出(追跡)するために使用されます。 つまりはPromiseが拒否(reject)された場合に、そのエラーがthenやcatchなどで処理されたかをこの[[PromiseIsHandled]]でチェックしています。

trueに設定されていた場合はそのPromiseの拒否は「ハンドリング済み」とみなされ、Unhandled Rejectionは発生しません。 逆にfalseの場合は「ハンドリングされていない」とみなされてUnhandled Rejectionが発生します。

// エラーハンドリングなし → 警告が表示される
const unhandled = Promise.reject('未処理のエラー');
// [[PromiseIsHandled]]: false
// → コンソールに警告が表示される

// エラーハンドリングあり → 警告なし
const handled = Promise.reject('処理済みのエラー');
handled.catch((e) => console.log(e));
// [[PromiseIsHandled]]: true
// → 警告は表示されない

開発中にエラーハンドリング忘れを発見できるフラグとも言えますね。

これで5つの内部スロットの説明は終わりですが、意外と単純な中身でしたね! 実際に格納されている値などはJavaScriptから見ることはできないので、なかなか実感としては分かりにくいところもありますが、 Promiseのコードを書きながらこのスロットにどう値が入って処理されていくのかのイメージを掴めたら完璧です!

pending状態での内部スロット

pending状態のPromiseはFulfillReactionsRejectReactionsの両方のリストを持っています。

thenの第2引数やcatchを省略しても「拒否用のリアクション」は登録される点がポイントです。

省略時は内部でDefaultRejectHandler(単に次のPromiseをrejectに流すだけの関数)が自動的に入ります。 つまり「失敗したら、とりあえず下流へエラーを伝播する」というコールバックが最初から箱に入るイメージです。

const pendingPromise = new Promise((resolve, reject) => {
  // まだresolveもrejectも呼ばれていない
});

const p1 = pendingPromise.then((v) => console.log('成功1', v));
const p2 = p1.then((v) => console.log('成功2', v));
const p3 = p1.catch((e) => console.log('失敗', e));

// 内部イメージ:
// pendingPromise: {
//   [[PromiseFulfillReactions]]: [reaction for p1],
//   [[PromiseRejectReactions]]: [reaction for p1]  // onRejected省略でも入る
// }
// p1: {
//   [[PromiseFulfillReactions]]: [reaction for p2],
//   [[PromiseRejectReactions]]: [reaction for p3]
// }
// p2: {
//   [[PromiseFulfillReactions]]: [],
//   [[PromiseRejectReactions]]: []
// }
// p3: {
//   [[PromiseFulfillReactions]]: [],
//   [[PromiseRejectReactions]]: []
// }
// (p2/p3はこの時点ではまだ次のリアクションが登録されていないため空のまま)

イメージ: 「箱 = Promise、紙 = コールバック」。
1つ目の箱(pendingPromise)に、成功用・失敗用の紙が1枚ずつ自動で入る(thenで登録されたp1用)。
2つ目の箱(p1)には、成功用の紙がp2へ、失敗用の紙がp3へ向けて入る。
resolve/rejectで箱のフタが閉まると、中の紙を上から順に実行し、結果は次の箱へ受け渡される――という流れ。

finallyメソッドの内部的な扱い

実はfinallyメソッドは内部的にthenメソッドの特殊なケースとして実装されています。 まずはサンプルコードで説明すると以下のようになります。

// finally は以下のように動作する
promise.finally(onFinally);

// ↓ 内部的には以下と同等

promise.then(
  (value) => {
    onFinally();
    return value; // 元の値をそのまま返す
  },
  (reason) => {
    onFinally();
    throw reason; // 元のエラーをそのまま投げる
  }
);

thenの第2引数はどうだったか覚えていますよね? そうです、失敗時(reject)の時のコールバックです。

つまり、thenを使って成功時と失敗時の両方に同じonFinallyを差し込んでいるだけです。

下のサンプルでは「成功しても失敗してもfinallyが走る」という動きのサンプルコードです。

// 成功パターン: 値はそのまま次へ
Promise.resolve('OK')
  .finally(() => {
    console.log('A: finally実行');
    return '無視される値';
  })
  .then((v) => {
    console.log('B: thenに届く値:', v); // 'OK' のまま
  });

// 失敗パターン: 理由はそのまま次へ
Promise.reject('NG')
  .finally(() => {
    console.log('C: finally実行');
  })
  .catch((e) => {
    console.log('D: catchに届く理由:', e); // 'NG' のまま
  });

ポイント整理:

  • onFinally が値を返しても無視され、元の値/理由が次へ進む。
  • onFinally がPromiseを返してrejectした場合のみ、その拒否理由が上書きで伝播する。
  • finallyは「必ず実行したい後片付け」を書く場所。値の置き換えはthen/catchで行う。 (finallyの戻り値で値が返された時に無視されるのは過去の記事で説明しましたね!)

さて、ここまで説明したのは何度も言っていますがJavaScriptからは見えない世界の話です。 なのでここからはJavaScriptでその見えない世界を具現化していきます!!

自作Promiseで体験する

理論だけでは分かりにくいので、実際に簡単なPromiseを実装してみましょう! 内部スロットの動きが具体的に理解できます。

(サンプルコードはPromiseの仕様の動きに基づいて作っていますが、完全なPromiseの動作をしていない点にご注意ください)

シンプルなMyPromiseクラス

// 学習用の最小実装です。本物のPromiseは「マイクロタスク」でハンドラを実行します。
// ここでは queueMicrotask(なければ Promise.resolve().then)でPromiseに近い挙動に寄せています。
const scheduleMicrotask = typeof queueMicrotask === 'function'
  ? queueMicrotask
  : (fn) => Promise.resolve().then(fn);

class MyPromise {
  constructor(executor) {
    // 内部スロットの初期化
    this.promiseState = 'pending';
    this.promiseResult = undefined;
    this.promiseFulfillReactions = [];
    this.promiseRejectReactions = [];

    // resolveハンドラー
    const resolve = (value) => {
      if (this.promiseState !== 'pending') return;
      this.promiseState = 'fulfilled';
      this.promiseResult = value;
      // 登録されたコールバックをマイクロタスクとして実行
      this.promiseFulfillReactions.forEach(callback => {
        scheduleMicrotask(() => callback(value));
      });
    };

    // rejectハンドラー
    const reject = (reason) => {
      if (this.promiseState !== 'pending') return;
      this.promiseState = 'rejected';
      this.promiseResult = reason;
      // 登録されたコールバックをマイクロタスクとして実行
      this.promiseRejectReactions.forEach(callback => {
        scheduleMicrotask(() => callback(reason));
      });
    };

    // executorを即座に実行(例外処理付き)
    try {
      executor(resolve, reject);
    } catch (error) {
      reject(error);
    }
  }

  then(onFulfilled, onRejected) {
    // thenは新しいPromiseを返す
    return new MyPromise((resolve, reject) => {
      const handleFulfilled = (value) => {
        try {
          if (typeof onFulfilled === 'function') {
            const result = onFulfilled(value);
            resolve(result);
          } else {
            resolve(value); // onFulfilledがなければ値をそのまま渡す
          }
        } catch (error) {
          reject(error);
        }
      };

      const handleRejected = (reason) => {
        try {
          if (typeof onRejected === 'function') {
            const result = onRejected(reason);
            resolve(result); // rejectハンドラーの結果はresolveに渡す
          } else {
            reject(reason); // onRejectedがなければエラーをそのまま渡す
          }
        } catch (error) {
          reject(error);
        }
      };

      // すでに確定している場合もマイクロタスクで実行
      if (this.promiseState === 'fulfilled') {
        scheduleMicrotask(() => handleFulfilled(this.promiseResult));
      } else if (this.promiseState === 'rejected') {
        scheduleMicrotask(() => handleRejected(this.promiseResult));
      } else {
        // pending状態ならコールバックリストに追加
        this.promiseFulfillReactions.push(handleFulfilled);
        this.promiseRejectReactions.push(handleRejected);
      }
    });
  }

  catch(onRejected) {
    // catchはthenの糖衣構文
    return this.then(undefined, onRejected);
  }

  finally(onFinally) {
    // finallyもthenで実装
    return this.then(
      (value) => {
        if (typeof onFinally === 'function') onFinally();
        return value;
      },
      (reason) => {
        if (typeof onFinally === 'function') onFinally();
        throw reason;
      }
    );
  }
}

自作Promiseを使ってみる

早速作ったPromiseの動作を見てみましょう!

const myPromise = new MyPromise((resolve) => {
  console.log('executor実行');
  setTimeout(() => {
    console.log('resolve呼び出し');
    resolve('成功!');
  }, 1000);
});

console.log('then登録');
myPromise
  .then((result) => {
    console.log('1番目のthen:', result);
    return '次の値';
  })
  .then((result) => {
    console.log('2番目のthen:', result);
  })
  .catch((error) => {
    console.error('エラー:', error);
  })
  .finally(() => {
    console.log('finally実行');
  });

// 出力順序:
// executor実行
// then登録
// resolve呼び出し(1秒後)
// 1番目のthen: 成功!
// 2番目のthen: 次の値
// finally実行

うまく動いてますね! JavaScriptのコードでPromiseの動きを理解できるので、好きな場所にconsoleを仕込んだりデバッガーを使って段階的にデバッグなどをして動作を確かめてみてください。

なぜthenが新しいPromiseを返すのか?

前回の記事で説明したPromiseチェーンを覚えていますか? その時に説明したthenが新しいPromiseを返すということもMyPromiseのコードを見ると理解しやすいんじゃないでしょうか。

thenメソッドは常に新しいMyPromiseインスタンスを返しています。 これがメソッドチェーンを可能にする仕組みです!

const promise1 = new MyPromise((resolve) => resolve('最初'));

const promise2 = promise1.then((v) => {
  console.log(v);
  return '2番目';
});

const promise3 = promise2.then((v) => {
  console.log(v);
  return '3番目';
});

// promise1、promise2、promise3は全て別のPromiseオブジェクト
console.log(promise1 === promise2); // false
console.log(promise2 === promise3); // false

実行の流れ(ざっくり):

  • new MyPromise(executor)で即座にexecutorが走る(同期)。
  • resolve(value)されたら状態をfulfilledにし、登録されている成功コールバックをマイクロタスクで実行に回す。
  • thenが呼ばれたとき、すでに確定していればコールバックをマイクロタスクに積み、未確定なら「確定したときに呼ぶコールバック」を登録する。
  • それぞれのコールバックの戻り値は次のPromiseへ受け継がれる(戻り値がPromise/thenable(※)なら、その解決を待ってから渡すのが本物の仕様。ここでは最小限の実装に留めています)。

thenの戻り値がPromise/thenableのときに「同化(待ってから次へ)」する仕組みは、本物のPromiseにだけ備わる挙動です。thenableとは「thenメソッドを持つオブジェクト/関数」のこと。詳しくは後半の「thenableオブジェクトの秘密」章で解説します。

マイクロタスクとイベントループ【超重要】

ここまで内部構造を見てきましたが、もう一つ重要な概念があります。それがマイクロタスクとイベントループです。 これはPromiseだけの話ではなく、JavaScript全体の「やることリストの順番」を決める仕組みです。 queueMicrotaskMutationObserverなど、Promise以外の非同期も同じマイクロタスクの箱を共有します。

なぜthenは「後で」実行されるのか?

早速ですが、以下のコードの出力順序を予想してみてください。

console.log('1');

Promise.resolve().then(() => {
  console.log('2');
});

console.log('3');

答えは...

1
3
2

「え、なんで2が最後?」と思いましたか?
これがマイクロタスクの仕組みです。

実行の順番はシンプルで、「同期コード → マイクロタスク(then/catch/finallyqueueMicrotask)→ タスク(setTimeoutなど)」の繰り返しです。 1周ごとにその時点のマイクロタスクを全部片付けてから、次のタスクに進みます。

簡易フロー図(1ループの流れ):

同期コードを実行
      ↓
マイクロタスクキューを全部処理
      ↓
タスクキューから1つ取り出して実行(例: setTimeout)
      ↓
次のループへ(再び同期コードから)

考え方のコツを先に説明すると

  • 「同期 → マイクロタスク → タスク(タイマー等)」の順番でまわる、と覚える。
  • then/catch/finallyは「マイクロタスク」。setTimeoutは「タスク」。どちらも“非同期”だけど、優先度が違います。

JavaScriptの実行モデル

JavaScriptはシングルスレッドで動作しており、一度に1つの処理しか実行できません。 こちらも過去の記事で説明した通りなんですが、JavaScriptはこのシングルスレッド上でうまく処理を回して非同期処理をマルチスレッドっぽく見せているんです。

ではここからはJavaScriptでの非同期処理の流れを見てみましょう!

1. コールスタック(Call Stack)

ここからはイベントループを構成する部品を順に見ていきます。 さきほど「同期 → マイクロタスク → タスク」と説明したときの“同期コード”が実行される場所がコールスタックです。

コールスタックとは現在実行中のコードを管理するスタックのことです。 スタックというと馴染みがないかもしれませんが、箱のようなものだと思ってください!

ざっくり流れを説明すると以下の感じです。

呼び出し元の関数が push される
        ↓
関数内の同期コードを上から順に実行
        ↓
別の関数を呼び出したらその関数が push される
        ↓
呼び出し先の処理が終わると pop されて呼び出し元に戻る
        ↓
スタックが空になったタイミングでマイクロタスクの実行可否をチェック

「push/pop の積み重ね → 空かどうか」でしか判断しない単純な仕組みですが、イベントループの起点になるので挙動をイメージしておくとデバッグが一気に楽になりますし、コードを書きながらコードの動きが読めるようにもなります!

コードで流れを確認してみましょう!

function foo() {
  console.log('foo');
  bar();
}

function bar() {
  console.log('bar');
}

foo();

// コールスタックの動き:
// 1. foo() がスタックに積まれる
// 2. console.log('foo') が実行される
// 3. bar() がスタックに積まれる
// 4. console.log('bar') が実行される
// 5. bar() がスタックから取り除かれる
// 6. foo() がスタックから取り除かれる

コールスタックの実態も内部スロット同様にJavaScriptからは見えない世界なのでなかなか実感が湧きにくいかと思います。 なので、コールスタックを配列として模倣すると、push/popの動きがより具体的に掴めます。

const callStack = [];

const enter = (name) => {
  callStack.push(name);
  console.log('PUSH:', [...callStack]);
};

const leave = () => {
  const done = callStack.pop();
  console.log('POP :', done, '→', [...callStack]);
};

function foo() {
  enter('foo');
  bar();
  leave();
}

function bar() {
  enter('bar');
  baz();
  leave();
}

function baz() {
  enter('baz');
  leave();
}

foo();
console.log('最終的にスタックは空?', callStack.length === 0);

// 出力
// PUSH: ['foo']
// PUSH: ['foo', 'bar']
// PUSH: ['foo', 'bar', 'baz']
// POP : baz → ['foo', 'bar']
// POP : bar → ['foo']
// POP : foo → []
// 最終的にスタックは空? true

配列のログがそのままコールスタックの挙動になります。 まずはこの感覚をつかんでから、非同期でスタックがどう再利用されるかを見ると理解がスムーズにできるかと思います!

このコールスタックに積まれているあいだは常に同期実行です。
ただしsetTimeoutPromiseのコールバックも、キューから順番が回ってきた瞬間に関数としてコールスタックへpushされ、終わればpopされます。
つまり「コールスタック=同期専用」ではなく、「非同期処理も呼び出される瞬間は必ずコールスタックを経由する」と捉えるのが正確です。

この違いを体感するためのサンプルコードも置いておきます。 ぜひログの出力順を予想してみてください!

console.log('A: 同期開始');

setTimeout(() => {
  console.log('C: setTimeout コールバック');
}, 0);

Promise.resolve().then(() => {
  console.log('B: Promise then');
});

console.log('D: 同期終了');

// 出力順
// A: 同期開始        (同期: そのままコールスタック)
// D: 同期終了        (同期: そのままコールスタック)
// B: Promise then    (マイクロタスク: 実行時にコールスタックへpush)
// C: setTimeout コールバック (タスク: 実行時にコールスタックへpush)

呼び出し方は違っても、実行の瞬間はすべてコールスタック上で同期的に処理される点を押さえておきましょう!

2. タスクキュー(Task Queue / Macro Task Queue)

setTimeoutsetIntervalなどのコールバックが入るキューのことです。

タスクキューに入る代表例はタイマー系コールバックです。 流れはシンプルで、マイクロタスク処理のあとにキューの先頭を1件だけ取り出して実行します。

同期コードの実行が終わる
      ↓
マイクロタスクを全部片付ける
      ↓
タスクキューから先頭の1件を取り出して実行
      ↓
再び同期コードへ(次のループ)

という感じです。 コードで確認してみましょう!

console.log('A: 同期');
setTimeout(() => {
  console.log('C: タスクキュー');
}, 0);
console.log('B: 同期');

// 出力
// A: 同期
// B: 同期
// (ここでマイクロタスクがあれば先に実行)
// C: タスクキュー

また、見えない世界のイメージを掴むために、タスクキューを配列で模倣してみます。
セットアップと取り出しのログを見るだけでも「1周で1件ずつ進む」感覚がつかみやすいはずです。

// タスクキューを配列で表現
const taskQueue = [];

const enqueueTask = (label) => {
  taskQueue.push(label);
  console.log('enqueue:', [...taskQueue]);
};

const runNextTask = () => {
  const next = taskQueue.shift();
  if (next) {
    console.log('run   :', next, '残り→', [...taskQueue]);
  } else {
    console.log('run   : (空)');
  }
};

// setTimeout が積まれるイメージ
enqueueTask('setTimeout cb 1');
enqueueTask('setTimeout cb 2');

// 「イベントループ1周で1件だけ取り出す」イメージ
runNextTask(); // setTimeout cb 1
runNextTask(); // setTimeout cb 2
runNextTask(); // (空)

3. マイクロタスクキュー(Microtask Queue)

マイクロタスクキューとはPromiseのthen/catch/finallyのコールバックが入るキューです。

マイクロタスクはタスクより優先され、1周ごとに「キューを全部」処理する点が特徴的です。

同期コードの実行が終わる
      ↓
マイクロタスクキューを全部処理
      ↓
タスクキューから1件実行
      ↓
次のループ

コードでの動きを見てみましょう。

console.log('A: 同期開始');

Promise.resolve().then(() => {
  console.log('B: マイクロタスク1');
}).then(() => {
  console.log('C: マイクロタスク2');
});

setTimeout(() => {
  console.log('E: タスク');
}, 0);

queueMicrotask(() => {
  console.log('D: queueMicrotask');
});

console.log('F: 同期終了');

// 予想出力
// A: 同期開始
// F: 同期終了
// B: マイクロタスク1
// C: マイクロタスク2
// D: queueMicrotask
// E: タスク

また、タスクキューの時と同様に「全部処理する」感覚を掴むために、マイクロタスクキューを配列で模倣してみます。 1周につき全部処理する点がタスクキューとの違いです。

// マイクロタスクキューを配列で表現
const microtasks = [];

const enqueueMicrotask = (label) => {
  microtasks.push(label);
  console.log('enqueue microtask:', [...microtasks]);
};

const runAllMicrotasks = () => {
  while (microtasks.length) {
    const job = microtasks.shift();
    console.log('run microtask    :', job, '残り→', [...microtasks]);
  }
};

// then や queueMicrotask が積まれるイメージ
enqueueMicrotask('Promise then 1');
enqueueMicrotask('Promise then 2');
enqueueMicrotask('queueMicrotask');

// 1周で全部処理するイメージ
runAllMicrotasks();
runAllMicrotasks(); // もう空なので何も動かない

いかがでしたか?
色んなループが出てきてややこしくなってきているかもしれませんがサンプルコードをいじったりして、 流れを把握していくことで深く理解できるかと思います。

イベントループの処理順序

重要なのはマイクロタスクはタスクよりも優先されるという点です!

処理順序:

  1. コールスタックのコードを実行
  2. コールスタックが空になったら、マイクロタスクキューを全て実行
  3. マイクロタスクキューが空になったら、タスクキューから1つ実行
  4. また2に戻る

実例で理解する

ぜひ出力の順番を予想しながらコードを見てください!

console.log('1: 同期処理');

setTimeout(() => {
  console.log('2: タスクキュー');
}, 0);

Promise.resolve().then(() => {
  console.log('3: マイクロタスク1');
}).then(() => {
  console.log('4: マイクロタスク2');
});

Promise.resolve().then(() => {
  console.log('5: マイクロタスク3');
});

console.log('6: 同期処理');

// 出力順序:
// 1: 同期処理
// 6: 同期処理
// 3: マイクロタスク1
// 5: マイクロタスク3
// 4: マイクロタスク2
// 2: タスクキュー

なぜこの順序?

  1. まず同期処理が実行される(1, 6)
  2. コールスタックが空になる
  3. マイクロタスクキューにある全てを実行(3, 5, 4)
  4. タスクキューから1つ実行(2)

デバッグTIP: 順序で迷ったら「今、コールスタックは空か?」「マイクロタスクに何が積まれているか?」だけを順番に追うと必ず答えにたどり着きます。

より複雑な例

ちょっと難易度を上げたものも用意してみました! ちゃんと予想ができるようになったらもうイベントループマスター認定です!

実際の現場でも入り組んだコードに出会うことも多いですし、イベントループを理解していなくて複雑なコードを書いて 思っていた通りに動かないというのもあり得る話ですので、しっかりとイベントループを理解するということは バグを生まないコードを書くためには必須とも言えます!

console.log('start');

setTimeout(() => {
  console.log('timeout1');
  Promise.resolve().then(() => {
    console.log('promise in timeout1');
  });
}, 0);

Promise.resolve().then(() => {
  console.log('promise1');
  setTimeout(() => {
    console.log('timeout in promise1');
  }, 0);
}).then(() => {
  console.log('promise2');
});

console.log('end');

// 出力順序:
// start
// end
// promise1
// promise2
// timeout1
// promise in timeout1
// timeout in promise1

Promiseがマイクロタスクを使う理由

「なぜPromiseはマイクロタスクキューを使うのか?」 こんなこと思いませんでしたか?

その理由は以下の通りと言えます

  • 予測可能性:同期コードの直後に実行されることが保証される
  • パフォーマンス:タスクキューより優先されるため、レスポンスが速い
  • 一貫性:複数のPromiseが連鎖していても、順序が保証される

もう少しかみ砕いてみましょう。

  • 仕様上、PromiseのthenPromise Jobsと呼ばれるマイクロタスクキューに積まれます。コールスタックが空になった瞬間にまとめて処理されるため、「同期処理を一通り終えてからすぐ実行される」ことが決まっています。
  • そのため、同じターンでセットしたsetTimeout(タスクキュー)より確実に先に動きます。描画(ブラウザのレンダリング)よりも前に走るので、画面更新の直前に状態を整えたいケースでも挙動が安定します。
  • チェーンが長くても1ループで全部片付けるので、.then().then().then()の順序が入れ替わる心配がありません。

挙動を以下のコードで確認してみてください!

console.log('A: 同期');

Promise.resolve()
  .then(() => console.log('B: Promise 1'))
  .then(() => console.log('C: Promise 2'));

setTimeout(() => console.log('D: setTimeout'), 0);

console.log('E: 同期終了');
// 予想出力: A → E → B → C → D

setTimeoutqueueMicrotaskに置き換えると、DもマイクロタスクとしてBと同じグループに入り、登録順に応じてBDCのように実行されます。マイクロタスク同士は登録順にすべて処理される、というルールが効いているのが分かります。

一点だけ注意があります。マイクロタスクを無限に追加すると、タスクキュー(タイマーやイベント)がいつまでも処理されず「スターブ(餓死)」してしまいます。

Promise.resolve().then(function loop() {
  queueMicrotask(loop); // 永久にマイクロタスクを追加し続ける
});
// 以降、クリックイベントなどのタスクがほぼ動かなくなる

「優先度が高い=乱用していい」ではないので、重い処理や長いループをマイクロタスクに詰め込みすぎないよう気をつけましょう!

賢くなった気になれる補足: queueMicrotaskとは?

queueMicrotaskは「Promiseを作らずにマイクロタスクを登録したい」ときの標準APIです。実行タイミングはPromise.resolve().then(...)と同じで、コールスタックが空になった直後にキューへ積まれ、登録順にすべて処理されます。

console.log('A');

queueMicrotask(() => console.log('B: queueMicrotask'));
Promise.resolve().then(() => console.log('C: Promise then'));
setTimeout(() => console.log('D: setTimeout'), 0);

console.log('E');
// 予想: A → E → B → C → D(BとCは登録順次第で入れ替わる)
  • メリット:余計なPromiseインスタンスを生成しないので、単なる「あとで(マイクロタスクで)走らせたい」処理に向きます。
  • デメリット:例外を投げるとcatchで拾えないまま「未捕捉例外」として報告されます(Promiseのthenならcatchチェーンで捕捉可)。必要なら自前でtry/catchを入れてください。
  • ネストしすぎると前述の「スターブ問題」を引き起こす点も同じです。

thenableオブジェクトの秘密

Promiseを理解する上で、もう一つ知っておきたいのがthenableオブジェクトです。

thenableとは?

thenメソッドを持つオブジェクトのことを、thenableと呼びます。

実は、Promiseの内部では「Promise型かどうか」ではなく、「thenメソッドを持っているかどうか」で判断しているのです!

以下のコードではthenというメソッドを持つ単なるオブジェクトをresolveに渡していますが...

const thenable = {
  then(resolve, reject) {
    setTimeout(() => {
      resolve('thenableの結果');
    }, 1000);
  }
};

// thenableオブジェクトをresolveに渡す
const promise = new Promise((resolve) => {
  resolve(thenable); // Promiseではなくthenable
});

promise.then((result) => {
  console.log(result); // 1秒後: 'thenableの結果'
});

ちゃんとPromiseは動作しちゃいます。 Promiseかどうかはみていないという証明ですね。

resolveにPromiseを渡すと何が起こる?

resolveにPromiseを渡すと、そのPromiseの解決を待ってから外側のPromiseが解決されます。

これは、Promiseがthenableとして扱われるためです!

以下のコードで動きを確認してみてください。

const innerPromise = new Promise((resolve) => {
  setTimeout(() => {
    resolve('内側のPromise完了');
  }, 1000);
});

const outerPromise = new Promise((resolve) => {
  resolve(innerPromise); // Promiseを渡す
});

outerPromise.then((result) => {
  console.log(result); // 1秒後: '内側のPromise完了'
});

1秒後に「内側のPromise完了」のログが出たかと思います。

awaitがPromise以外でも使える?!

async/awaitについて覚えていますでしょうか?

過去の記事でも触れた基本をさらっと復習してみましょう!

  • asyncを付けた関数は必ずPromiseを返すラッパーになる
  • awaitは「そのPromiseが完了するまで一時停止して、結果か例外を受け取る」
  • 失敗はthrowとして伝わるので、その場でtry...catchを書ける

ここまで読んで「awaitはPromise専用でしょ?」とか思いません?
いかにもPromise専用で作られた感じがしますよね?

でも実はasync/awaitawaitは、Promiseだけを待つわけではないんです!

「え、そうなの?」という感じですが、仕様にちゃんと書いてある正式な挙動でして、awaitthenableオブジェクトなら何でも待てます

一つ前の章で説明したあのthenableオブジェクトです。

早速サンプルコードでawaitがthenableオブジェクトも待ってくれるというのを確認してみましょう!

const thenable = {
  then(resolve) {
    setTimeout(() => {
      resolve('await完了');
    }, 1000);
  }
};

async function test() {
  console.log('開始');
  const result = await thenable; // Promiseではない!
  console.log(result); // 1秒後: 'await完了'
}

test();

どうですか?
本当にawaitthenableオブジェクトなら待ってくれるということに驚きましたか?

ここら辺の機能はライブラリの開発などでオリジナルの非同期処理の実装を組む時などに使われます。

滅多に使わない部分ではあるんですけれども、知っておくといざという時ドヤれますね!

注意: thenableのthen実装が雑だと、思わぬ多重呼び出しや例外伝播の抜けが起きてバグになります。 自作する場合は「一度しか呼ばない」「例外はcatchしてrejectへ渡す」を徹底しましょう。

まとめ

この記事ではPromiseの内部構造について深掘りしました。かなり濃い内容でしたね!

今回学んだこと

  • 内部スロット:JavaScriptから直接アクセスできない内部プロパティ
  • 5つの内部スロット:State, Result, FulfillReactions, RejectReactions, IsHandled
  • インスタンス化の流れ:executorは同期的に即座に実行される
  • 自作Promise:内部の動きをコードで体験
  • マイクロタスクとイベントループ:Promiseのthenがなぜ「後で」実行されるのか
  • thenableオブジェクト:thenメソッドを持つオブジェクトはPromiseのように扱える

理解を深めるために

今回学んだ知識を使って、以下を試してみてください:

✅ 自作Promiseを改良してみる
✅ マイクロタスクの実行順序を予測する練習

次回予告

次回の第4回では、Promiseのエラーハンドリングを完全攻略します!

  • エラーの伝播とスキップの詳細
  • PromiseIsHandledとUnhandled Rejection
  • 複雑なPromiseチェーンの解読
  • async/awaitとの比較
  • よくあるアンチパターン
  • 実践的なエラー処理パターン

複雑なケースも自信を持って扱えるようになるはずです!お楽しみに!

参考リンク

さらに深く学びたい方は、以下の一次情報が役立ちます:


最後まで読んでいただき、ありがとうございました!

内部構造を理解することで、Promiseへの理解が深まったのではないでしょうか?

なかなか難しいところもあるかもしれませんが根気強く理解していきましょう!

テコテックの採用活動について

テコテックでは新卒・中途採用を積極的に行っています。 採用サイトでは会社の雰囲気や福利厚生、募集ポジションをご確認いただけます。 ご興味をお持ちいただけましたら、ぜひチェックしてみてください。 tecotec.co.jp