銀の弾丸

プログラミングに関して、いろいろ書き残していければと思っております。

全部約束 Promise.all - 非同期処理を効率よく並列実行するために

f:id:takamints:20200830201734j:plain photo credit: EpicTop10.com Promise via photopin (license)

互いに独立した複数の非同期処理は、 Promise.all で待ちましょうね!ってことを書いています。

例えば、複数のREST APIの結果を得たい場合、以下のように書きがちです。 一見問題なく見えますが、処理速度的にちょっと無駄なんですね。

実際ワタシも細かいことを気にせずにザクザクとコードを書いている時はこのように書いていて、あとから見直すことが多々あります。

async function sample() {
    const response1 = await fetch("http://path.to/rest/api1");
    const response2 = await fetch("http://path.to/rest/api2");
}

上のコードでは、 最初のAPIの結果を得てから次のリクエストを行います。つまり順次実行されるのですが、これってちょっと無駄なんです。

APIの数が多くなればなるほど処理速度はどんどん伸びます。しかし、そのほとんどが結果を待っているだけの時間なので無駄なんです。 (他の非同期処理が行われるため一概に無駄とは言い切れませんが)

無駄なく実行するコード

複数の独立した非同期処理を素早く実行するには、以下のように書きます。

async function sample() {
    const [response1, response2] = await Promise.all([
        fetch("http://path.to/rest/api1"),
        fetch("http://path.to/rest/api2"),
    ]);
}

仮にAPIの数が10個になると、最初の例では約10倍の時間がかかります。でも Promise.all を使った場合は、それほど時間は伸びません。 理論的には、ひとつのAPIの結果を得るまでの時間と同程度のはず(実際にはRESTサーバー側の処理能力に依存しますが)。

Promise.all について

Promise.all は、複数のPromiseオブジェクトがすべて完了するのを待ちます。 引数はPromiseオブジェクトの配列で、ひとつのPromiseオブジェクトを返します。 Promise.all が返すPromiseオブジェクトは、引数で与えられたすべてのPromiseが解決した場合に解決し、 その結果は、引数で与えたPromise配列と同じ数の配列であり、各要素が解決した値となります。 (ここではすべてのPromiseの解決が成功する前提で書いています)

上のコードの例では、個々のAPI呼び出しが返すPromiseオブジェクトを、個別に awaitせず、配列に格納して Promise.all に与えています。 これによって、すべてのAPIリクエストを一気に行って、結果が出るまで待つことになります。

複数非同期処理の並列実行で気をつけること

ここまでに書いたコードは、以下のように Array.map を使って置き換え可能です。 これが直感的であるかどうかは Promiseやasync/awaitに慣れているかどうかに依存すると思いますが、ここでワタシがよくやる間違いをひとつ書いておきます。

async function sample() {
    const apis = [
        "http://path.to/rest/api1",
        "http://path.to/rest/api2",
    ];
    const responses = await Promise.all(apis.map(api => fetch(api));
}

例えば、当初は非同期処理でなかったので Array.forEach で書いていたのだが、途中で非同期処理に変更されたような時に、Array.forEachArray.map に置き換えることを忘れていたり、 Promise.allawait するのを忘れていたり。これが実行時にエラーにならないんですよねえ。

できれば Array.forEach に AsyncFunction を与えた場合や、 Array.map の戻り値に対してなにも行っていない場合は警告を出してほしい。