2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Promise の内部動作を ECMAScript 仕様から読む

2
Posted at

Promise の内部動作を ECMAScript 仕様から読む

はじめに

本記事では Promise の内部動作を ECMAScript 仕様書に基づいて紐解いていきます。

随所に ECMAScript の原文とその該当箇所へのリンクを併記しています。
(※原文を参照・引用する箇所についてはすべて引用ブロック > を用いて明示します)
原文に添えている日本語の文章は日本語訳ではなく、説明に用いやすいように意訳したものです。

【各章について】
1章でコードの実行順という「謎」を提示します。2〜6章で仕様上の部品(内部スロット・各 Abstract Operation)を順に解説し、7章でその部品をすべて組み合わせながら1章のコードを1ステップずつ追います。


目次


1. Promise の実行順序という謎

次のコードを見てください。

console.log('A');

const p = new Promise((resolve) => {
  console.log('B');
  resolve(42);
  console.log('C');
});

p.then((value) => {
  console.log('D:', value);
});

console.log('E');

実行すると出力は次の順になります。

A
B
C
E
D: 42

resolve(42)console.log('C') の前に呼ばれているのに、'D: 42' は最後です。
p.then(...) を登録した時点では Promise はすでに fulfilled 状態なのに、なぜコールバックは即座に呼ばれないのか——これが本記事で解明する謎です。

答えは「.then() のコールバックは 必ずマイクロタスクキューを経由して 実行される」ことにあります。以降の章ではその「必ず」がどこで保証されているかを仕様から確認します。


2. Promise の内部スロット

Promise インスタンスがどんなデータを持っているかを仕様で確認します。

原文:
Instances of Promise have the following internal slots:
[[PromiseState]] — One of pending, fulfilled, or rejected.
[[PromiseResult]] — The value with which the promise has been fulfilled or the reason with which it has been rejected. Only meaningful if [[PromiseState]] is not pending.
[[PromiseFulfillReactions]] — A List of PromiseReaction Records to be processed when/if the promise transitions from the pending state to the fulfilled state.
[[PromiseRejectReactions]] — A List of PromiseReaction Records to be processed when/if the promise transitions from the pending state to the rejected state.
[[PromiseIsHandled]] — A Boolean indicating whether the promise has ever had a fulfillment or rejection handler; used in unhandled rejection tracking.
§27.2.6 Properties of Promise Instances

Promise インスタンスの核心は [[PromiseState]] と 2 本の Reaction リストです。

01_promise_internal_slots.png

[[PromiseState]] が取りうる値は 3 つだけです。

  • pending — 初期状態。まだ決定していない
  • fulfilled — 成功で決定済み。結果は [[PromiseResult]] に入っている
  • rejected — 失敗で決定済み。理由は [[PromiseResult]] に入っている

02_promise_states.png

pending から fulfilled または rejected への遷移は 一方通行かつ一度きりです。一度決定した Promise の状態は変わりません。これを保証する仕組みは 4 章で確認します。

[[PromiseFulfillReactions]][[PromiseRejectReactions]].then().catch() で登録されたコールバックを保持するリストです。Promise が pending のうちは、呼び出されるコールバックが分からないためここに蓄えておき、状態が決定した瞬間にまとめて処理します。Promise がすでに決定済みの場合は、このリストを使わず即座にジョブをキューに積みます(詳細は 5 章)。


3. new Promise(executor) が実行されるとき

new Promise(executor) を呼んだとき、仕様では何が起きているでしょうか。

原文:

  1. If NewTarget is undefined, throw a TypeError exception.
  2. If IsCallable(executor) is false, throw a TypeError exception.
  3. Let promise be ? OrdinaryCreateFromConstructor(NewTarget, "%Promise.prototype%", « [[PromiseState]], [[PromiseResult]], [[PromiseFulfillReactions]], [[PromiseRejectReactions]], [[PromiseIsHandled]] »).
  4. Set promise.[[PromiseState]] to pending.
  5. Set promise.[[PromiseFulfillReactions]] to a new empty List.
  6. Set promise.[[PromiseRejectReactions]] to a new empty List.
  7. Set promise.[[PromiseIsHandled]] to false.
  8. Let resolvingFunctions be CreateResolvingFunctions(promise).
  9. Let completion be Completion(Call(executor, undefined, « resolvingFunctions.[[Resolve]], resolvingFunctions.[[Reject]] »)).
  10. If completion is an abrupt completion, then
    a. Perform ? Call(resolvingFunctions.[[Reject]], undefined, « completion.[[Value]] »).
  11. Return promise.
    §27.2.3.1 Promise ( executor )

処理を整理すると次のようになります。

  1. Promise オブジェクトを生成し、内部スロットを初期化する(ステップ 3〜7)
  2. CreateResolvingFunctions を呼び、resolvereject の関数オブジェクトを作る(ステップ 8)
  3. executor を同期的に呼び出す(ステップ 9)— resolvereject を引数として渡す
  4. executor 内で例外が投げられた場合は 自動で reject を呼ぶ(ステップ 10)

重要なのは ステップ 9 が同期実行 であるという点です。executor の中身(console.log('B')resolve(42))は new Promise(...) の呼び出し中にそのまま走ります。だから 1 章の例で 'B''C' が先に出力されるのです。

CreateResolvingFunctions(§27.2.1.3)が生成する resolve 関数と reject 関数は、それぞれ [[Promise]](紐づいた Promise オブジェクト)と [[AlreadyResolved]](共有のフラグオブジェクト)という内部スロットを持ちます。[[AlreadyResolved]] の役割は次の章で解説します。


4. resolve / reject が呼ばれるとき

resolve(42) が呼ばれると、仕様上は Promise Resolve Function(§27.2.1.3 CreateResolvingFunctions 内)が実行されます。

[[AlreadyResolved]] — 二重呼び出し防止

まず冒頭でこの確認が入ります。

原文:

  1. Let F be the active function object.
  2. Assert: F has a [[Promise]] internal slot whose value is an Object.
  3. Let promise be F.[[Promise]].
  4. Let alreadyResolved be F.[[AlreadyResolved]].
  5. If alreadyResolved.[[Value]] is true, return undefined.
  6. Set alreadyResolved.[[Value]] to true.
    §27.2.1.3 CreateResolvingFunctions

**[[AlreadyResolved]].[[Value]]true なら何もせず即 return**します。そして true でなければ即座に true へ書き換えます(ステップ 6)。

resolvereject は同じ [[AlreadyResolved]] オブジェクトを共有しています。どちらか一方が先に呼ばれた時点でフラグが立つため、その後に resolve を何度呼んでも reject を呼んでも無視されます。Promise の状態が一度きりしか変わらないのはこの仕組みによるものです。

FulfillPromise — 状態遷移と Reactions の処理

[[AlreadyResolved]] のガードを通過した後、resolution の値が thenable(.then を持つオブジェクト)でない場合(※1)は FulfillPromise が呼ばれます。

原文:

  1. Assert: promise.[[PromiseState]] is pending.
  2. Let reactions be promise.[[PromiseFulfillReactions]].
  3. Set promise.[[PromiseResult]] to value.
  4. Set promise.[[PromiseFulfillReactions]] to undefined.
  5. Set promise.[[PromiseRejectReactions]] to undefined.
  6. Set promise.[[PromiseState]] to fulfilled.
  7. Perform TriggerPromiseReactions(reactions, value).
  8. Return unused.
    §27.2.1.4 FulfillPromise ( promise, value )

ここで重要なのはステップの順序です。

  1. まず [[PromiseResult]] に値をセット(ステップ 3)
  2. 両方の Reaction リストを undefined で上書き(ステップ 4, 5)— TriggerPromiseReactions に渡した後は参照を保持する必要がないため解放する
  3. [[PromiseState]]fulfilled に変更(ステップ 6)
  4. 最後に TriggerPromiseReactions を呼ぶ(ステップ 7)

TriggerPromiseReactions は保存されていた Reaction ごとに NewPromiseReactionJob を呼び、各ジョブをマイクロタスクキューに積みます。この処理が 6 章の主題です。

resolution が thenable のとき(例: resolve(anotherPromise))は NewPromiseResolveThenableJob というジョブが介在し、処理が異なります。本記事ではこの経路は扱いません。


5. .then() の登録

.then(onFulfilled, onRejected) の中核は PerformPromiseThen という Abstract Operation です。ステップ 1〜7 で onFulfilled / onRejected から PromiseReaction Record を生成した後、ステップ 8 以降で Promise の状態に応じた分岐が入ります。

原文:(ステップ 8 以降を抜粋)
8. If promise.[[PromiseState]] is pending, then
a. Append fulfillReaction to promise.[[PromiseFulfillReactions]].
b. Append rejectReaction to promise.[[PromiseRejectReactions]].
9. Else if promise.[[PromiseState]] is fulfilled, then
a. Let value be promise.[[PromiseResult]].
b. Let fulfillJob be NewPromiseReactionJob(fulfillReaction, value).
c. Perform HostEnqueuePromiseJob(fulfillJob.[[Job]], fulfillJob.[[Realm]]).
10. Else,
a. Assert: promise.[[PromiseState]] is rejected.
b. Let reason be promise.[[PromiseResult]].
c. If promise.[[PromiseIsHandled]] is false, perform HostPromiseRejectionTracker(promise, "handle").
d. Let rejectJob be NewPromiseReactionJob(rejectReaction, reason).
e. Perform HostEnqueuePromiseJob(rejectJob.[[Job]], rejectJob.[[Realm]]).
§27.2.5.4.1 PerformPromiseThen ( promise, onFulfilled, onRejected [ , resultCapability ] )

Promise の状態によって .then() の振る舞いが分岐します。

Promise の状態 .then() の動作
pending Reaction を [[PromiseFulfillReactions]] / [[PromiseRejectReactions]] に追加するだけ
fulfilled 即座に NewPromiseReactionJobHostEnqueuePromiseJob
rejected 即座に NewPromiseReactionJobHostEnqueuePromiseJob

すでに fulfilled な Promise に .then() を呼んでも、コールバックは即座には実行されません。 HostEnqueuePromiseJob でマイクロタスクキューに積まれ、現在の同期コードが終わってから実行されます。1 章の例で 'E''D: 42' より先に出るのはこのためです。

PromiseReaction Record

Reaction リストに積まれるのは PromiseReaction Record というデータ構造です。

原文:
A PromiseReaction Record is a Record value used to store information about how a promise should react when it becomes resolved or rejected with a given value. PromiseReaction Records have the fields listed in Table 76.
[[Capability]] — The PromiseCapability Record for the promise that is resolved in the reaction. [[Capability]] may be undefined if the reaction handler was not from a call to then.
[[Type]] — One of Fulfill or Reject.
[[Handler]] — The function that should be applied to the incoming value, and whose return value governs what happens to the derived promise. If [[Handler]] is empty, a function that depends on the value of [[Type]] will be used instead.
§27.2.1.2 PromiseReaction Records

03_promise_reaction_record.png

[[Handler]]onFulfilled(または onRejected)のコールバック関数が入ります。[[Capability]].then() が返す新しい Promise を解決・拒否するための参照です。これによって Promise チェーンが実現されます。


6. PromiseReactionJob とマイクロタスクキュー

HostEnqueuePromiseJob に渡される「ジョブ」の正体が NewPromiseReactionJob の戻り値です。

原文:

  1. Let job be a new Job Abstract Closure with no parameters that captures reaction and argument and performs the following steps when called:
    a. Let promiseCapability be reaction.[[Capability]].
    b. Let type be reaction.[[Type]].
    c. Let handler be reaction.[[Handler]].
    d. If handler is empty, then
    i. If type is Fulfill, let handlerResult be NormalCompletion(argument).
    ii. Else, ...
    e. Else, let handlerResult be Completion(HostCallJobCallback(handler, undefined, « argument »)).
    f. If promiseCapability is undefined, return unused.
    g. If handlerResult is an abrupt completion, then
    i. Return ? Call(promiseCapability.[[Reject]], undefined, « handlerResult.[[Value]] »).
    h. Else, return ? Call(promiseCapability.[[Resolve]], undefined, « handlerResult.[[Value]] »).
  2. Let handlerRealm be null.
    ...
  3. Return the Record { [[Job]]: job, [[Realm]]: handlerRealm }.
    §27.2.2.1 NewPromiseReactionJob ( reaction, argument )

**NewPromiseReactionJob が返すのは「関数を呼ぶ準備を閉じ込めたジョブ(Abstract Closure)」**です。この Abstract Closure が実際に呼ばれたとき、はじめてコールバックが実行されます。

HostEnqueuePromiseJob とマイクロタスクキュー

原文:
The host-defined abstract operation HostEnqueuePromiseJob takes arguments job (a Job Abstract Closure) and realm (a Realm Record or null) and returns unused. It schedules job to be performed at some future time. The Abstract Closures used as jobs should be scheduled in FIFO order of when they are scheduled...
§9.5.5 HostEnqueuePromiseJob ( job, realm )

HostEnqueuePromiseJobホスト環境(ブラウザや Node.js)が実装する抽象操作です。仕様は「FIFO で将来のある時点に実行する」とだけ定めており、実行タイミングの詳細はホストに委ねています。ブラウザと Node.js はどちらもこれをマイクロタスクキューに積む形で実装しています。

04_microtask_queue.png

マイクロタスクキューは現在の「タスク」(同期コードの実行 = コールスタックが空になるまで)が終わった直後に全て処理されます。だから .then() のコールバックは必ず同期コードより後に実行されます。

なお、イベントループとタスクキュー・マイクロタスクキューの詳細(setTimeout との優先順位など)は ECMAScript の範囲外であるため、本記事では扱いません。


7. 全体像に戻る — コードを1ステップずつ追う

1 章のコードを仕様の言葉で追いかけます。

console.log('A');                          // (a)

const p = new Promise((resolve) => {       // (b)
  console.log('B');                        // (c)
  resolve(42);                             // (d)
  console.log('C');                        // (e)
});

p.then((value) => {                        // (f)
  console.log('D:', value);               // (g) ← ここが謎
});

console.log('E');                          // (h)

step 0 — 実行開始前

コールスタックにはグローバルコードのみ。マイクロタスクキューは空。

05_ch7_step0_initial.png

step 1 — (a) console.log('A') → 出力: A

通常の同期処理。Promise とは無関係。

step 2 — (b) new Promise(executor) が実行される

Promise コンストラクタ(§27.2.3.1)が走り、p に Promise オブジェクトが生成されます。

  • p.[[PromiseState]] = pending
  • p.[[PromiseFulfillReactions]] = [](空リスト)
  • p.[[PromiseRejectReactions]] = []
  • CreateResolvingFunctions(p) が呼ばれ、resolve 関数([[Promise]]=p, [[AlreadyResolved]].[[Value]]=false)が生成される

そのまま executor同期的に呼ばれます(§27.2.3.1 ステップ 9)。

step 3 — (c) console.log('B') → 出力: B

executor 内の同期処理。

step 4 — (d) resolve(42) が呼ばれる

Promise Resolve Function(§27.2.1.3 CreateResolvingFunctions 内)が実行されます。

  1. [[AlreadyResolved]].[[Value]]false → ガードを通過し、true に書き換え
  2. 42 は thenable ではないため FulfillPromise(p, 42)
  3. FulfillPromise の中で:
    • p.[[PromiseResult]] = 42
    • p.[[PromiseFulfillReactions]] = undefined(まだリストは空)
    • p.[[PromiseState]] = fulfilled
    • TriggerPromiseReactions([], 42) → リストが空なので何もしない

この時点で p は fulfilled 状態ですが、.then() はまだ登録されていません。

06_ch7_step4_resolve_called.png

step 5 — (e) console.log('C') → 出力: C

resolve(42) の後に続く executor 内の同期処理。Promise はすでに fulfilled ですが、このコードは普通に実行されます。

step 6 — (f) p.then(callback) が呼ばれる

PerformPromiseThen(§27.2.5.4.1)が実行されます。

p.[[PromiseState]]fulfilled(pending ではない)ため、ステップ 9 のブランチへ:

  1. value = p.[[PromiseResult]] = 42
  2. fulfillJob = NewPromiseReactionJob(reaction, 42) — コールバックを包んだジョブが作られる
  3. HostEnqueuePromiseJob(fulfillJob.[[Job]], ...)マイクロタスクキューに積まれる

コールバック自体はまだ呼ばれていません。ジョブがキューに入っただけです。

07_ch7_step6_job_enqueued.png

step 7 — (h) console.log('E') → 出力: E

同期コードの最後。コールスタックが空になります。

step 8 — マイクロタスクキューが処理される

コールスタックが空になった直後、ホスト環境がマイクロタスクキューを処理します。

キューに積まれていた PromiseReactionJob が実行され:

  1. handler = callback(value) => { console.log('D:', value); }
  2. HostCallJobCallback(handler, undefined, « 42 ») → コールバックが呼ばれる
  3. → 出力: D: 42

08_ch7_step8_job_executed.png

実行順のまとめ

A  ← (a) 同期
B  ← (c) executor 内・同期
C  ← (e) executor 内・同期(resolve 後でも同期)
E  ← (h) 同期
D: 42  ← (g) マイクロタスク(同期コードが全て終わってから)

resolve が呼ばれた時点でコールバックが実行される」のではなく、「resolve.then() のどちらか後に呼ばれた方のタイミングでジョブがキューに積まれ、同期コード終了後に実行される」というのが仕様の実体です。


8. まとめ

  • Promise の状態管理は [[PromiseState]][[PromiseResult]] の 2 スロットが担い、pendingfulfilled / rejected の一方通行遷移は [[AlreadyResolved]] フラグで保証される
  • .then() のコールバックは直接呼ばれないPerformPromiseThenNewPromiseReactionJob でジョブを生成し、HostEnqueuePromiseJob でマイクロタスクキューに積む
  • .then() 登録時点で Promise がすでに fulfilled / rejected ならジョブは即キューに積まれるが、それでもコールバック実行は同期コード終了後になる
  • HostEnqueuePromiseJob はホスト定義の抽象操作であり、ECMAScript 仕様は「FIFO で将来のある時点に実行する」とだけ規定している。マイクロタスクキューへの積み方はブラウザ・Node.js の実装に委ねられている

参考

2
2
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
2

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?