地獄を抜けたらそこは地獄だった。
以下はHow to avoid (or escape) async/await hellという記事の戸田奈津子訳です。

How to avoid (or escape) async/await hell

async/awaitはたしかに我々をコールバック地獄から解放してくれました。
しかし、それは恐るべき地獄の、ほんのプレリュードにすぎなかったのです。
そう、async/await地獄の誕生です。

この記事ではasync/await地獄が何であるか、そしてそれから逃れるためのヒントをいくつか紹介します。

What is async/await hell

非同期JavaScriptを使用する際、しばしば複数の関数呼び出しすべてにawaitをつけがちです。
これによってパフォーマンス上の問題が発生します。
あるステートメントは別に手前のステートメントに依存はしていないのに、それが終わるまで待たせられてしまうのです。

An example of async/await hell

ピザと飲み物を注文するスクリプトを考えてみましょう。
何も考えずに実装すると以下のようになります。

    (async () => {
      const pizzaData = await getPizzaData()    // async call
      const drinkData = await getDrinkData()    // async call
      const chosenPizza = choosePizza()    // sync call
      const chosenDrink = chooseDrink()    // sync call
      await addPizzaToCart(chosenPizza)    // async call
      await addDrinkToCart(chosenDrink)    // async call
      orderItems()    // async call
    })()

一件正しそうに見えるし、実際正しく動作します。
しかしこれは良い実装ではありません。
何故ならば平行性がなくなっているからです。
問題を解決するにはどうすればいいか、順に見ていきましょう。

Explanation

コードは全体を即時関数で括られています。
このコードは正確に以下の順番で動作します。

1.ピザのリストを取得する
2.ドリンクのリストを取得する
3.リストからピザを選ぶ
4.リストからドリンクを選ぶ
5.選んだピザをカートに入れる
6.選んだドリンクをカートに入れる
7.カートを注文する

So what's wrong ?

さきほど言ったように、これらの命令は全て順番にひとつづつ実行され、平行に処理されることはありません。
ちょっと考えてみましょう。
ドリンクのリストを入手する前に、ピザのリストを取得する必要はありますか?
両方のリストは同時に取得すべきです。
しかし、ピザを選ぶ際にはピザのリストが用意されている必要があります。
ドリンクも同じです。

以上より、ピザ関連の処理とドリンク関連の処理はパラレルに行うことができますが、ピザに関する各処理は順番にひとつづつ実行していく必要があることがわかります。

Another example of bad implementation

以下のスニペットは、カート内のアイテムを全て取得して注文のリクエストを行います。

    async function orderItems() {
      const items = await getCartItems()    // async call
      const noOfItems = items.length
      for(var i = 0; i < noOfItems; i++) {
        await sendRequest(items[i])    // async call
      }
    }

このforループは、sendRequest()が毎回完了するのを待ってから次のループに入っています。
しかし、実際には待つ必要はありません。
なるだけ早く全てのリクエストを送信し、その後で全てのリクエストが完了するまで待つように変更することができます。

async/await地獄がどれほどプログラムのパフォーマンスに深刻な悪影響を与えているのか、あなたが理解してくれることを願っています。
問い詰めたい。小一時間問い詰めたい。

What if we forget the await keyword ?

async関数の中でawaitを書き忘れていても、関数は処理を行います。
つまり関数内にawaitを書く必要はないということです。
async関数はPromiseを返すので、それを後から使用することができます。

    (async () => {
      const value = doSomeAsyncTask()
      console.log(value) // pending
    })()

ただし注意するべき点は、関数の動作が完全に終了しているかどうかをコンパイラは知らないということです。
従って、コンパイラはasyncな関数の動作が完了しているか気にせずにプログラムを終了します。
これがawaitキーワードが必要な理由です。

Promiseの興味深い特性のひとつは、Promiseの取得と解決のための待機を別の場所で行うことができるということです。
そしてこれこそが、async/await地獄から逃れるための鍵です。

    (async () => {
      const promise = doSomeAsyncTask()
      const value = await promise
      console.log(value) // settled
    })()

見てのとおり、doSomeAsyncTask()はPromiseを返します。
この時点でdoSomeAsyncTask()の実行は開始されています。
次の行でawaitキーワードを使用し、実行が完了するまで待機しています。
awaitはその時点でプログラムの実行を一旦止め、Promiseが解決されたら次に進むようにするキーワードです。

How to get out of async/await hell ?

async/await地獄から逃れるためには、以下の手順に従ってください。

Find statements which depend on the execution of other statements

最初の例ではピザとドリンクを選んでいました。
熟考の末、ピザを選ぶ前にはピザのリストを持っている必要があると結論づけました。
また、ピザをカートに突っ込む前に、突っ込むピザを選んでおく必要があります。
従って、この3ステップはお互いに依存していると言えます。
手前の処理を終えるまで、次の処理に進むことはできません。

ところでピザの選択はドリンクの選択に一切依存しないため、ピザの選択とドリンクの選択は平行して実行することができます。

他のステートメントに依存するステートメントと、依存しないステートメントを切り分けることができました。

Group-dependent statements in async functions

これまで見てきたように、ピザの選択にはピザのリスト取得が必要で、取得したピザはカートに突っ込むといった従属関係が存在します。
これらをまとめてasync関数でグループ化しましょう。
このようにしてselectPizza()selectDrink()のふたつの非同期関数を得ることができます。

Execute these async functions concurrently

次に、このふたつの非同期関数をイベントループで同時に実行します。
一般的なパターンは、Promiseの早期returnと、Promise.allメソッドを使うことです。

Let's fix the examples

上記3手順を実行し、例題に適用してみます。

    async function selectPizza() {
      const pizzaData = await getPizzaData()    // async call
      const chosenPizza = choosePizza()    // sync call
      await addPizzaToCart(chosenPizza)    // async call
    }

    async function selectDrink() {
      const drinkData = await getDrinkData()    // async call
      const chosenDrink = chooseDrink()    // sync call
      await addDrinkToCart(chosenDrink)    // async call
    }

    (async () => {
      const pizzaPromise = selectPizza()
      const drinkPromise = selectDrink()
      await pizzaPromise
      await drinkPromise
      orderItems()    // async call
    })()

    // もしくはこう書いてもいい
    (async () => {
      Promise.all([selectPizza(), selectDrink()]).then(orderItems)   // async call
    })()

大きくふたつの関数にグループ化されました。
関数内では、各ステートメントは前の行に依存しています。
ついで、selectPizza()selectDrink()を同時に実行します。

Promise.allの例では、全個数がいくつになるかわからないPromiseに対応する必要があります。
対応は簡単で、配列を作ってその中にPromiseを入れるだけです。
Promise.allは配列内の全てのPromiseが完了するまで待機してくれます。

    async function orderItems() {
      const items = await getCartItems()    // async call
      const noOfItems = items.length
      const promises = []
      for(var i = 0; i < noOfItems; i++) {
        const orderPromise = sendRequest(items[i])    // async call
        promises.push(orderPromise)    // sync call
      }
      await Promise.all(promises)    // async call
    }

この記事が、async/awaitの基本とパフォーマンス向上に対する理解のために役立ってくれることを願っています。

この記事が気に入ったら拍手してください。
ヒント:50回拍手できます

FbやTwitterで共有してください。
記事の更新をチェックしたいときはTwittermediumをフォローしてください。
ツッコミがあればコメントしてください。

感想

async/awaitはコールバック地獄を解消することには成功したが、だからといって不用意に使うと不要な待ち時間が増えてしまいますよ、という内容。
正直地獄ってほどかと思わないでもないが、実際それに対応すると再びソースが面倒なことになる。
今回は完全に独立していた2グループだったからよかったものの、複数の関連がある複数のグループをまとめようと思うと気苦労は計り知れない。
焦熱地獄から旧地獄にランクアップした感はあるものの、地獄温泉はまだまだ遠い。

404contribution

どの処理とどの処理が独立かを厳密に考えて、この指針を適用すると、Promise.allの多重入れ子になる。今度はPromise.all 地獄の幕開けになるだけの気がする。

3contribution

Promise.allだとあるタスクが失敗したら、Promiseが失敗になり、うまく動けないケースがあります。
各タスクをコントロールしたいなら、forループで対応しないといけません。

138contribution

この記事、噛めば噛むほど味が出る良い記事ですね。
元記事のチョイス、翻訳ありがとうございました。

検討してみたらちょっとしたフローは一撃で書けるみたいですね。
(getScoreはuserがないと発火できない)

freerun.js
const getRoles = async () => ['admin', 'user', 'guest']
const getUser = async () => ({id: 123, name: 'taro', role: 'user'})
const getScore = async user => user.name === 'taro' ? 80 : 70

const getData = async () => {
  const promise = {}
  promise.role = getRoles()
  const user = await getUser()
  promise.score = getScore(user)
  return {
    roles: await promise.role,
    user,
    score: await promise.score
  }
}

getData().then(console.log)
// { roles: [ 'admin', 'user', 'guest' ], user: { id: 123, name: 'taro', role: 'user' }, score: 80 }

仰るように複数×複数条件が複雑に絡み合って、それぞれ最速に発火させるとなると地獄ですが…
複数の箇所を1つの関数にまとめろという話ですね。
それくらいはしょうがないと思いますし、言うほど酷いことにはならないかも知れませんね。

1429contribution

await/async地獄になるってことは、サーバAPIの設計ミスが多いと思います。
サーバAPIがプリミティブになりすぎてるケースが多いかなと。

Sign up for free and join this conversation.
Sign Up
If you already have a Qiita account log in.