JavaScriptの非同期処理をじっくり理解する (2) Promise
前回 定義した以下の用語を今回も使います。
1 tick ... タスクキューが1周すること。 1 microtick ... マイクロタスクキューが1周すること。これらの単位は非同期処理の間の相対的な優先順位を決めるものであり、実時間と直接対応するものではありません (1 microtick削減することでパフォーマンスが上がるとは限らない)。
Promises/A+
を先に読むと理解しやすいでしょう。Promises/A+はPromiseの核である
Promise.prototype.then
の動作を規定しています。 (ECMAScriptのほうが規定が詳細であるため、以降はECMAScriptの記述を参照しながら説明します)
Promiseには pending, fulfilled, rejected の3つの状態があり、 pending→fulfilled, pending→rejected の2種類の状態遷移が可能です。 fulfilled と rejected をあわせてsettledと言います (ECMAScriptでの呼称)
これらの状態は同期的な処理と以下のように対応しているとみなせます。
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);
以下の操作を指す。
ハンドラの戻り値 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コンストラクタはコールバックをひとつ取ります。
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;
NewPromiseCapability
と new Promise
の能力は本質的に同じものです。そしてこれらは、コールバック型のAPIからPromiseを作りだすために必要な能力を完全に備えているという意味で普遍的なコンストラクタだと言えます。
振舞いが簡単なのはrejectのほうです。rejectは名前の通り、Promiseをrejected状態に遷移させます。それまでに登録されたthenコールバックがあれば、それらは登録順にエンキューされます。
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される。
CreateResolvingFunctions内で作られる専用オブジェクトで管理されることに注意が必要です。 CreateResolvingFunctionsは resolve
内でThenableを再帰的に解決するときにも呼ばれます。つまり以下のコードで resolve0
と resolve1
は別の関数です。
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の状態遷移図を以下のように描き直すことで理解することができます。
ひとつ前の図では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で、大まかには以下のような処理を行います。
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が呼ばれる。
HostPromiseRejectionTrackerです。
HostPromiseRejectionTrackerは以下を行います。
rejectイベントはマイクロタスクキューの処理が終わるまで遅延される。マイクロタスクがなくなってもまだunhandledなら、unhandledrejectionイベントを発行する。
unhandledrejectionが発行されたPromiseについて、それ以降に当該Promiseがハンドルされた場合はrejectionhandledイベントを発行する。
よって、rejectは同tick内で処理できればエラーとして扱われることはありません。
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 がハンドルされなかったら、警告メッセージを出力する。
unhandledRejection を発火する。
警告メッセージを出力する。
warn-with-error-code
unhandledRejection を発火する。
unhandledRejection がハンドルされなかったら、警告メッセージを出力する。
unhandledRejection がハンドルされなかったら、 process.exitCode
を1にする。
unhandledRejection を発火する。
Node.js 14まではデフォルト用に別のモードが用意されていました。
デフォルト (Node.js 14まで)
unhandledRejection を発火する。
unhandledRejection がハンドルされなかったら、警告メッセージを出力する。
unhandledRejection がハンドルされなかったら、非推奨化メッセージを出力する。 (1度だけ)
「△」はunhandledRejection eventがハンドルされなかった場合に行われる
uncaughtExceptionはデフォルトではexit code 1 でプロセスを終了します。つまりデフォルトの挙動は以下のように説明できます。
Promise rejectを同tick内 (※ここでのtickはI/Oイベントなどのマクロタスクのループにおける1回分を指す) に処理できなかった場合、
Node.js 14まで→ 警告が出る。
Node.js 15以降→ exit code 1でプロセスが中断される。
no-floating-promisesでは以下のようにして警告を抑制できます。ただし、これは警告を抑制しているだけで何も挙動は変えていないことに注意が必要です。
useEffect(() => {
// voidで警告を抑制している (挙動は同じ)
void fetch("https://example.com/logger", { method: "POST" });
}, []);
AsyncContext APIやvm.runInContextを使う。
AsyncContext APIについては2つ後の回で解説予定です。
Wikipediaの記述に任せます。
以降ではJavaScriptのPromiseと関わりの深いものに絞って紹介していきます。
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の発展にあわせて以下のように進化しました。
2010年3月 (1.5.0): Deferredがオリジナルの実装に書き換えられた。
既存のインターフェースは残しつつ。Promises/A のインターフェース (then
) に準拠するようになった。 then
を使った場合はコールバックチェインの構造はPromises/Aと同様になる。
2012年4月 (1.8.0): 元々あったDeferred (dojo/_base/Deferred
) から新しいDeferred (dojo/Deferred
) が切り出された。新しいDeferredは古いインターフェース (addCallback
/ addErrback
等) を削除している。
バージョン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 1.5でDeferred/Promiseが導入されました。 done
/fail
など利用向けAPIを提供するオブジェクトがPromiseで、Promiseに提供側APIを足したものがDeferredです。
then
は done
によるコールバックと fail
によるコールバックを行い、自身を返すAPIでした。Promises/Aと相互運用可能なインターフェースですが、Promises/Aが要求する「新たなPromiseを返す」を満たしていませんでした。
2012年8月 jQuery 1.8で then
がチェイン可能になりました。
2016年6月 jQuery 3.0でコールバックが非同期実行されるなど細かい挙動の修正が行われ、DeferredがPromises/A+互換になりました。
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は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側の設計に引きずられたのかもしれません。
ServerJS (現: CommonJS)でPromiseの規格化を提案しました。
その後Kris KowalはNarwhalの一部としてWaterkenのQを導入し、実験的な利用を開始しました。 (Narwhal内の最新版はここにあります)
この経験をもとにPromiseの設計を改善することがCommonJS内で宣言され、NarwhalのPromiseはkriskowal/qに移されました。その後の設計の変更は以下のように進んでいきます。
2009-03 WaterkenのQがNarwhalに移植された。Narwhalのイベントループを使うように変更された。
2010-02 Promiseチェインを導入し when
を then
にすることで非同期処理が読みやすくする方針が上記スレッドで提案された。
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/B
then
ベースの Promises/A
の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の規定とは、Promiseはthenメソッドを持つことです。もちろんこれだけでは使い物にならないため、thenメソッドには以下の振舞いが規定されています。
第一引数onFulfilledが関数の場合、Promise成功後にこれを1度だけ呼ぶ。
第二引数onRejectedが関数の場合、Promise失敗後にこれを1度だけ呼ぶ。
第三引数onProgressが関数の場合、この関数を使ってPromiseの途中経過を報告してもよい。
thenは新たなPromiseを返す。返されたPromiseはonFulfilled, onRejectedの完了によって成功または失敗する。
提案し、UncommonJSを立ち上げました。こうしてCommonJSからforkされる形で生まれた規格のうちPromiseに相当するのがUncommonJS Thenable Promisesです。これはPromises/AとPromises/Dを統合して記述を改良したものになっています。
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+のGitHubリポジトリでは then
以外の機能についても議論が行われていました。テーマは以下の通りです。
Promiseのキャンセル
AbortSignalで実現されつつある。
Promiseの状態取得
Promiseの途中経過報告
Promiseのコンストラクタ
ECMAScriptの標準Promise策定によって実現した。 (ES2015)
Unhandled Rejections
ECMAScriptの標準Promise策定後、ES2016で実現した。
Microtaskは2012年3月にMutationObserverのためにHTML仕様に追加されました。
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.of
や Promise.flatMap
などのAPIも提案されていたがこの時点で削除されている)
2013年9月の議論でpromises-unwrappingをESのPromiseとして採用すること、PromiseのリリースターゲットをES7 (=ES2016) からES6 (=ES2015) に早めることが決定されています。
そして少なくとも2014年1月時点のDraftには現在の形に近いPromiseが含まれています。 (Promise.cast
はその後削除)
最終的にES2015 (ES6)にPromiseが含まれました。紆余曲折を経て、 new Promise
と Promise.prototype.then
の2つのコアAPIといくつかのユーティリティー関数のみからなり、Promises/A+にも準拠した洗練されたAPIが誕生しました。
Promiseを使うと、コールバックが1度ずつ実行されることを保証できる。また、複数の非同期処理を繋げるときにコールバックを直接登録するよりも書きやすくなる。
JavaScript標準のPromiseは then
メソッドによって標準以外の実装と相互運用できるようになっている。
Promiseに登録されたコールバックはマイクロタスクにエンキューされてから実行される。これによりコールバックとコールバック登録後の処理の前後関係が決まりやすくなり、プログラムの振舞いを制御しやすくなる。
JavaScriptのPromiseはPythonやEの実装に影響を受けつつ、いくつかのコミュニティー仕様を経て最終的にECMAScriptの一部として仕様化されたという経緯がある。この過程で多くのPromise実装が誕生しているが、これらの多くは then
メソッドを提供することによって相互運用できるようになっている。
次回→ async/await
Node.js v15ではunhandled rejectionでプロセスがエラー終了する
PromiseのUnhandled Rejectionを完全に理解する
2021/10/03 公開。
2021/10/03 Promises/A+のUnhandled Rejectionに対する言及でESのバージョンを書いていたのが間違っていたので修正、表現も書き直した。
2021/10/10 「Promiseの並列実行」を追加。
Promise関係のジョブ(タスク)は同じ優先度で処理されますが、Promises/A+やECMAScriptは優先度についてそれ以上の規定を持ちません。WebブラウザやNode.jsではマイクロタスクとしてエンキューされます。Promiseのpolyfillが必要な環境では通常queueMicrotaskも存在しないため、setImmediate相当の処理で代替されるようです。 ↩︎
strictではuncaughtExceptionが先に発火する ↩︎
Discussion
petamoriken
かなり細かい指摘なのですが、Unhandled Rejection が ECMAScript に取り込まれたのは ES2015 ではなく ES2016 ですね。