📞

JavaScriptの非同期処理をじっくり理解する (2) Promise

2021/10/03に公開
3

対象読者と目的

  • 非同期処理の実装方法は知っているが、仕組みを詳しく知らないのでベストプラクティスがわからないときがある
  • 実行順序の保証がよくわからないので自信をもってデプロイできない変更がある
  • より詳しい仕組みを理解することでより計画的な実装をできるようになりたい

という動機で書かれた記事です。同様の課題を抱える人を対象読者として想定しています。

目次

  1. 実行モデルとタスクキュー
  2. Promise
  3. async/await
  4. AbortSignal, Event, Async Context
  5. WHATWG Streams / Node.js Streams (執筆中)
  6. 未定

用語に関する注意

前回定義した以下の用語を今回も使います。

  • 1 tick ... タスクキューが1周すること。
  • 1 microtick ... マイクロタスクキューが1周すること。

これらの単位は非同期処理の間の相対的な優先順位を決めるものであり、実時間と直接対応するものではありません (1 microtick削減することでパフォーマンスが上がるとは限らない)。

Promise

Promiseはコールバックベースだった非同期処理APIに秩序を与えるためのラッパーです。Promiseが解決するのは以下のような問題です。

  • イベントが発火する前でも後でも一様に動作するように処理を記述したい。
    • Promise.prototype.then はイベント発火後の場合は記録済みの値をハンドラに渡すようになっている。
  • コールバック地獄 (コールバックAPIを素朴に使うと操作回数に比例してクロージャのネストが深くなる問題) を回避したい
    • Promise.prototype.then はハンドラが返すPromiseをチェーンするので、メソッドチェーンにすることでネストを回避できる。
// モジュールが読み込み済みの場合でも必ずthenが発火する
import("foo.js").then((module) => console.log(module));

// 非同期処理をチェーンできる
listSomething()
  .then((list) => getSomething(mostRelevantOne(list)))
  .then((detail) => ...);

現在PromiseはECMAScriptの一部として規定されていますが、その前身となったと考えられるPromises/A+を先に読むと理解しやすいでしょう。Promises/A+はPromiseの核である Promise.prototype.then の動作を規定しています。 (ECMAScriptのほうが規定が詳細であるため、以降はECMAScriptの記述を参照しながら説明します)

PromiseLikeとThenable

JavaScriptのPromiseはライブラリの実装として導入されたものが徐々に(コミュニティ標準を経由して)標準化されていったという経緯があります。そのため、世の中には標準の Promise によらずに同様の機能を提供しているものがあります。

// jQueryのPromise風機能
$.ajax("https://example.com/endpoint1.json")
  .done(() => { ... })
  .done(() => { ... });

標準化の過程で、これらのPromise風のオブジェクトは then メソッドを通じて相互運用するようになりました。

// jQueryのPromiseもthenを実装している
$.ajax("https://example.com/endpoint1.json")
  .then(() => { ... })
  .then(() => { ... });

これは以下のようなインターフェースを備えていることが期待されます。

interface PromiseLike<T> {
  then(onFulfilled?: undefined, onRejected?: (e: any) => T | PromiseLike<T>): PromiseLike<T>;
  then<U>(onFulfilled: (value: T) => U | PromiseLike<U>, onRejected?: (e: any) => U | PromiseLike<U>): PromiseLike<U>;
}

しかし、あるオブジェクトがPromiseLikeインターフェースを備えているかどうかを厳密にランタイムで判定するのは不可能です。そこで、判定を緩くしたのが以下のThenableインターフェースです。

// thenプロパティが関数型を持てば、それはThenableである (PromiseLikeと仮定して処理を進める)。
interface Thenable {
  then(...args: any[]): any;
}

Promiseの状態

Promiseには pending, fulfilled, rejected の3つの状態があり、 pending→fulfilled, pending→rejected の2種類の状態遷移が可能です。 fulfilled と rejected をあわせてsettledと言います (ECMAScriptでの呼称)

図: Promiseの状態図

これらの状態は同期的な処理と以下のように対応しているとみなせます。

  • Pending: 関数が実行中
  • Settled: 関数の実行が終了した状態
    • Fulfilled: 関数が正常終了し、値を返した状態
    • Rejected: 関数が例外を投げて終了した状態

Pending状態からfulfilled状態に移行することを「fulfillする」, pending状態からrejected状態に移行することを「rejectする」と言います。この "fulfill" という用語は "resolve" とは区別されているため注意が必要です。resolveはfulfill処理を含みますが、より複雑な処理を行うラッパー関数だからです。

コールバックの登録

Promise.prototype.then の最初の役割は状態に応じてコールバックを処理することです。

  • Pending状態の場合、コールバックを自身に登録します。
    • 登録されたコールバックは、Settled (FulfilledまたはRejected) に遷移するときに登録順にジョブキューにエンキューされます
  • Settled状態 (Fulfilled状態またはRejected状態) の場合、コールバックをジョブキュー (マイクロタスクキュー等) にエンキューします[1]

Promise.prototype.then に渡されたハンドラが必ず非同期的に呼ばれるのは実行順序が変わることによるバグの抑止が大きな理由としてあるようです。

let value = initValue;

promise.then((val) => {
  value = val;
});

// do something with value

以上のことから、以下は実行順序が保証されます。

const promise = Promise.resolve(42);
promise
  .then(() => console.log(2))
  // 2→4は当たり前。Promises/A+的には曖昧だが、ECMAScript上は3がすでにエンキューされているので4がその後に来ることが保証される
  .then(() => console.log(4));
promise
  // thenの登録順に実行されるため、2→3が決まる
  .then(() => console.log(3));
// 2, 3, 4 は全て別マイクロタスクで処理されるため、1より後になる
console.log(1);

チェイニング

2つ目の役割はチェイニングです。 Promise.prototype.then はPromiseを返します。これは then に渡されたハンドラの結果に応じて以下のように解決されます。

  • ハンドラが失敗したら、そのときの例外を用いてrejectされる。
  • ハンドラが成功したら、そのときの戻り値を用いてresolveされる。resolveとは以下の操作を指す
    • ハンドラの戻り値 value がThenableの場合は、 value.then(resolve, reject) が(Promise関連ジョブとしての優先度で) エンキューされる。ただし、
      • resolve, reject は元々の then が返したPromiseを解決するための関数
      • value がThenableとは、 value がオブジェクト(関数を含む)で value.then が関数であることを指す。
      • value.then は一度だけ評価される。
      • value.then の評価に失敗したらrejectされる。
    • value がThenableでない場合は、 value の値でfulfillされる。

なお、ハンドラが成功した場合の処理 (resolve) は new Promise 時に得られるケーパビリティ関数に他なりません。

たとえば、以下はハンドラの戻り値がThenableではない例です。

(async () => { for(const i in [0, 1, 2, 3, 4, 5]) { console.log(`microtick ${i}`); await null; } })();
Promise.resolve(42) // promise A
  .then((x) => x * 2) // callback 1 / promise B
  .then(console.log); // callback 2 / promise C
// Microtick 0: Aがfulfillされる。
// Microtick 1: callback 1が呼ばれる。
// Microtick 1: Bがfulfillされる。
// Microtick 2: callback 2が呼ばれる。

ハンドラの戻り値がPromiseな例。 (PromiseはThenableである)

(async () => { for(const i in [0, 1, 2, 3, 4, 5]) { console.log(`microtick ${i}`); await null; } })();
Promise.resolve(42) // promise A
  .then((x) => Promise.resolve(x * 2))
//            ^^^^^^^^^^^^^^^^^^^ promise D
//      ^^^^^^^^^^^^^^^^^^^^^^^^^ callback 1
//  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ promise B
  .then(console.log); // callback 2 / promise C
// Microtick 0: Aがfulfillされる。
// Microtick 1: callback 1が呼ばれる。
// Microtick 1: Dがfulfillされる。
// Microtick 2: Dに対してthenが呼ばれる。 (このときのコールバックをcallback 3とする)
// Microtick 3: callback 3が呼ばれる。
// Microtick 3: Bがfulfillされる。
// Microtick 4: callback 2が呼ばれる。

ハンドラの戻り値がThenableだが、Promiseではない例。

(async () => { for(const i in [0, 1, 2, 3, 4, 5]) { console.log(`microtick ${i}`); await null; } })();
Promise.resolve(42) // promise A
  .then((x) => ({ then(resolve) { resolve(x * 2) } }))
//                     ^^^^^^^ callback 3
//                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//              ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ thenable D
//      ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ callback 1
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ promise B
  .then(console.log); // callback 2 / promise C
// Microtick 0: Aがfulfillされる。
// Microtick 1: callback 1が呼ばれる。
// Microtick 2: Dに対してthenが呼ばれる。 (このときのコールバックをcallback 3とする)
// Microtick 2: callback 3が呼ばれる。
// Microtick 2: Bがfulfillされる。
// Microtick 3: callback 2が呼ばれる。

Promiseコンストラクタ

Promiseコンストラクタはコールバックをひとつ取ります。

const promise = new Promise((resolve, reject) => {
  // resolve(otherPromise);
  // resolve(value);
  // reject(exception);
});

コールバックを取る形式になっているのはPromiseの設計上本質的なものではありません。PromiseコンストラクタはPromiseインスタンスを返すべきであることと、resolve/rejectが自動的に狭いスコープに閉じ込められるほうが扱いやすいことからこのような設計になっているのではないかと考えられます。実際、ECMAScriptの仕様中では以下に相当する補助ルーチンがよく使われます。

function NewPromiseCapability() {
  // エラーチェックは省略
  const capability = {};
  capability.promise = new Promise((resolve, reject) => {
    capability.resolve = resolve;
    capability.reject = reject;
  });
  return capability;
}

NewPromiseCapabilitynew Promise の能力は本質的に同じものです。そしてこれらは、コールバック型のAPIからPromiseを作りだすために必要な能力を完全に備えているという意味で普遍的なコンストラクタだと言えます。

reject

NewPromiseCapabilitynew Promise を用いてPromiseを作った場合、作った側は将来のいずれかのタイミングで resolve または reject を1度呼び出す必要があります。 (さもなければ、そのPromiseは永遠に解決しないPromiseとなり、これはデッドロックと同じくらい困った結果になります。)

振舞いが簡単なのはrejectのほうです。rejectは名前の通り、Promiseをrejected状態に遷移させます。それまでに登録されたthenコールバックがあれば、それらは登録順にエンキューされます。

resolve

一方resolveはrejectよりも複雑です。これはPromise(を含むThenable全般)を特別扱いするからです。大まかに言うと、

  • Promise(を含むThenable全般) が渡された場合は、その結果を待って最終的にfulfillまたはrejectする。
  • それ以外の場合は、その値でfulfillする。

という処理を行います。より正確には、以下の手順を踏みます。

  • resolveの引数 value がThenableの場合は、 value.then(resolve, reject) が(Promise関連ジョブとしての優先度で) エンキューされる。ただし、
    • resolve, reject はこのPromiseを解決するための関数
    • value がThenableとは、 value がオブジェクト(関数を含む)で value.then が関数であることを指す。
    • value.then は一度だけ評価される。
    • value.then の評価に失敗したらrejectされる。
  • value がThenableでない場合は、 value の値でfulfillされる。

resolve/rejectの回数管理

resolve/rejectはどちらか片方を一度だけ呼ぶことが想定されています。複数回呼んだ場合、最初の呼び出し以外は無視されます。

const promise = new Promise((resolve) => {
  resolve(42);
  resolve(100);
});
promise.then(console.log); // => 42

規格上、この状態管理はPromise本体ではなくCreateResolvingFunctions内で作られる専用オブジェクトで管理されることに注意が必要です。 CreateResolvingFunctionsは resolve 内でThenableを再帰的に解決するときにも呼ばれます。つまり以下のコードで resolve0resolve1 は別の関数です。

const promise = new Promise((resolve0, reject0) => {
  resolve0({
    then(resolve1, reject1) {
      resolve1(42);
    },
  });
});
promise.then(console.log); // => 42

実際、ここでresolve0を使っても何も起きません (resolve0/reject0は消費済みのため)

const promise = new Promise((resolve0, reject0) => {
  resolve0({
    then(resolve1, reject1) {
      resolve0(42);
    },
  });
});
promise.then(console.log); // => pendingのまま進まない

このことはPromiseの状態遷移図を以下のように描き直すことで理解することができます。

図: resolveのThenable対応を考慮したPromiseの状態遷移図

ひとつ前の図ではPromiseの状態遷移を "fulfill" と "reject" の2つで表していましたが、実際には

  • resolveにThenable以外を渡した場合 (=fulfill)
  • resolveにThenableを渡した場合
  • rejectを呼んだ場合

の3つに分かれます。そして真ん中のケースではPromiseはpending状態のままですが、内部的に別のpending状態に切り替わったとみなすことができます。これらに遷移順にpending0, pending1, pending2, ... と名前をつけることにすると、resolve0/reject0はpending0状態からの脱出にのみ使えて、resolve1/reject1はpending1状態からの脱出にのみ使える、という理解をすることができます。

Promise.resolve

new Promise が普遍的なコンストラクタであることは説明しましたが、これらから生成できる補助的なコンストラクタがいくつかあります。特に重要なのがPromise.resolveで、大まかには以下のような処理を行います。

Promise.resolve = function(value) {
  // 真のPromiseインスタンスだった場合は、変換せずそのまま返す
  if (value instanceof this) return value;
  // そうでない場合はresolveする (→Thenableは自動的に解決される)
  return new Promise((resolve) => resolve(value));
};

ポイントは以下の2点です。

  • resolve関数の仕様により、Thenable全般はその結果を返すようなPromiseに変換される。
  • 上記にもかかわらず、Promiseのインスタンスは特別扱いされている。

この特別扱いは、PromiseとPromise以外のThenableでタイミングの違いを生みます。

(async () => { for(const i in [0, 1, 2, 3, 4, 5]) { console.log(`microtick ${i}`); await null; } })();

Promise.resolve(Promise.resolve(42))
  //            ^^^^^^^^^^^^^^^^^^^ Promise A
  //^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Promise B (= Promise A)
  .then(console.log);
  //    ^^^^^^^^^^^ callback 1
Promise.resolve(((pr) => ({ then: pr.then.bind(pr) }))(Promise.resolve(84)))
  //                                                   ^^^^^^^^^^^^^^^^^^^ Promise C
  //            ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Thenable D
  //^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Promise E
  .then(console.log);
  //    ^^^^^^^^^^^ callback 2

// Microtick 0: A (= B) がfulfillされる。
// Microtick 0: Cがfulfillされる。
// Microtick 0: Dに対してthenを行う処理がエンキューされる。
// Microtick 1: callback 1が呼ばれる。
// Microtick 1: Dに対してthenが呼ばれる。 (このときのコールバックをcallback 3とする)
//              このコールバックは実際にはCに登録される。
// Microtick 2: callback 3が呼ばれる。
// Microtick 2: Promise Eがfulfillされる。
// Microtick 3: callback 2が呼ばれる。

Handledフラグ

Promiseには [[PromiseIsHandled]] というフラグがあり、 thenが一度でも呼ばれたかどうか を記録しています。Handledを考慮して状態遷移図を書くと以下のようになります。

図: Handledを考慮した状態遷移図

ここで Rejected (Unhandled) と書かれた状態が重要です。この状態は(同期的な処理でいうところの)例外に相当する状態であるにも関わらず誰もそれをハンドルしていないことを意味します。同期的な処理では誰にもcatchされなかった例外は処理系によって回収され、エラーメッセージを出すなどの対応が取られますから、非同期処理でも同様の対応を取るのが望ましいです。

なお、「thenが一度でも呼ばれたかどうか」が基準なので、以下のようなコードでもハンドルされた扱いになります。

const promise = Promise.reject(new Error("test"));
// promise はhandled扱いになる
promise.then();

しかしこのような場合は promise.then() が生成するPromiseにrejectionが伝搬され、そちらがハンドルされていないので結局抑制はできないということになります。

ただし、非同期処理の場合はthenの登録がrejectイベントよりも後になる可能性があります。その場合は結果としてrejectは適切にハンドルされたことになるため、「ハンドルされるまで一定時間待つ」「あとからハンドルされたらエラーメッセージを取り消す」などの対応をしたい可能性があります。そこで、ECMAScriptの仕様では、 Rejected (Unhandled) 状態になったイベントとその状態から脱出したイベントを発行して、その具体的な処理は処理系に任せるように規定されています。それがHostPromiseRejectionTrackerです。

WebブラウザのHostPromiseRejectionTracker

WebブラウザのHostPromiseRejectionTrackerは以下を行います。

  • rejectイベントはマイクロタスクキューの処理が終わるまで遅延される。マイクロタスクがなくなってもまだunhandledなら、unhandledrejectionイベントを発行する。
  • unhandledrejectionが発行されたPromiseについて、それ以降に当該Promiseがハンドルされた場合はrejectionhandledイベントを発行する。

よって、rejectは同tick内で処理できればエラーとして扱われることはありません。

Node.jsのHostPromiseRejectionTracker

HostPromiseRejectionTrackerに相当する処理はpromiseRejectHandlerで行われています。V8は "reject" / "handle" 相当のイベントのほかに、二重resolveを検出するためのイベントも同じ仕組みで通知できるようになっているようですが、本稿では重要ではないので無視します。

ブラウザ同様、rejectイベントは遅延して処理されます。遅延タイミングもWebブラウザと同様で、マイクロタスクキューの処理後です。このタイミングでunhandled rejectionが残っていたときの処理は --unhandled-rejections 引数 によって異なります。Node.js 16では以下のような挙動になっています。

  • throw (デフォルト)
    • unhandledRejection を発火する。
    • unhandledRejection がハンドルされなかったら、uncaughtException を発火する。 (→デフォルトではexit(1))
  • strict
    • uncaughtException を発火する。 (→デフォルトではexit(1))
    • unhandledRejection を発火する。
    • unhandledRejection がハンドルされなかったら、警告メッセージを出力する。
  • warn
    • unhandledRejection を発火する。
    • 警告メッセージを出力する。
  • warn-with-error-code
    • unhandledRejection を発火する。
    • unhandledRejection がハンドルされなかったら、警告メッセージを出力する。
    • unhandledRejection がハンドルされなかったら、 process.exitCode を1にする。
  • none
    • unhandledRejection を発火する。

Node.js 14まではデフォルト用に別のモードが用意されていました

  • デフォルト (Node.js 14まで)
    • unhandledRejection を発火する。
    • unhandledRejection がハンドルされなかったら、警告メッセージを出力する。
    • unhandledRejection がハンドルされなかったら、非推奨化メッセージを出力する。 (1度だけ)
unhandled
Rejection
uncaught
Exception
警告 非推奨警告 exitCode
throw
strict [2]
warn
warn-with-error-code 1に設定
none
node14-default
  • 「△」はunhandledRejection eventがハンドルされなかった場合に行われる

uncaughtExceptionはデフォルトではexit code 1 でプロセスを終了します。つまりデフォルトの挙動は以下のように説明できます。

  • Promise rejectを同tick内 (※ここでのtickはI/Oイベントなどのマクロタスクのループにおける1回分を指す) に処理できなかった場合、
    • Node.js 14まで→ 警告が出る。
    • Node.js 15以降→ exit code 1でプロセスが中断される。

エラーハンドリングで気をつけるべきパターン

ここまでで説明したように、Promise rejectionの処理に失敗すると (呼び出し元のcatchをすり抜けて) プロセス終了などの意図しない効果を生んでしまう可能性があります。

async/awaitを使っている場合、これを防ぐための原則は、「作ったPromiseは次のawaitで消費する」です。

// fetchで作ったPromiseをすぐに消費している
await fetch("https://example.com/");

この原則に従っている限り、基本的に例外はその関数内で回収されるため問題は起きません。問題になるのは以下のケースです。

// endpoint1のレスポンスを待たずにfallback1を発行する
const promise1 = fetch("https://example.com/endpoint1.json");
const promise2 = fetch("https://example.com/fallback1.json");
await promise1;
await promise2;

この場合 promise2 は次のawaitで消費されていないので、原則に則っていません。実際、もしpromise1のリクエストが完了する前にpromise2のエラーが発生してしまうと、promise2のrejectionがハンドルされません。また、 promise1とpromise2の両方がエラーになった場合も await promise2 がスキップされてしまうため同様の結果が起きます。

この問題は本質的に並列エラーの処理方法の問題なので、実現したい挙動によって正しい実装は異なります。一番わかりやすい方法のひとつは、「他のエラーが存在する場合はpromise2のエラーを無視する」という方法です。これを行うにはpromise2を作った段階でrejectハンドラを登録すればよいです。

const promise1 = fetch("https://example.com/endpoint1.json");
const promise2 = fetch("https://example.com/fallback1.json");
// promise2のエラーをデフォルトで無視する。
promise2.catch(() => {});
await promise1;
// ここにたどり着いたときだけpromise2のエラーがハンドルされる。
await promise2;

別の方法として Promise.race など並列処理用のプリミティブを使う手もあります。

よりシンプルにPromiseを捨てている事例も同様の問題を抱えています。

useEffect(() => {
  // 戻り値のPromiseを破棄している
  fetch("https://example.com/logger", { method: "POST" });
}, []);

これも結局のところ、エラーハンドリングをどのように行いたいかに合わせて選ぶ必要があります。 (上記のReactを想定したコード例はNode.jsで実行されることを想定していないので、unhandled rejectionが起こることを許容するという選択肢もありえます)

typescript-eslintのno-floating-promisesでは以下のようにして警告を抑制できます。ただし、これは警告を抑制しているだけで何も挙動は変えていないことに注意が必要です。

useEffect(() => {
  // voidで警告を抑制している (挙動は同じ)
  void fetch("https://example.com/logger", { method: "POST" });
}, []);

Promiseの並列実行

JavaScriptのPromiseはいちど返されれば呼び出し側で何もしなくても勝手に進むため、Promiseを返す処理を複数呼び出したら自動的に (スレッド並列ではないですが) 並列実行されます。しかし先述の通り、Unhandled Rejectionを避けるために、生成されたPromiseにはすぐにハンドラを登録するのが望ましいです。こういった並列実行特有の問題を自動的に処理してくれるプリミティブが4つ存在します。

Fulfill Reject
Promise.allSettled 全て※ 全て※ ES2020
Promise.all 全て (配列) 最も速いrejection ES2015
Promise.any 最も速いfulfillment 全て (AggregateError) ES2021
Promise.race 最も速いfulfillment 最も速いrejection ES2015

上の表で「最も速い」と書かれている項目は、最速で到達した項目以外の情報を破棄します。たとえば Promise.all でどれか一つがrejectされた場合は、残りのPromiseはfulfillした場合もrejectした場合もその情報は破棄されます。

Promise.all, Promise.any, Promise.race はfulfillをfulfillに対応させ、rejectをrejectに対応させます。一方 Promise.allSettled は全ての結果を省略せずまとめるために PromiseSettledResult (TypeScriptでの呼称) と呼ばれるインターフェースを返します。これは必ずfulfillment valueとして返されます (仮に全てのPromiseがrejectされた場合でも)。

await Promise.all([Promise.resolve(42), Promise.resolve(84)])
// => [42, 84]
await Promise.all([Promise.resolve(42), Promise.reject(new Error("bar"))])
// => Uncaught Error: bar
await Promise.any([Promise.resolve(42), Promise.reject(new Error("bar"))])
// => 42
await Promise.any([Promise.reject(new Error("foo")), Promise.reject(new Error("bar"))])
// => Uncaught AggregateError: All promises were rejected
await Promise.race([Promise.resolve(42), Promise.reject(new Error("bar"))])
// => 42
await Promise.race([Promise.reject(new Error("foo")), Promise.resolve(84)])
// => Uncaught Error: foo
await Promise.allSettled([Promise.resolve(42), Promise.reject(new Error("bar"))])
// => [{ status: 'fulfilled', value: 42 }, { status: 'rejected', reason: new Error("bar") }]
await Promise.allSettled([Promise.reject(new Error("foo")), Promise.reject(new Error("bar"))])
// => [{ status: 'rejected', reason: new Error("foo") }, { status: 'rejected', reason: new Error("bar") }]

スレッドローカル変数

JavaScriptはシングルスレッドのため、グローバル変数をそのままスレッドローカル変数として使えます。try-catch-finallyと組み合わせることで、スコープドなスレッドローカル変数を作ることもできます。

// scoped (sync) thread local
function withContext(context, callback) {
  const oldContext = globalThis.currentContext;
  try {
    return callback();
  } finally {
    globalThis.currentContext = oldContext;
  }
}

これは同期処理では役に立ちますが、非同期処理では役に立ちません。

withContext(42, async () => {
  await null;
  console.log(globalThis.currentContext); // => undefined
});

JavaScriptでは非同期処理に対してスレッドローカル変数に相当する仕組みを実現するのは簡単ではありません。代替策として以下のような選択肢があります。

  • 明示的に引数として持ち回す。
  • ReactなどUIフレームワークが提供するcontextを使う。
  • iframe等を使って新規Realmを立ち上げる。
  • Node.jsのAsyncContext APIvm.runInContextを使う。

AsyncContext APIについては2つ後の回で解説予定です。

Promiseの歴史

Promiseパターンの誕生

Promise/Futureに関する全てを網羅することはできないため、Wikipediaの記述に任せます。

以降ではJavaScriptのPromiseと関わりの深いものに絞って紹介していきます。

Twisted, MochiKit, Dojo

Twistedは2002年にリリースされたPython向けの非同期I/Oライブラリです。Twistedの中核機能のひとつにDeferredがあります。 (その歴史は初期リリースより前の2001年8月まで遡れるようです) (1.0.0付近のコミット)

ECMAScript Promiseのfulfill handlerとreject handlerにあたる概念がcallback, errbackという名前で呼ばれていますが、基本的な構造はPromiseと同じです。

# Deferredを返す側
d = defer.Deferred()
return d
d.callback(42)  # 結果を通知
d.errback(RuntimeError())  # または、エラーを通知

# Deferredを受け取る側
d = someAsyncFunction()
d.addCallback(lambda value: ...)
d.addErrback(lambda err: ...)

Twisted Deferredの(初期版の)追加機能として以下のようなものがあったようです。

  • デフォルトコールバック (別のコールバックを追加すると消える) の設定
  • コールバックを複数登録した場合、前のコールバックの結果が次のコールバックの引数になる
    • ECMAScript Promiseとはコールバックチェインの構造が異なることに注意。
    • コールバックのひとつがDeferredを返した場合は、その解決後に次のコールバックが呼ばれる。

また少し後のバージョンでは以下の機能が追加されています。

  • キャンセル機能
    • 呼び出し元で d.cancel() を呼ぶと、Deferredはキャンセル状態になり後続のコールバックは実行されなくなる。
    • Deferred提供側がキャンセルコールバックを登録することでキャンセル時のクリーンアップを実行することも可能。

MochiKitは2005年にリリースされたJavaScript向けの非同期I/Oライブラリで、Twistedの影響を受けています。 (Async.Deferredのドキュメンテーション, Deferredが追加されたコミット)

// Deferredを返す側
var d = new Deferred();
return d;
d.callback(42); // 結果を通知
d.errback(new Error("")); // または、エラーを通知

// Deferredを受け取る側
var d = someAsyncFunction();
d.addCallback(function (value) { ... });
d.addErrback(function (err) { ... });

MochiKit-0.50時点でのDeferredはTwistedの機能 (キャンセル機能つき) とほぼ同じです。

Dojo Toolkitは2005年にリリースされたAjaxライブラリ群です。2007年 (0.9.0) にMochiKitのDeferredがDojo Toolkitに移植されました。その後Dojo ToolkitはPromiseの発展にあわせて以下のように進化しました。

Node.js Legacy Promises

Node.jsはバージョン0.1.0 (2009年6月) から0.1.30 (2010年2月) までPromiseという名前のAPIが存在していました。

このPromiseはEventEmitterのサブクラスです。命名を見るとDojoのDeferredを意識しているようですが、Deferredのフラットなプロミスチェインは存在せず、いくつcallbackをアタッチしても同じ値が渡されるようです。

// Promiseを返す側
var promise = new events.Promise();
return promise;
promise.emitSuccess(42); // 結果を通知
promise.emitError(new Error(""); // または、エラーを通知

// Promiseを受け取る側
promise.addCallback(function (value) { ... });
promise.addErrback(function (err) { ... });

0.1.30でPromiseが削除されたことで、非同期APIはコールバックを受け取る形に戻されています。Promiseを削除した理由について当時のRyan Dahl氏はよりオーバーヘッドの低いほうを基本のAPIとして露出するべきだと説明しています。氏は2018年にNode.jsについて後悔していることの1つに「Promiseを採用しなかったこと」を挙げています

その後Node.jsバージョン0.11.13 (2014年)でV8の更新によりECMAScriptのPromiseが使えるようになり、以降はPromiseベースのAPIも提供されるようになりました。

jQuery Deferred

  • 2011年1月 jQuery 1.5でDeferred/Promiseが導入されました。 done/fail など利用向けAPIを提供するオブジェクトがPromiseで、Promiseに提供側APIを足したものがDeferredです。
  • 2012年8月 jQuery 1.8then がチェイン可能になりました。
  • 2016年6月 jQuery 3.0でコールバックが非同期実行されるなど細かい挙動の修正が行われ、DeferredがPromises/A+互換になりました。

E

EはJVMで動く分散プログラミング言語で、Wikipediaによると1997年に登場したようです。 (以下Eに関する説明はWikipediaとE in a Walnutに基づく)

EはRPCの結果を受け取るのにPromiseを使うようです。

def promise := obj <- method(args)
# methodの完了を待たずに処理を再開する

when 構文でPromiseのコールバックを登録することができます。

when (e) -> {
  // 完了時の処理
} catch err {
  // 失敗時の処理
}

Wikipediaの出典不明情報によると、Douglas Crockford氏もEに関与していたようです。

Waterken (ref_send) のQ

WaterkenはJava + JavaScriptのWebアプリケーションフレームワークです。 (登場はおそらく2007年頃) 主にTyler Close氏が作っていたようです。(そのTyler Close氏はPromises/Bの議論にも参加しています。)

WaterkenはJoe-Eによる検証が可能な設計になっていると主張しており、Eの影響を受けていると考えられます。

WaterkenのJava側APIには Promise<T> があり、イベントループであるEventualに入れることで現在のECMAScript Promiseに近い形で使えるようになっているようです。 (HPのWebサイトにあった論文 を参考にしました)

Promise<Integer> promise = someAsyncFunction();
Promise<ArrayList<Integer>> anotherPromise = eventual.when(promise, new Do<Integer, Promise<ArrayList<Integer>>>() {
  public Promise<ArrayList<Integer>> fulfill(Integer value) {
    // ...
  }
});

WaterkenのJavaScript側API (web_send) にもそれに対応するQという機能があり、同様のインターフェースで使えたようです。

var promise = someAsyncFunction();
var anotherPromise = Q.when(promise, function fulfill(value) {
  // ...
});

WaterkenのQでは、Promiseはfunctionとして識別されます。この関数は実質的には複数の関数の集まりで、以下のオーバーロードを持ちます。

// 基本処理
promise("WHEN");
promise("WHEN", function fulfill(value) { ... });
promise("WHEN", function fulfill(value) { ... }, function reject(reason) { ... });
// 最新のPromiseを返す
promise();
// ユーティリティー処理
promise("GET", ...); // value[property]
promise("POST", ...); // value[property]()
promise("PUT", ...); // value[property]
promise("DELETE", ...); // delete value[property]

この理由からPromiseは関数に解決することができません。これについて作者のTyler Close氏はPromiseの振舞いを確実にカプセル化するためと説明しています。このような要件が追加された背景としてWaterkenはJoe-Eにより検証されており、Object-capability modelの影響を受けていると考えられます。やがてES5で変更不可能プロパティが作れるようになったことでこの設計根拠は突き崩されることになっていきますが、そもそもJavaScriptコミュニティーでObject-capability modelを必要とする人が多くなかったためか、その後のPromiseでは完全なカプセル化は必ずしも求められなくなっています。

また、null, undefined, NaNに解決するPromiseも明示的に禁止されています。

またWaterkenのQはイベントループとイベントキューを明示的に保持しています (setTimeout(..., 0) で処理系のイベントループに埋め込まれます)。正確な理由は不明ですが、Java側の設計に引きずられたのかもしれません。

Kris KowalのQ

2009年3月、Kris Kowal氏はServerJS (現: CommonJS)でPromiseの規格化を提案しました

その後Kris KowalはNarwhalの一部としてWaterkenのQを導入し、実験的な利用を開始しました。 (Narwhal内の最新版はここにあります)

この経験をもとにPromiseの設計を改善することがCommonJS内で宣言され、NarwhalのPromiseはkriskowal/qに移されました。その後の設計の変更は以下のように進んでいきます。

  • 2009-03 WaterkenのQがNarwhalに移植された。Narwhalのイベントループを使うように変更された。
  • 2010-02 Promiseチェインを導入し whenthen にすることで非同期処理が読みやすくする方針が上記スレッドで提案された。
  • 2010-02 大規模なリファクタリング
    • NaNチェックが外された。 defined で明示しない限りnull, undefinedが許可されるようになった。
  • 2010-03 Promiseが関数ではなく Promise のインスタンスとして識別されるようになった。
  • 2010-09 280north/narwhalからkriskowal/qに切り出された。
  • 2010-10 Node.jsで process.nextTick を使うように変更された。
  • 2010-12 Promiseがduck type化された。この時点ではPromiseとは I look like a promise, I quack like a promise. という名前のメソッドを持つオブジェクトとして定義されている。
  • 2011-01 Promiseが持つべきメソッドの名前が promiseSend になった。 (→Promises/D への移行)
  • 2011-01 then がサポートされた。 (Promises/A への合流)

WaterkenのQとの違いがドキュメントにまとめられています。また./designでPromiseの設計原理がインクリメンタルに説明されており、一見の価値があります。

Promises/A, Promises/B, Promises/D

CommonJS内では、Qを元にした when ベースのAPIと、その後発案された then ベースのAPIの2つの提案のどちらが良いか決めかねている状態だったようです。そこで

の2つの提案仕様を書き出した上で議論が進められました。さらに、 when / then の対立とは別に、Promiseオブジェクトの特徴づけに関する対立する議論が生じました

  • 不透明 (Opaque) Promise 派 ... PromiseクラスのインスタンスがPromiseである。
  • Duck Typing 派 ... 所定のプロパティを持つオブジェクトがPromiseである。

そこで、 Proises/BをDuck Typingで再定義したPromises/Dがさらに追加されました。

(またこれとは別にFuturesJS内容を仕様化したPromises/KISSというのもあったようですが省略します)

結果的に、シンプルで高い相互運用性を持つPromises/Aが広く普及し、 Promises/BやPromises/Dの主要な実装であったQも2011年1月にPromises/Aに対応しました。これでPromise戦争は幕を閉じたと言えるでしょう。

Promises/A

そのPromises/Aの規定とは、Promiseはthenメソッドを持つことです。もちろんこれだけでは使い物にならないため、thenメソッドには以下の振舞いが規定されています。

  • 第一引数onFulfilledが関数の場合、Promise成功後にこれを1度だけ呼ぶ。
  • 第二引数onRejectedが関数の場合、Promise失敗後にこれを1度だけ呼ぶ。
  • 第三引数onProgressが関数の場合、この関数を使ってPromiseの途中経過を報告してもよい。
  • thenは新たなPromiseを返す。返されたPromiseはonFulfilled, onRejectedの完了によって成功または失敗する。

UncommonJS Thenable Promises

2011年6月にKris Kowal氏はCommonJSの議論の場を試験的にGitHubに移すことを提案しUncommonJSを立ち上げました。こうしてCommonJSからforkされる形で生まれた規格のうちPromiseに相当するのがUncommonJS Thenable Promisesです。これはPromises/AとPromises/Dを統合して記述を改良したものになっています。

Promises/A+

2012年10月、Brian Cavalier氏がUncommonJS Thenable Promiseを踏まえ、Promises/Aの改良を提案しました。議論の場がGitHubに移されPromises/A+として公開されるようになりました。

仕様の本文は2013年6月の変更を最後にほぼ変わっていません。

Promises/A+の資料にはPromises/AとPromises/A+の違いの説明も含まれています。特筆すべき点は2つあります。ひとつはonFulfilled/onRejectedがThenable (Promise) を返した場合の振舞いが明示されたことです。

promise
  // someAsyncFunctionがPromiseを返した場合
  .then((x) => someAsyncFunction(x))
  // Promiseの結果を待ってから次のthenが実行される
  .then((y) => y + 1)

もうひとつはコールバックが非同期的に呼ばれることです。

// 必ず (すでにpromiseが解決済みでも) earlier → later の順に実行される
promise
  .then(() => console.log("later"));
console.log("earlier");
promise1
  .then(() => console.log("later"));

// 必ず (すでにpromiseが解決済みでも) earlier → later の順に実行される
{
  someActionToResolvePromise1(); // ここでpromise1が解決されるとする
  console.log("earlier");
}

この時点では、コールバックを非同期的に処理するためにどのような仕組みを使うかは規定されていません。マイクロタスク相当の仕組みでエンキューしても、タスク相当の仕組みでエンキューしてもOKでした。

Promises/A+ その他の議論

Promises/A+のGitHubリポジトリでは then 以外の機能についても議論が行われていました。テーマは以下の通りです。

process.nextTick, Microtask

process.nextTickは2010年1月にNode.jsに追加されました

Microtaskは2012年3月にMutationObserverのためにHTML仕様に追加されました

ES6 Promise

Promises/A+と並行して、2012年11月にes-discuss MLでPromiseの標準化に関する議論が発生しました。これはTC39の第31回ミーティングでも取り上げられ、ES7 (=ES2016) への導入を目指すことが検討されました。

またGitHub側にある2012年11月の議事録を見ると、同時期に現在のasync/awaitにあたる機能提案があり、async/awaitの標準化のためにはTask object (=Promise) の標準化が必要だとも言及されています。

これを受けてか2012年12月にDOM Promises 提案が作られ、W3Cでも議論が行われたようです。

2013年5月にPromises vs. Monadsという発表 (スライド) が行われました (これを行ったMark S. Miller氏はEの作者です)。ここでPromiseのチェイニングのための基本APIの組み合わせとしてUP, QP, AP, AP2, AP3という亜種が提示されました。

これを踏まえ、2013年8月にはPromises/A+のメンバーでもあるDomenic Denicola氏がpromises-unwrapping提案 (→ECMAScript向け) の執筆を開始しています。promises-unwrappingは上記スライドのAP2をもとにして作られています。(途中までは Promise.ofPromise.flatMap などのAPIも提案されていたがこの時点で削除されている)

2013年9月の議論でpromises-unwrappingをESのPromiseとして採用すること、PromiseのリリースターゲットをES7 (=ES2016) からES6 (=ES2015) に早めることが決定されています。

そして少なくとも2014年1月時点のDraftには現在の形に近いPromiseが含まれています。 (Promise.cast はその後削除)

最終的にES2015 (ES6)にPromiseが含まれました。紆余曲折を経て、 new PromisePromise.prototype.then の2つのコアAPIといくつかのユーティリティー関数のみからなり、Promises/A+にも準拠した洗練されたAPIが誕生しました。

まとめ

  • Promiseを使うと、コールバックが1度ずつ実行されることを保証できる。また、複数の非同期処理を繋げるときにコールバックを直接登録するよりも書きやすくなる。
  • JavaScript標準のPromiseは then メソッドによって標準以外の実装と相互運用できるようになっている。
  • Promiseに登録されたコールバックはマイクロタスクにエンキューされてから実行される。これによりコールバックとコールバック登録後の処理の前後関係が決まりやすくなり、プログラムの振舞いを制御しやすくなる。
  • JavaScriptのPromiseはPythonやEの実装に影響を受けつつ、いくつかのコミュニティー仕様を経て最終的にECMAScriptの一部として仕様化されたという経緯がある。この過程で多くのPromise実装が誕生しているが、これらの多くは then メソッドを提供することによって相互運用できるようになっている。

次回→ async/await

関連資料

更新履歴

  • 2021/10/03 公開。
  • 2021/10/03 Promises/A+のUnhandled Rejectionに対する言及でESのバージョンを書いていたのが間違っていたので修正、表現も書き直した。
  • 2021/10/10 「Promiseの並列実行」を追加。
脚注
  1. Promise関係のジョブ(タスク)は同じ優先度で処理されますが、Promises/A+やECMAScriptは優先度についてそれ以上の規定を持ちません。WebブラウザやNode.jsではマイクロタスクとしてエンキューされます。Promiseのpolyfillが必要な環境では通常queueMicrotaskも存在しないため、setImmediate相当の処理で代替されるようです。 ↩︎

  2. strictではuncaughtExceptionが先に発火する ↩︎

Discussion

Masaki HaraMasaki Hara

かなり細かい指摘なのですが、Unhandled Rejection が ECMAScript に取り込まれたのは ES2015 ではなく ES2016 ですね。

ありがとうございます! 直しておきます。

mizchimizchi

resolve/rejectが自動的に狭いスコープに閉じ込められるほうが扱いやすいことからこのような設計になっているのではないか

初期に普及した jQuery.Deferred や、Bluebird の非標準の Promise.defer がまさにこの Deferred パターンでしたが、Bluebird の方はメモリリークを誘発するという理由で非推奨になっています。

Deprecated APIs | bluebird

Bluebird のフォーラムに良い議論があったのでリンクを置いておきます。(注意: 2016年です)

Why is Promise.defer() deprecated and .done() discouraged?

(うろ覚えですがPromise の仕様を策定した domenic もそういう話をしていた気がします)

個人的にはメソッドの外にresolve/rejectを持ち越すのはプリミティブなコントロールフローとして稀に実装しますが(非同期キューの管理など)、基本的に避けるようにしています。