Promise の登場で面倒な非同期処理がとても簡潔に扱えるようになりました。それでも、やはり非同期処理には違いないので、ややこしい部分もあります。そんなとき、筆者は簡単なコードを書いて、実際に試してみることにしています。
以下は、筆者が自分のために作ったコードレットをクイズにしたものです。Promise が分かりにくいと思う方は、トライしてみてください。
なお、検証した環境は、Node.js が v17.2.0、OS が Debian Linux v11.1 です。
予備知識: Promise オブジェクトの状態
ここで、Promiseオブジェクトの状態をまとめておきます1。
状態 | 内容 |
---|---|
待機中(pending) | 初期状態。非同期関数は実行中 |
成功(fulfilled) | 非同期関数の処理が成功した |
失敗(rejected) | 非同期関数の処理が失敗した。catchで処理する |
第1問 Promise化
JavaScriptの既存の非同期関数を Promise オブジェクトを返す関数として再定義することを Promise 化(Promisification)といいます。ここで、setTimeout を Promise 化してみます2。
リスト1は、2秒(2000ミリ秒)待って経過したミリ秒数を出力するコードです。正しければ、2000+α の値が出力されるはずです。ところが、出力(「----」以下)は1ミリ秒になってしまいます3。間違いを探してください
01: function wait(msec) {
02: return new Promise(resolve =>
03: setTimeout(resolve(), msec));
04: }
05: const start = new Date();
06: wait(2000)
07: .then( console.log('elapsed =', new Date() - start) );
----
elapsed = 1
解答
実は間違いは2箇所あります。
1つ目は3行目。setTimeoutの第1引数「resolve()」です。setTimeoutの第1引数は関数オブジェクトです。setTimeoutは第2引数の時間だけ待った後、第1引数の関数を実行します。「resolve()」では、setTimeoutの呼び出しを待たずに、先にresolve()を実行してしまいます。そのため、PromiseはsetTimeoutを待つことなく、次のthen()の処理に移ります。関数オブジェクトである「resolve」でなければなりません。
2つ目は7行目。thenの第1引数も関数オブジェクトでなければなりません。Promiseは前段の完了後(ここではresolveの実行後)にthenに与えられた関数を実行します。アロー関数を使えば、最小限の変更で関数オブジェクトが実現できます。従って、正解はリスト2です。
01: function wait(msec) {
02: return new Promise(resolve =>
03: setTimeout(resolve, msec));
04: }
05: const start = new Date();
06: wait(2000)
07: .then( () => console.log('elapsed =', new Date() - start) );
----
elapsed = 2003
なお、リスト2では resolve が値を返しませんが、もし値を返したい場合は、リスト3のようにアロー関数4にする必要があります。
03: setTimeout(() => resolve(返り値), msec));
リスト2の wait 関数は、Promiseを返す非同期関数の典型的な例ですので、フローが分かりにくいときにこれを使って実験してみると良いと思います。次の問題でも wait 関数を使います(プログラムリスト中では省略します)。
第2問 同期処理
2個の非同期関数を直列に(同期的に)実行したいと思って、リスト4のようなコードを書きました。出力は何秒になるでしょうか。
05: const start = new Date();
06: wait(1000)
07: .then( wait(2000) )
08: .then( () => console.log('elapsed =', new Date() - start));
解答
答えは1秒です。本来は3秒になってほしいのですが、7行目の then() に問題があります。
wait 自身が Promise オブジェクトを返す関数であるためややこしいのですが、このままでは then 内部の wait 関数は非同期に実行されてしまいます。
同期的に実行したい場合は、thenの引数を「Promiseオブジェクトを返す関数オブジェクト」にする必要があります(リスト5)。なお、then() の連鎖については、第6問を参照してください。
05: const start = new Date();
06: wait(1000)
07: .then( () => wait(2000) )
08: .then( () => console.log('elapsed =', new Date() - start));
----
elapsed = 3005
ただし、単に同期的に実行したいだけなら、await を使う方がわかりやすいでしょう。このとき、async キーワードを付けた関数内部でないと await が使えないことに注意が必要です。
05: async function waiting() {
06: const start = new Date();
07: await wait(1000);
08: await wait(2000);
09: console.log('elapsed =', new Date() - start);
10: }
11: waiting();
第3問 async 関数の戻り値
リスト6のように、async/await を使うと then がなくても Promise を処理できます。では、async を付けた関数の返り値はどうなるでしょうか。リスト7の出力を考えてください。
05: async function waiting() {
06: await wait(1000);
07: return 1;
08: }
09: const r = waiting();
10: console.log(r);
解答
返り値は、次のように pending 状態の Promise オブジェクトになります。「1」ではないことに注意してください。
Promise { <pending> }
もし、リスト7の11行目に次のコードを加えると、
11: r.then( () => console.log(r) );
出力は次のようになります。arync を付けた関数の返り値が Promise オブジェクトであるために、then が使えるわけです。
Promise { <pending> }
Promise { 1 }
第4問 非同期処理の並列実行
複数の非同期処理を並列させたとき、すべての処理が終了するのを待ってから次に進みたいときがあります。このときには、Promise.all を使用します(リスト8)。
05: const start = new Date();
06: const a = [wait(1000), wait(2000), wait(3000)] // 3個の非同期処理
07: Promise.all(a)
08: .then( () => console.log('elapsed =', new Date() - start));
しかし、リスト7において、もし3個の非同期処理のうち1個が失敗した(rejected 状態の Promise を戻す)場合(リスト9)には、どうなるでしょう。
06: const a = [wait(1000), Promise.reject(), wait(3000)] // 1個の処理が失敗する
このとき Promise.all は、実行中の処理を待たずに即座に失敗(rejected 状態の Promise オブジェクトを返す)してしまいます。そうではなく、仮にどれかの処理が失敗しても、残りの処理が終了するのを待つにはどうすればよいでしょうか。
解答
この場合には、Promise.all のかわりに Promise.allSettled を使います。
05: const start = new Date();
06: const a = [wait(1000), Promise.reject(), wait(3000)] // 1個の処理が失敗する
07: Promise.allSettled(a)
08: .then( () => console.log('elapsed =', new Date() - start));
Promise.allSettled は、仮にどれかの処理が失敗しても無視し、それ以外のすべての処理が成功するまで待ちます。ちなみに「settled」とは、「pending」でない状態、すなわち、「fulfilled」または「rejected」の状態の総称です。
第5問 最速の処理を待つ
リスト7の Promise.all は、すべての処理が成功して終了するのを待ちます。では、最初に成功する処理だけを待ちたい場合(早いもの勝ち)、どうすればよいでしょうか。
解答
Promise.race を使います。ただし、Promise.race も Promise.all と同様に、どれかの処理が先に失敗してしまうと自身も失敗してしまいます。それを避けて、最初に成功する処理だけを待つためには、Promise.any を使います。
07: Promise.race(a) // 最初に「終了」する処理を待つ
または
07: Promise.any(a) // 最初に「成功」する処理を待つ
第6問 then() の連鎖
最後は遊びです。
リスト5では、then() を連鎖しています。こんなことできるのも、then() 自身が、Promise オブジェクトを返す関数だからです。このことを使って、フィボナッチ数列
f_{i + 2} = f_i + f_{i + 1}
を求めるコード(何の役にも立ちませんが)を作ってみます。リスト11の7行目に何を入れればいいでしょうか。add() を再帰的に実行したいのです。Promise なんか使わずに「ar = add(ar)」でいいじゃないか、などという見も蓋もないことは言わないでお願いします。
01: function add(ar) {
02: console.log(ar[0])
03: return [ar[1], ar[0] + ar[1]];
04: }
05: let a = Promise.resolve([1, 1]);
06: for(let i=0; i<10; i++) {
07: ...
08: }
----
1
1
2
3
5
8
13
21
34
55
解答
07: a = a.then( add );
then() が Promise を返す関数ですから、それをもとの変数に代入すれば再帰的実行ができます。
あとがき
もう少しちゃんとしたクイズにしたかったという悔いが残ります。catch() の例なんかも入れたかったのですが。暇があれば追加・修正したいと思っています。
-
MDN のドキュメント中の表を参考にしましたが、日本語訳(状態の名前を含む)は公式ページと異なります ↩
-
Node.js の v16 より Promise 版のsetTimeout(timersPromises.setTimeout)が導入されました。リスト1の wait 関数と同じ機能です ↩
-
実際の出力はシステムによって違いますが、せいぜい数ミリ秒です ↩
-
言うまでもないことですが、アロー関数でなく、通常の funnction 文による関数定義でもかまいません ↩