JavaScript: 非同期処理 - Promise, async / await

JavaScript での非同期処理は、ジョブキューにコールバック関数を登録し、それを適宜呼び出してもらう。シングルスレッドでの動作が基本になる。

複数スレッドなどの物理的に同時に走る並列 parallel 処理ではない。並行 concurrent 処理だ。

巷では Promise の書き方を簡単にしたのが async / await のように解説されていることも多いが、多くの場合誤りか、そうでなくても誤解しやすい。

非同期処理のコールバック関数

単純なコールバック関数の例. setTimeout() はすぐ戻る. 時間が経過したらコールバック関数が呼び出される.

JavaScript
[RAW]
  1. setTimeout(function() {
  2. console.log('2. Hello');
  3. }, 100); // 0.1秒
  4. console.log('1. setTimeout() returned.');

実行結果;

1. setTimeout() returned.
2. Hello  

まず setTimeout() の次の文が実行され、後からコールバック関数が呼び出されている。

イベントハンドラを登録する例。状態が変化したら、コールバック関数が呼び出される。

JavaScript
[RAW]
  1. const xhr = new XMLHttpRequest();
  2. // open(method, url, async, user, password)
  3. xhr.open('GET', '/hoge', true);
  4. xhr.onload = function() {
  5. console.log('2. onload called.');
  6. if ( this.status < 200 || this.status > 299)
  7. console.log(`Status error: ${this.status} ${this.statusText}`);
  8. };
  9. // try-catch block doesn't work for asynchronous.
  10. // ネットワークエラー以上のことは分からない.
  11. xhr.onerror = function(err) { // ProgressEvent
  12. console.log('Error: ', err);
  13. }
  14. xhr.send(null);
  15. console.log('1. send() returned.');

実行結果;

1. send() returned.
2. onload called.

send() はすぐ戻る. リソースの fetch に成功したり失敗したときにコールバックされる。

JavaScript の制限として, 非同期処理は try-catch で例外を捕捉できない。そこで、戻り値でエラーかどうか示すのがよく採られる。次は, Node.js でのファイル読み込みの例;

JavaScript
[RAW]
  1. const fs = require('fs');
  2. fs.readFile('hoge.txt', 'utf-8', (err, data) => {
  3. // エラー処理
  4. if (err) { // err is an Error object.
  5. // [Error: ENOENT: no such file or directory, open 'hoge.txt']
  6. console.log(err);
  7. return;
  8. }
  9. console.log('2. Body: ', data);
  10. });
  11. console.log('1. readFile() returned.');
実行結果
1. readFile() returned.
2. Body:  fugaあああ

同様に, readFile() はすぐ戻る。読み込みが完了したりあるいはエラーになったら、コールバックされる。

Promiseの使い方 (消費側)

非同期処理・エラー状態・正常データをセットで標準化したものです。Promise は "非同期" 処理で使われるのが前提です。

Promise オブジェクトを返す関数

例えば, fetch() 関数は URL で指し示されたデータを取得します。この関数は、呼び出すと直ちに Promise オブジェクトを返します。

JavaScript
[RAW]
  1. const p = fetch("/fuga"); // fetch() は直ちに Promise<Response>を返す.
  2. p.then( (response) => {
  3. // 正常データ
  4. console.log('2. .then() callback called.');
  5. if ( response.status < 200 || response.status > 299 )
  6. console.log(`Status error: ${response.status} ${response.statusText}`);
  7. }, (error) => {
  8. // エラー状態
  9. // ネットワークエラーや CORS ポリシー違反も TypeError が投げられる.
  10. console.log('Error: ', error); // TypeError: Failed to fetch
  11. });
  12. console.log('1. fetch() returned.');
実行結果.
1. fetch() returned.
2. .then() callback called.
Status error: 404 Not Found

Promise オブジェクトの .then() でコールバック関数 (handler) を登録します。ここはあくまで登録であって、非同期処理を待ち受けるわけではありません。巷の解説ではしばしば待ち受けるかのように誤解しやすいですが、この区別は重要です。

非同期処理が完了したかエラー状態になったときに, .then() に与えたコールバック関数 (handler) が実行されます。

正常データとエラー状態のセットを返す方法の標準化としては, Haskell の Either モナドに近いです。 ハンドラの戻り値が Promise で wrap ラップされる、とかいう解説が多いですが、これも正しくありません。Promise が wrap するのは, "pending" か実行済みかという状態であって、ハンドラの戻り値ではありません

正常状態とエラー (例外) 状態

Promise オブジェクトへのハンドラの登録は、次の3種類があります。

.then( onFulfilled )
1引数版
.then( onFulfilled, onRejected )
2引数版
.catch( onRejected )

onFulfilled, onRejected はコールバック関数です。それぞれ正常状態、エラー状態になったときに (のみ) 呼び出されます。.then(), .catch() とも、直ちに, 新しい Promise を返します。さらに後続のハンドラを登録できます。

JavaScript では, 非同期処理の例外を同期側から try-catch ブロックで捕捉することはできません。プロミスを使う非同期処理では, 例外が発生すると, 後続の .then() 2引数版か .catch() に与えたハンドラが呼び出されます。

JavaScript
[RAW]
  1. require('./fake_fetch');
  2. fakeFetch('fuga')
  3. .then( res => {
  4. console.log('.then() callback'); // ここは通らない.
  5. })
  6. .catch( error => {
  7. console.log('.catch(): ', error); // Error: Something wrong: fuga
  8. });
  9. fakeFetch('/success')
  10. .then( res => {
  11. // 例外を投げると, 後続の最初の .catch() のコールバック関数が呼び出され
  12. // る.
  13. throw new TypeError("hoge");
  14. })
  15. .catch( error => {
  16. console.log('.catch(): ', error) // TypeError: hoge
  17. });
  18. // .catch() を書き忘れる => 'unhandledrejection' イベントが発生する
  19. // デフォルトの挙動は実装依存, か. 握りつぶされるかも。
  20. console.log('1. Here!');
  21. // この後, どちらが先に表示されるかは, 先に処理が完了したほうになって, 一意では
  22. // ない

実行結果

$ node --unhandled-rejections=strict chain2.js
1. Here!
.catch():  TypeError: hoge
    at /home/hori/src_local/js/promise/chain2.js:17:15
.catch():  Error: Something wrong: fuga
    at Timeout._onTimeout (/home/hori/src_local/js/promise/fake_fetch.js:26:24)
    at listOnTimeout (internal/timers.js:549:17)
    at processTimers (internal/timers.js:492:7)

たまたま後ろのエラーのほうが先に完了したため、先に実行されている。

.then() または .catch() のハンドラの戻り値は、プロミスである必要はありません。通常の値か例外送出のどちらかが基本です。

プロミスを返した場合は, 特別に, その戻り値のプロミスの "状態" に応じて, 後続のハンドラの呼び出しが決まります。値の wrap を剥がすのではない.

メソッドチェイン: 処理の順序を指定

メソッドチェインで, 処理の「順序」を指示することができます。

.then() (1引数版) は正常時のみ, .catch() はエラー状態時のみ処理が実行され、それぞれ該当しない時は skip されます。分岐 (decision) は、下表の fork で、片方を .then(), 他方を .catch() にすればよい。

順列
順番に実行する
p1 = プロミスを返す関数()
p2 = p1.then(...)  // 2引数版だと、正常でもエラーでも処理を実行.
p3 = p2.then(...)
並列 (fork)
複数の処理へ並列に枝分かれする
p1 = プロミスを返す関数()
p2 = p1.then(...)
p3 = p1.then(...) // 複数の .then() を接続してよい
Join
複数の処理のすべてが到達したら、次へ進む
p1 = プロミスを返す関数()
p2 = p1.then(...)
p3 = p1.then(...) // fork
p4 = Promise.all([p2, p3])
併合 (merge)
複数の処理のどれかが到達したら、次へ進む
p1 = プロミスを返す関数()
p2 = p1.then(...)
p3 = p1.then(...) // fork
p4 = Promise.race([p2, p3])

次の例は, .then() を二つ繋げます。fakeFetch()fetch() に似せた関数です。定義は後述しますが、今は本題ではありません。

JavaScript
[RAW]
  1. require('./fake_fetch');
  2. // すぐ戻る.
  3. const p1 = fakeFetch("hoge/success");
  4. // .then() も、コールバック関数を登録するだけで, すぐ戻る
  5. const p2 = p1.then( res => {
  6. console.log('2. p1.then(): ', res);
  7. // 何か値を return すると、後続の .then() に渡される.
  8. // 何も return しないときの function の戻り値は undefined.
  9. // => これも, 中断ではなく, 後続に渡される.
  10. return 1;
  11. });
  12. const p3 = p2.then( x => {
  13. console.log('3. p2.then(): ', x); //=> 1
  14. });
  15. console.log('1. Here!');
実行結果
$ node --unhandled-rejections=strict chain.js
1. Here!
2. p1.then():  Response { body: 'Response of hoge/success', status: 200 }
3. p2.then():  1

.then() よりもさらに後ろのスクリプトが先に実行されている点に注意てす。

.catch() が戻したプロミスに, さらに .then().catch() を繋げてもよい。.catch() のハンドラが正常に完了すると後続の .then() の, 例外を投げると後続の .catch() のコールバック関数に繋がる.

これは意外な挙動で, そうすると, あるプロミスについて, 先に .catch() を書くような書き方は上手くいかない。

ハンドラから値を返したとしても, 例外を投げたとしても, いずれも後続のハンドラに繋がるので, ハンドラの中からはメソッドチェインを途中で中断, というのはできない。

待ち受け: 正常値と例外に引き戻す

最後のハンドラの戻り値を得るには、非同期処理が完了するのを待ち受ける。await キーワードを使う。

Promise.all(), Promise.race() は, 上述のとおり, 待ち受けではありません。

async, await は, Promise の構文糖ではありません

JavaScript
[RAW]
  1. async function test1() {
  2. const p1 = fetch('/');
  3. const p2 = Promise.all([p1]); // すぐ戻る。待ち受けではない。
  4. const p3 = p2.then( ary => {
  5. console.log('3. p2.then(): ', ary[0]); // 非同期の文脈: 先行処理が完了.
  6. return ary[0];
  7. });
  8. console.log('1. Here!');
  9. const results = await p3; // await は yield と同様, いったん関数 test1 から
  10. // 抜けさせる。非同期処理が完了したら、ここに戻る.
  11. console.log('4. Results = ', results); // p3 から値を取り出す.
  12. }
  13. const r = test1(); // await したタイミングで, Promise を返す.
  14. console.log('2. returned: ', r); //=> Promise {<pending>}
実行結果
1. Here!
2. returned: Promise {}
3. p2.then(): Response {type: "basic", url: ...}
4. Results = Response {type: "basic", url: ...}

awaitPromise オブジェクトを引数に取り, settle されたら、値を取り出すか, 例外を送出します。

await キーワードは, async function の中でしか書けません。単純な完了待ちではスクリプト全体が止まってしまいます。それを囲む async function をいったん脱出し, 処理が完了する前に Promise を返し、その後ろの処理を続行します。その後、非同期処理が完了したら、ふたたび await の場所に戻ってきます。

結局、いったん非同期処理を開始したら、非同期ではない側から, 適時に値を取り出すことはできません。

非同期処理を作る: まず Promise を返し、その後実行

非同期関数は, 新しい Promise オブジェクトを返すようにします。

JavaScript
[RAW]
  1. class Response {
  2. constructor(body, options) {
  3. this.body = body;
  4. if (options) {
  5. this.status = options.status;
  6. }
  7. }
  8. }
  9. // fetch() っぽい関数.
  10. // @input 絶対URL でも path でも可.
  11. // @return Promise<Response>
  12. global.fakeFetch = function (input, init) {
  13. return new Promise((resolve, reject) => {
  14. // resolve(), reject(): プロミスの "状態" を変更し, 後続の .then() また
  15. // は .catch() に渡す.
  16. setTimeout( () => {
  17. if ((input + '/').indexOf("/success/") >= 0)
  18. resolve( new Response(`Response of ${input}`, {status:200}) );
  19. else
  20. reject(new Error(`Something wrong: ${input}`));
  21. }, 300 * Math.random());
  22. });
  23. }

Promise のコンストラクタに非同期処理したい関数を渡します。resolve, reject の2引数を取ります。

プロミスは内部状態を持つ. "pending", "fulfilled" (成功), そして "rejected" (失敗) です。"fulfilled" と "rejected" を合わせて、プロミスが「settle された」といいます。

resolve() を呼び出すと, プロミスの状態が "fulfilled" になり, .then() で登録したハンドラが呼び出されます。reject() を呼び出すと, プロミスの状態が "rejected" になり, .then() 2引数版か .catch() で登録したハンドラが呼び出されます。

非同期処理のキャンセル

AbortController を使う。TODO: 書く

See Cancel a JavaScript Promise with AbortController - Bramus! - Medium

Promise はモナドか?

最初, 値を wrap または lift したものが Promise と誤解していたので、モナドかどうかが気になったが、そもそもそれが間違いだった。

これはナンセンスな議論だ; JavaScript の Promise は モナドではない というか, 値を得るには await じゃない?

外部リンク

ざっと見ると, Promise について、正しく解説しているページのほうが少ない。