echo("備忘録");

IT技術やプログラミング関連など、技術系の事を備忘録的にまとめています。

【JavaScript】非同期処理(async/await)に関するちょっとしたTips

はじめに

新年あけましておめでとうございます。(もう1/25だけど)

去年もいろいろありましたが、今年も正月休みも終わり、そろそろ平常運転にも慣れてきたので、ブログの方も開始しようと思いました。

で、新年一発目のネタですが、最初は安定のServerless Framework..と思ったんですが、業務でJavaScript(Node.js)の非同期処理についてちょっと調べる機会があったので、その内容をブログにしました。

ちなみに、僕も過去に非同期処理に関するブログをいくつか書いてますので、良ければそちらも読んでみてください。

内容

  • Promise.allSettled()でPromiseの結果を個別に判定する
  • ループの中でawaitを使う場合の挙動
  • JavaScriptでsleep関数を実現する方法

Promise.allSettled()でPromiseの結果を個別に判定する

2020年6月リリースのES2020(ECMAScript2020)で、「Promise.allSettled()」というメソッドが追加されました。(Node.jsならver12.9.0以上で使用可能)

これはどんなメソッドかというと、複数の非同期処理に関して、下記の挙動を実施するメソッドです。

  • 引数の配列で指定した非同期処理がすべてresolve/rejectされるまで待つ
    • これはPromise.all()と同じ
  • 引数の配列で指定した非同期処理について、個別にresolve/rejectを判別可能
    • これがPromise.all()と違う点
    • 1つでもrejectされたらメソッド自体がrejectされる...ということがない

これの何が便利かというと、

・ 個別にresolve/rejectを判別可能
rejectされた処理があってもメソッド自体はrejectされない

という点です。

これまで、「非同期処理の並列化」といえばPromise.all()を使っていたわけですが、Promise.all()は

引数の非同期処理のうち1つでもrejectされた場合、その時点でPromise.all()自体がrejectされる

という挙動なので、複数の非同期処理を並列に実行する際に、

  • 複数の非同期処理で、rejectされる状況が仕様上おこりえる。
  • rejectされた非同期処理があっても、並列実行の結果自体はrejectしたくない(≒エラーにしたくない)

という場合に、非同期処理側で下記のような処理を行う必要があり、一手間がかかりました。

/**
* 例:AWSのS3バケットからキー(ファイル)の内容を読み込む処理
* ただし、ファイルの内容が存在しないことは普通に起こりえるとする
*/  
const main = async () => {
  
    const keys = ['hoge.json', 'fuga.json', 'piyp.json'];  
    const promises = [];
    
    for(const key of keys) {
        promises.push(getS3ObjectAsync(key));
    }
  
    const promises = await Promise.all(promises);
  
    for (let i = 0; i < 3; i++) {
  
        if (promises[i].size !== -1) {
            // getS3ObjectAsync()で何もなかった場合の処理  
            conssole.info(`${keys[i]} のファイルサイズは${promises[i].size}, 内容は${promises[i].content} です。`);
        } else {
            // getS3ObjectAsync()でエラー発生した場合の処理  
            conssole.warn(`${keys[i]} はsample-bucketバケットに存在しません。`);
        }
    }
}  
  
/**
* s3バケットからgetObject()でキーの内容を読み込む。
*/ 
const getS3ObjectAsync= async (key) => {
  
    const param = {
        Bucket: 'sample-bucket',
        Key: key
    };
    
    let contentsObj = null;   
    
    try{  
        const data= await s3.getObject(param).promise();  
        contentsObj = {  
            size: data.ContentLength,  
            content: data.Body.toString()
        };  
    catch(e) {  
        // エラー発生時は、専用の値を返す。  
        // 指定したkeyのファイルがsample-bucket内にない場合、  
        // エラー発生する。 
        console.warn(`key ${key} is not found`);
        contentsObj = {  
            size: -1,  
            content: ""
        };
    }  
  
    return contentsObj;
}  
  
(async ()=> {  
    await allSettledAsync();  
}).call();  

しかしPromise.allSettled()を使えば、メインロジック側で下記のような方法でresolve/rejectを判別できるので、非同期処理側で個別に上記のような処理を行わなくてよくなります。

/**
* 例:AWSのS3バケットからキー(ファイル)の内容を読み込む処理
* ただし、ファイルの内容が存在しないことは普通に起こりえるとする
*/  
const main = async () => {
  
    const keys = ['hoge.json', 'fuga.json', 'piyp.json'];  
    const promises = [];
    
    for(const key of keys) {
        promises.push(getS3ObjectAsync(key));
    }
  
    // ここまではさっきと同じ  
    const promises = await Promise.allSettled(promises);
    
    // ここからが異なる
    for (let i = 0; i < 3; i++) {
  
       // Promise.allSettled()を使用した場合、各promiseの  
       //「status」キーでresolve/rejectを判別可能  
       // もちろんrejectされても、Promise.allSettled()自体は
       // rejectされない。(=エラーにならない) 
        if (promises[i].status === 'fulfilled') {
  
            // statusが'fullfilled'の場合、そのpromiseはresolveされた 
            // その場合、resolveされた値はキー「value」に格納される
            conssole.info(`${keys[i]} のファイルサイズは${promises[i].value.size}, 内容は${promises[i].value.content} です。`);
        } else if (promises[i].status === 'rejected'){
  
            // statusが'rejected'の場合、そのpromiseはrejectされた
            conssole.warn(`${keys[i]} はsample-bucketバケットに存在しません。`);
        }
    }
}  
  
/**
* s3バケットからgetObject()でキーの内容を読み込む。
*/ 
const getS3ObjectAsync= async (key) => {
  
    const param = {
        Bucket: 'sample-bucket',
        Key: key
    };
     
    // ここでcatchする必要がない(エラー発生時はそのままrejectしてOK)
    const data= await s3.getObject(param).promise();  
    const contentsObj = {  
        size: data.ContentLength,  
        content: data.Body.toString()
    };  
  
    return contentsObj;
}  
  
(async ()=> {  
    await allSettledAsync();  
}).call();  

ループの中でawaitを使う場合の挙動

JavaScriptの非同期処理をググってみると、たまに

  • ループの中で非同期処理は使えない
  • ループの中で非同期処理を使う場合は注意が必要

という記載がある記事を見かけます。

ただ、記事によって書いてある内容が違っててごちゃごちゃしてたので、実際に検証しました。

結論:ループ内でも普通にawaitは使える ※ただしforEach()を除く

まず結論から言うと、ほとんどのループ処理(下記)では、普通にループ内でawaitが使用できます。

  • for
  • for ~ of
  • for ~ in
  • while

ただし例外として、forEach()内ではawaitは機能しません。
なのでforEach()内でawaitを使用したところで、resolve/rejectを待つことなく次のループ処理を実施してしまいます。

※ちなみにMDNには、以下のように記載されています。

forEach はプロミスを待ちません。forEach のコールバックとしてプロミス (または非同期関数) を使用する場合は、その意味合いを理解しておくようにしてください。

動作確認

サンプルとして、下記ソースを用意しました。
(ちょっと長いですが、概要としては「先述の各ループ処理について、個別にawaitしながら5回非同期処理を実施する」という内容です)

// for~of、forEach()で使用するカウントアップ用の配列  
const counts = [1, 2, 3, 4, 5];  
  
const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));  
  
// while内でawaitする  
const whileAsync = async () => {  
  
    console.log("関数[whileAsync]を実行します");  
  
    let cnt = 0;  
  
    while(true) {
        if(5 <= cnt) break;
  
        console.log(`引数は${cnt + 1}です。`);
  
        try {
            await sampleFuncAsync(cnt + 1);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`引数${cnt + 1}のawaitが終了しました。`);
        cnt++;
    }
}
  
// forループ内でawaitする  
const forAsync = async () => {
  
    console.log("関数[forAsync]を実行します");
    for(let cnt = 0; cnt < 5; cnt++) {
  
        console.log(`引数は${cnt + 1}です。`);
  
        try {
            await sampleFuncAsync(cnt + 1);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`引数${cnt + 1}のawaitが終了しました。`);
    }
}
   
// for~ofループ内でawaitする
const forOfAsync = async () => {
  
    console.log("関数[forOfAsync]を実行します");
    for (const cnt of counts) {
  
        console.log(`引数は${cnt}です。`);
  
        try {
            await sampleFuncAsync(cnt);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`引数${cnt}のawaitが終了しました。`);
    }
}
  
// for~inループでawaitする
const forInAsync = async () => {
  
    console.log("関数[forInAsync]を実行します");
    const fruits = { apple: 1, orange: 2, banana: 3, melon: 4, grape: 5 };
  
    for (const fruit in fruits) {
  
        console.log(`果物は${fruit}、引数は${fruits[fruit]}です。`);
  
        try {
            await sampleFuncAsync(fruits[fruit]);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`果物${fruit}、引数${fruits[fruit]}のawaitが終了しました。`);
    }
}
  
// forEach()ループでawaitする
const forEachAsync = async () => {
  
    console.log("関数[forEachAsync]を実行します");
  
    counts.forEach(async (cnt) => {
  
        console.log(`引数は${cnt}です。`);
  
        try {
            await sampleFuncAsync(cnt);
        } catch(e) {
            console.warn(e.message);
        }
  
        console.log(`引数${cnt}のawaitが終了しました。`);
    });
}
  
// サンプルの非同期処理。  
// index秒待って、indexが偶数ならresolve, 奇数ならrejectする。
const sampleFuncAsync = async (index) => {
  
    await _sleep(index * 1000);
  
    if (index % 2 !== 0) {
        throw new Error(`${index} is Odd!`);
    }
  
    return true;
}
  
// 非同期関数の呼び出し
(async ()=> {
    await forAsync();
    await forOfAsync();
    await forInAsync();
    await forEachAsync();
    await whileAsync();
}).call();

で、上記ソースの実行結果が下記枠内です。

結果としては、forEach()以外はすべて想定される挙動である

  • 「引数はxです」とログ記載
  • x is Odd!とエラーログ記載(xが奇数の場合のみ)
  • 「引数xのawaitが終了しました」とログ記載
  • xをカウントアップ

という結果ですが(=awaitが正しく機能している)、forEach()だけはいきなり1~5について「引数はxです」のログが書かれていることから、awaitしてもresolve/rejectを待っていないことがわかります。

関数[whileAsync]を実行します
引数は1です。
1 is Odd!
引数1のawaitが終了しました。
引数は2です。
引数2のawaitが終了しました。
引数は3です。
3 is Odd!
引数3のawaitが終了しました。
引数は4です。
引数4のawaitが終了しました。
引数は5です。
5 is Odd!
引数5のawaitが終了しました。
関数[forAsync]を実行します
引数は1です。
1 is Odd!
引数1のawaitが終了しました。
引数は2です。
引数2のawaitが終了しました。
引数は3です。
3 is Odd!
引数3のawaitが終了しました。
引数は4です。
引数4のawaitが終了しました。
引数は5です。
5 is Odd!
引数5のawaitが終了しました。
関数[forOfAsync]を実行します
引数は1です。
1 is Odd!
引数1のawaitが終了しました。
引数は2です。
引数2のawaitが終了しました。
引数は3です。
3 is Odd!
引数3のawaitが終了しました。
引数は4です。
引数4のawaitが終了しました。
引数は5です。
5 is Odd!
引数5のawaitが終了しました。
関数[forInAsync]を実行します
果物はapple、引数は1です。
1 is Odd!
果物apple、引数1のawaitが終了しました。
果物はorange、引数は2です。
果物orange、引数2のawaitが終了しました。
果物はbanana、引数は3です。
3 is Odd!
果物banana、引数3のawaitが終了しました。
果物はmelon、引数は4です。
果物melon、引数4のawaitが終了しました。
果物はgrape、引数は5です。
5 is Odd!
果物grape、引数5のawaitが終了しました。
関数[forEachAsync]を実行します
引数は1です。
引数は2です。
引数は3です。
引数は4です。
引数は5です。
1 is Odd!
引数1のawaitが終了しました。
引数2のawaitが終了しました。
3 is Odd!
引数3のawaitが終了しました。
引数4のawaitが終了しました。
5 is Odd!
引数5のawaitが終了しました。

for await of はあるけれど...

もう一つ、ループの非同期関数に関する処理として、「for await of」というのがあります。
これは、非同期のループ処理(=非同期イテレーター&ジェネレーター)について、awaitしながらループを順に処理する、というものです。

具体的な使用方法は、下記ソースを見てください。(非同期イテレータ&ジェネレーターについては、ここでは詳しく説明しません)

const counts = [1, 2, 3, 4, 5];  
  
// lists配列の要素の数値について、その2乗の数をresolveする
// 非同期イテレーターのジェネレーター
const sampleFuncAsyncGenerator = async function* (lists) {
    for(const cnt of lists) {  
        try {  
            console.log(`cntは${cnt}です。`);
            yield cnt * cnt;  
        catch(e)  {  
            // rejectするとその時点でfor await ofループが終了するので、  
            // for await ofループは継続したい場合、エラー発生時専用の値を   
            // resolveする必要がある
            yield Number.MIN_VALUE;  
        }
    }
};  
  
const forAwaitAsync = async () => {
  
    console.log("関数[forAwaitAsync]を実行します");
  
    try {  
        // for await ofを使用して、非同期イテレータで非同期処理を  
        // awaitしながら実施
        for await (const cnt of sampleFuncAsyncGenerator(counts)) {  
            if(cnt !== Number.MIN_VALUE) {  
                console.log(`cntの2乗は${cnt}です。`);
                console.log(`awaitが終了しました。`);  
            } else {  
                // エラー発生時の値がresolveされた場合
                console.log(`エラーが発生しました。`);  
            }
        };
    }catch (e) {  
        // この書き方でわかる通り、rejectされるとその時点で  
        // for await of ループが終了してしまう...
        console.warn('error: ' + e.message);
    }
};  
  
(async ()=> {  
    await forAwaitAsync();
}).call();

ただ、これを使ってた感想として、

  • ループ内でawaitした場合と、そこまで違いが見受けられない
    • あえてfor await of(=非同期イテレーター)を使う理由って何だろう?(知ってたら教えてほしいです)
  • reject時の処理が面倒
    • 上のソースでなんとなくわかる通り、rejectされるとその時点でfor await ofループがが終了してしまう
    • 先述の「メイン処理自体はrejectしたくない(後続のfor await of処理を継続したい)」場合、Promise.all()同様、固有の処理が必要

というのがあり、個人的には「for await of 使うなら、for~ofループ内でawaitした方が良いのでは?」というのが正直な感想です。

というか、そもそもループ内でawait自体が

と、ここまでループ内でのawaitについて書きましたが、個人的にはそもそもループ内でawaitすること自体がNGだと思います。

なぜなら、ループでawaitするということは、非同期処理の大きなメリットである「並列処理による実行時間短縮」を捨ててしまっているからです。
なので、(何か特別な理由がない限り)そのようなソースを書くべきではないます。

代わりに「ループでは非同期処理の呼び出しだけ行って、awaitは全呼び出し終了後にまとめて実施」しましょう。

  
// NG例  
// 全同期処理を個別にawaitすると、処理時間は各処理時間の合計になる  
// 例えば、200msかかる非同期処理を5回実行した場合、  
// 全処理終了までに1000msかかる
for (const cnt of counts) {
    await sampleFuncAsync(cnt);
}  
  
// -----------------------------------------------------------  
  
// OK例  
// 全同期処理をまとめてawaitすると、処理時間は「各処理時間のうち、
// 一番長かったものの処理時間」になる  
// 例えば200msかかる非同期処理を5回実行しても、200msで全処理終了する。
const promises = [];  
for (const cnt of counts) {  
  
    // ループ内では、非同期関数の呼び出しだけ実施
    promises.push(sampleFuncAsync(cnt));
}
  
// Promise.all()なりPromise.allSettled()なりで、
// 呼び出し非同期処理を一括でawaitする。
await Promise.all(promises);

ちなみに、ESLintでも「ループ内のawait」はデフォルトで不許可になっています。
eslint.org

JavaScriptでSleep関数を実現する方法

これは本当に小ネタレベルですが、PromiseとsetTimeout()を組み合わせた下記の関数(_sleep関数)を呼び出すことで、JavaC#などにあるSleep処理をJavaScriptでも実行できます。(「ループの中でawaitを使う場合の挙動」でも使用しています)

// Sleepしたいときに呼び出す関数(この1行だけでOK)  
// 引数msは、スリープさせたい時間(ミリ秒)
const _sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));  
  
// 具体的な使用例  
const waitThreeMin = async () => {  
    console.log('3分間待ってやる');  
    await _sleep(1000 * 60 * 3); 
    console.log('時間だ'); 

    return;
}

まとめ

という感じで、非同期処理のちょっとした情報をまとめました。

特にPromise.allSettled()は個人的には割と「待ってました」な感じの機能なので、Node.jsのバージョンが12.9.0以上の環境での開発なら、有効活用していきたいなあ、と考えています。

また、ES2020(ECMAScript2020)ではPromise.allSettled()以外にも便利な機能がいろいろ追加されているので、興味があれば下記サイトなどで一度調べてみるのもいいかもしれません。

それでは、今回はこの辺で。