async/await は Promise を置き換えない

まとめ

  • async/await 構文は、Promise で書ける処理のうち特定のケースしか表現できない
    • 特定のケースとは、ある非同期処理の前処理と後処理がそれぞれ 1 個ずつの場合のみである
  • async/await 構文は初心者に非同期処理を導入する際に適しているが、非同期処理を逐次処理として書けるという幻想を与えるので、どこかで知識をアップデートする機会を設けるべきである

この記事はなに?

少しバズったのでまとめておこうかと。

async/await 構文とは?

Promise を使った非同期処理を平易に記述するための糖衣構文。

例えば、Twitter API から指定したユーザのプロフィール画像の URL を取得する関数と、指定した URL のデータをメモリにダウンロードする関数があったとする。どちらも非同期処理なので、一つ前の処理の完了を待ち合わせたり、ネットワークエラーなどに対処する必要がある。JavaScript の場合、これを Promise を使って書くとこのようになる(型アノテーションは TypeScript の記法を流用)。

const fetchProfileImageUrl = (username: string): Promise<URL> => { ... };
const downloadUrl = (url: URL): Promise<ArrayBuffer> => { ... };

const image = fetchProfileImageUrl('okapies').then(url => downloadUrl(url));

非同期処理は、処理が完了する順番やその待ち合わせ、例外処理などの様々なケースを考慮する必要があり面倒が多い。Promise を使えば、非同期処理を「Promise を返す関数」と then の組み合わせで実装できる。

しかし、この例では一つ前の非同期処理の結果を使って処理を順番に実行したいだけなので、組み合わさる処理が増えてくると記述が煩雑になる。そこで、非同期処理を命令型の逐次処理のように書くための構文として async/await が提案されている。以下の例では、fetchProfileImageUrl の前に await キーワードを追加することで、thencatch などの特別なメソッドを使わずに、あたかも非同期処理の組み合わせを逐次処理であるかのように書ける。

const fetchProfileImageUrl = async (username: string): Promise<URL> => { ... };
const downloadUrl = async (url: URL): Promise<ArrayBuffer> => { ... };

const url: URL = await fetchProfileImageUrl('okapies');
const image: ArrayBuffer = await downloadUrl(url);

Promise は多くの言語でクラスやデータ型として表現できるが、async/await は構文の拡張が必要であり、全てのプログラミング言語で利用できるわけではない。非同期処理のユースケースが増える中で、async/await 構文を持つことは新興の言語にとって大きなセールスポイントになっている。

async/await は何ができないか

ある非同期処理について、そこに繋がる前処理や後処理が2つ以上ある場合 (N > 1) は async/await は適用できない。なぜなら、原理的に、二つ以上の非同期処理の並列実行を逐次処理 (N = 1) として記述することはできないからだ。ストリームデータ処理などの文脈では、このような接続関係を fan-in/fan-out と呼ぶ(おそらく論理回路の用語の流用)。

f:id:okapies:20201213143951p:plain

例えば、冒頭のツイートで指摘した問題は投機的実行として知られるテクニックだが、これを解くには、以下のように Promise の配列を Promise.any() に渡す必要がある。*1 *2

const asyncFn0 = async (): Promise<any> => {...};
const asyncFn1 = async (): Promise<any> => {...};
...

const results = [asyncFn0(), asyncFn1(), ...];
Promise.any(results).then(a => { ... });

また、もう少し一般的なユースケースでは「複数の API に問い合わせて、全ての結果が返ってきたら結果を集約して表示する。どれかが失敗した場合はエラー処理をする」というものがある。この場合も、同様に Promise の配列に対して Promise.all() 関数を使う。*3 *4

後処理が複数ある場合も同様だ。例えば、先ほどの問題で Twitter から取得した URL をキャッシュサーバにキャッシュしておくとして、生の Promise を使うなら単に Promise 値 imageUrl に対して then を二回呼べばよい。

const fetchProfileImageUrl = (username: string): Promise<URL> => { ... };
const downloadUrl = (url: URL): Promise<ArrayBuffer> => { ... };
const cacheUrl = (username: string, url: URL): Promise<void> => {...};

const imageUrl = fetchProfileImageUrl('okapies');
const image = imageUrl.then(url => downloadUrl(url));
imageUrl.then(url => cacheUrl('okapies', url));

対して、async/await で書くと以下のようになる。一見、async/await でも正しく記述できているように見えるが、セマンティクスが変わってしまう。つまり、downloadUrl の完了を待ってから cacheUrl しているので並列に実行できず応答性能が劣化する。*5

const imageUrl = await fetchProfileImageUrl('okapies');
const image = await downloadUrl(imageUrl);
await cacheUrl('okapies', imageUrl);

async/await をどう教えるか

昨今、「async/await は従来の難しい Promise を完全に置き換える優れたコンセプトである」という主張を散見する。しかし、以上のような限界がある以上、async/await は Promise の特定のユースケースで便利な糖衣構文、という以上のものではない。

Promise が「難しい」のは、そもそも非同期処理が難しいからだ。そして、async/await が「簡単」なのは、非同期処理の中でも簡単な部分をより簡潔に書けるようにしているからに過ぎない。

もちろん、以上のような限界を理解した上で活用するのは問題ない。特に、JavaScript のように代表的なユースケースに UI 処理やネットワーク処理などが入ってくる環境では、プログラミング入門者向けに非同期処理の必要な部分で async/await 構文を「おまじない」として教えるのは致し方ない面もある。

だが、そうした便宜上の教え方を真実と勘違いした技術者が増えるとなると話は別で、コミュニティが主体となった知識のアップデートなど、何らかの対策が必要であるように思う。

十年以上前、Protocol Buffer の RPC 機能(今日 grpc として知られているもの?)の是非について論争があった際に、反 RPC 派の Steve Vinoski 氏が以下のように述べたという。当時と比べて開発環境は大いに変化したが、残念ながら、根本的に問題になっている点はあまり変わっていないように思う。

For years we’ve known RPC and its descendants to be fundamentally flawed, yet many still willingly use the approach. Why? I believe the reason is simply convenience. Regardless of RPC’s well-understood problems, many developers continue to go down the RPC-oriented path because it conveniently fits the abstractions of the popular general-purpose programming languages they limit themselves to using. Making a function or method call to a remote or distributed function, object, or service appear just like any other function or method call allows such developers to stay within the comfortable confines of their language. Those who choose this approach essentially decide that developer convenience and comfort is more important than dealing with hard distribution issues like latency, concurrency, reliability, scalability, and partial failure.

Convenience Over Correctness :: Steve Vinoski’s Blog

補足

記事に対していただいたご意見など。

async/await の明確なメリット

制約があるということは、処理系の側で制約を使った最適化ができるということなので、async/await を使えるときは使った方が良いというのはその通りですね。

追記:

これは完全におっしゃる通りですね。Promise を使っていると変数間の依存関係を常に意識させられて煩雑な面がある(良い面もあるにせよ)わけですが、古典的な制御構造の範囲内では、そこを処理系がよしなにしてくれる。

fan-out でも Promise.all を使う

qiita.com

const imageUrl = await fetchProfileImageUrl('okapies');
const result = await Promise.all([
    downloadUrl(imageUrl),
    cacheUrl('okapies', imageUrl),
]);

ありがとうございます。まず、何をもって「Promise を置き換えた」と判断するかは様々な見解がありうるという前提を置いた上で、僕は Promise.all() に渡す式を評価すると Promise の配列が出てくる以上は Promise を隠蔽できていないと考えます。

これに対して、allany などの関数は async/await に付随した特殊な専用構文であり、await していない async 式を渡せる例外とみなすことは可能だと思います。しかし、この解釈だと関数呼び出しを書く場所が制約されてコードの取り回しが悪くなりますし、それならば Promise を変数として直接取り扱った方がシンプルかつ楽な方法に見えます。

あと、okazuki さんご自身も注釈されているように、Promise.all() は渡した Promise のうち一つでも失敗すると全体が失敗します。たとえば、キャッシュサーバへの通信経路が輻輳して URL のキャッシュが失敗すると、たとえ画像のダウンロードは成功していても一緒に失敗とみなされます。そこで、記事では個々の async 関数の中でエラー処理をすることで Promise.all() に例外を見せない方法を提案されていますが、それはそれでエラー処理の方法が制約されることになります(上のレイヤに例外のハンドリングを移譲する、など)。

JavaScript の場合、代わりに Promise.allSettled() を使う方法があるかもしれません。これを使うと、渡した非同期処理が一部失敗することを許容して、全て「完了」するのを待つことができます。ただこの方法でも、全体の完了時間が、渡した非同期処理のうち最も完了まで時間がかかったものに律速されます。これを避けたいなら、やはり個別の非同期処理ごとに then を書くしかないように思います。

all を使う方法がうまく行かないのは、おそらく all が集約関数だからだと思います。並列処理を逐次処理の中で書けるようにするには処理を集約するしかないですが、その代わりに個別の事情に沿った処理ができなくなってしまうのです。

any を race で代替できるか

ブコメより。

anyはexperimentalでも、raceで代替できるはず。/ thenの代替をawaitでやりたい、というモチベーションなら、raceで最初のひとつを待ってから処理した後、処理済みのpromiseを除いてからもう一度raceにかけてawaitすればいい。

azzrのブックマーク / 2020年12月14日 - はてなブックマーク

雑に書いてみたんですが、reject されると promise ではなく error が渡ってくるので、失敗した Promise を特定する方法が無さそうです。例外に自分自身の参照を埋め込むしかなさそう。

let resolved = null;
let results = [promise1, promise2];

while (!resolved) {
  try {
    resolved = await Promise.race(results);
  } catch (error) {
    results -= ???;
  }
}

*1:なお、any 関数は現時点では experimental なので Edge などのブラウザでは使用できない。

*2:追記: any は race 関数 で代替できるというコメントを頂いたが、race は最初に完了した Promise が rejected だった場合は、それ以降に fulfilled で完了する Promise があっても rejected になる。つまり、セマンティクスが異なるので置き換えられない。

*3:追記: TC39 で提案されている await.ops を使えば Promise をユーザに見せずに済む、というコメントを頂いているが、僕はこれは論旨に影響しないと考える。なぜなら、仕様書を読む限り、結局は Promise の配列を受け取って Promise.all() 等の集約関数にそのまま引き渡す薄いラッパーに過ぎないからだ。他の懸念点としては、例えば Promise が一つではなく二つ以上成功した場合を扱うユースケースがあった時に、仮に any2() という関数を生やすとして、それを毎回言語仕様に反映するのか、ということだ。

*4:追記: この記事で問題にしているのは、Promise という文字列をユーザが書かずに済むか否かという話ではなく、計算を記述するパラダイムの根本的な違いについてだ。本文中では説明を端折っているが、本質的に Promise は有向非循環な非同期計算の計算グラフ (DAG) を直接組み立てる API であり、その帰結として、N > 1 の場合は命令型のパラダイムでは記述できない。async/await で記述できるのは N = 1 の場合のみだ。これは原理的な問題なので、構文の工夫では解決できないと考える。

*5:もし処理系が Promise 同士の依存関係を正しく分析できるなら、Promise 版と同等のコードを生成してくれるかもしれない。その場合、コード上は逐次処理に見えるのに実行順序がひっくり返る、というようなことが起きるが…。