echo("備忘録");

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

【JavaScript】awaitの使い方を復習する

はじめに

JavaScriptの非同期処理(Promiseとかasync/await)について、今年の1月に、下記のブログを書きました。
makky12.hatenablog.com

が、最近AWS Lambdaの非同期処理をJavaScriptで書いていて、ちょっとこんがらがってしまったことがあったので、備忘録的に記事にしました。

復習:awaitとは

awaitとは、非同期処理について、Promiseの結果が返ってくるまで処理を待機する(ように記載できる)構文です。

例えば、下記の2つのソースは、同じ動作をします。

// awaitを使わない場合、Promiseの結果が返った来た後の処理は  
// then()内に記載する。
function noAwaitFunc() {
    someAsyncFunc()
    .then(data => {
        console.log(data);
    });  
}
  
// awaitを使う場合、Promiseの結果が返った来た後の処理を  
// awaitから下のソースに記載する。  
// つまり、awaitから下の処理は全てPromiseの結果が返った来た後に  
// 実施される。(thenの中身と同じ)
// なおawaitを使う関数は「async」を定義し、非同期関数にする必要がある。    
async function withAwaitFunc() {
    const data = await someAsyncFunc();
    console.log(data);
}

awaitを使う事で、下記のメリットがあります。

  • 非同期処理をシンプルに書くことができ、ソースの可読性などが上がる
    • コールバック地獄のような記載がなくなる
  • 非同期処理を同期処理っぽく書くことができ、分かりやすくなる

で、依存関係がない複数の非同期処理について、処理時間を考える意味で「取りあえずawait」はやめよう...というのを書いたのが、先述のブログの内容です。

// asyncFuncA~asyncFuncCは全て非同期処理を行う関数とする。  
  
// 処理時間がasyncFuncA:5秒、asyncFuncB:10秒、asyncFuncC:15秒として、  
// 下記の書き方だと全て終わらせるのに5+10+15=30秒かかってしまう。
const a = await asyncFuncA();
const b = await asyncFuncB();
const c = await asyncFuncC();
  
// 下記のようにPromise.allをawaitし、3処理を並列処理すれば、  
// 処理時間はmax(5, 10, 15)=15秒で済む。     
const a = asyncFuncA();
const b = asyncFuncB();
const c = asyncFuncC();
const results = Promise.all([a, b, c]);

なおawaitについて、たまに「Promiseの結果が返ってくるまで処理を待つ」みたいなニュアンスの記載をしている記事がありますが、これは誤りです。

※あくまで「そのように記載できる」だけで、実際に「Promiseの結果が返ってくるまで処理を待つ」わけではないです。(先程「ように記載できる」と書いたのはそのため)

本題

で、僕がこんがらがったのは下記ソース。

例えば下記ソースでは、

  • 非同期関数hello()(=ハンドラ関数)で、someAsyncFuncAを呼び出している
  • 非同期関数someAsyncFuncAで、someAsyncFuncBを呼び出している
  • 非同期関数someAsyncFuncBで、別の非同期処理(ここではsetTimeOut)を呼び出す
  • 非同期関数someAsyncFuncA、someAsyncFuncBをawaitしている
  • 非同期関数hello()は、全ユーザーのsomeAsyncFuncAがresolveされるまで、returnしない(Promise.all)

なのですが、

  • someAsyncFuncAはawait使うけど、awaitより下はPromiseの結果が返るまで実行されない
    • returnまでの処理は、全てPromiseの結果が返った後にしか実施されない
    • return以後のソースは実施されない(=到達不可)なので、Promiseの結果が返らないとsomeAsyncFuncA自身を抜けることができない?
  • 結局、someAsyncFuncAがreturn(=resolve)されるまで、someAsyncFuncAの呼び出しから後の処理は実施されない(=待たされる)のか?

という疑問が浮かび、こんがらがってしまいました。

module.exports.hello = async event => {    
    const promises = [];
    // usersには、ユーザーの配列が格納されてるものとする  
    for(const user of users) {
         const promise = someAsyncFuncA(user);
         promises.push(promise);
    }  
    
    await Promise.all(promises);
    return;
}  
  
async function someAsyncFuncA(user) {  
    // usersには、ユーザーの配列が格納されてるものとする
    await someAsyncFuncB(user)
    return;
}  
  
async function someAsyncFuncB(user) {  
    // 何でもいいから、なんか非同期処理をするものとする  
    return new Promise(resolve => {
        setTimeout(() => {
            console.info('user is' + user);
            resolve();
        }, 2000);
  });  
}

もちろん、実際は「復習:awaitとは」の内容でなんとなくわかる通り、そんなことはなかったわけですが。

あれこれ言わず、手を動かして確認する

てなわけで、実際にソースを動かして確認しました。(Lambdaで実行)

'use strict';
const moment = require("moment");

const users = ["userA", "userB", "userC"];
  
// ソースが見にくくなるので省きましたが、実際はconsole.infoで  
// 実施時刻も出力しています。
module.exports.hello = async event => {  
  
  console.info(`[event] ${JSON.stringify(event)}`);  
  const promises = [];  
  
  for(const user of users) {
    console.info(`[asyncFuncA beforeCall] ${user}`);  
  
    // asyncFuncAの内容を2パターン用意し、1パターンずつ  
    // 実施して実施順を確認する。
    const promise = asyncFuncA(user);
    console.info(`[asyncFuncA afterCall] ${user}`);
    promises.push(promise);
  }
  
  console.info(`[Promise.all waiting]`);  
  
  // 3人のユーザーすべての処理が終わるまで待機
  await Promise.all(promises);
  console.info(`[Promise.all resolved]`);
  
  return {
    statusCode: 200,
    body: JSON.stringify(
      {
        message: 'Go Serverless v1.0!...(以下略)',
      }
    ),
  };
};
  
// パターンA:awaitを使用する場合
async function asyncFuncA(user) {
  console.info(`[asyncFuncA called] ${user}`);
  await asyncFuncB(user);
  console.info(`[asyncFuncA resolve] ${user}`);
  return;
  
  console.info(`[asyncFuncA finished] ${user}`);
}
  
// パターンB:awaitを使わず、Promise.then()を使用する場合
async function asyncFuncA(user) {
  
  return new Promise(resolve => {
     asyncFuncB(user)
     .then(() => {
       console.info(`[asyncFuncA resolve] ${user}`);
       resolve();
     });
  
     console.info(`[asyncFuncA finished] ${user}`);
  });
}  
  
// asyncFuncAから呼ばれる非同期処理を実施する関数。  
// ここではsetTimeOutを実施し、2秒後にresolveする。
function asyncFuncB(user) {
  
  console.info(`[asyncFuncB called] ${user}`);
  return new Promise(resolve => {
    setTimeout(() => {
      console.info(`[asyncFuncB resolve] ${user}`);
      resolve();
    }, 2000);
  
    console.info(`[asyncFuncB finished] ${user}`);
  });
}

結果をログで確認する

その結果のログが以下。

結論から言ってしまえば、処理順序はパターンAとBで全く同じ。
先述の「asyncFuncAがreturn(=resolve)されるまで待たされる」なんてことはありません。

唯一、赤枠の「asyncFuncA finished」のログの有無だけが違います。(が、awaitとPromise.thenの仕様を考えれば、当然の結果といえます)

つまり、「呼び出す非同期関数内でのawait/Promise.thenによる挙動の違いは全くないので、awaitによる待たされなどの考慮は一切不要」となるわけです。
(もちろん、Promiseの結果が返る前の処理は別)

またログを見ると分かる通り、どちらもasyncFuncAのresolveの前に次のログ(「asyncFuncA afterCall」や次のユーザーのasyncFuncAの呼び出し)が行われています。
「復習:awaitとは」で、「Promiseの結果が返ってくるまで処理を待つ」正確にはは誤り」と記載しましたが、このことからも「awaitでも別に処理を待つわけではない」ことが分かります。

ただし、「全て直列で処理を実行する」場合は、その考え方でも差し支えないと思います。 (並列で処理する場合、上記を考慮する必要がある)

※パターンA:awaitを使用する場合 f:id:Makky12:20200724183446p:plain

※パターンB:awaitを使わず、Promise.then()を使用する場合 f:id:Makky12:20200724183800p:plain

2020/7/25追記:Lambdaハンドラ関数をasync/awaitを使わずに書く

上記までで関数asyncFuncAについて、awaitとPromise.thenで、処理の違いは全くないことが分かりました。
でも、関数asyncFuncAはあくまで「サブ関数(=サブルーチン)」です。

では、メイン関数であるLambdaハンドラ関数(今回ならhello)をasync/awaitを一切使わず書いた場合、結果はどうなるのか?...というのを調べました。

実際AWS公式ドキュメントでも、ハンドラ関数はほぼ非同期関数(async function)で書いてあり、そうではない形式で書かれているなんて、まずないと思います。

で、実際に先述のhello関数をasync/awaitを一切使用せずに書くと、こうなります。

'use strict';  
  
const moment = require("moment");  
const users = ["userA", "userB", "userC"];
  
// 先程と同様、ソースは省きましたが、実際はconsole.infoで  
// 実施時刻も出力しています。  
  
// 先述のソースのLambdaハンドラ関数を、async/awaitを  
// 一切使わずに記載した例
module.exports.hello = event => {
  
  return new Promise(resolve => {
    console.info(`[event] ${JSON.stringify(event)}`);
    const promises = [];
  
    for(const user of users) {
      console.info(`[asyncFuncA beforeCall] ${user}`);
      const promise = asyncFuncA(user);
      console.info(`[asyncFuncA afterCall] ${user}`);
      promises.push(promise);
    }
  
    console.info(`[Promise.all waiting]}`);
    Promise.all(promises)
    .then(() => {  
  
      // ここでresponseを返す
      console.info(`[Promise.then resolved]`);
      resolve({
        statusCode: 200,
        body: JSON.stringify(
          {
            message: 'Go Serverless v1.0! Your function executed successfully!',
          }
        ),
      });
    })
    console.info(`[handler finished]`);
  });
};
  
function asyncFuncA(user) {
  
  return new Promise(resolve => {
    asyncFuncB(user)
    .then(() => {
      console.info(`[asyncFuncA resolve] ${user}`);
      resolve();
    });
  
    console.info(`[asyncFuncA finished] ${user}`);
  });
}
  
function asyncFuncB(user) {
  
  console.info(`[asyncFuncB called] ${user}`);
  return new Promise(resolve => {
    setTimeout(() => {
      console.info(`[asyncFuncB resolve] ${user}`);
      resolve();
    }, 2000);
  
    console.info(`[asyncFuncB finished] ${user}`);
  });
}

で、上記ソースをRest Clientで実行した結果が下図。(余計な箇所は消してます)
f:id:Makky12:20200725183004p:plain

結果から言えば、async/await使用時と同様、何ら問題なく実行できました。

つまりイベントハンドラでも、awaitとPromise.thenで、挙動は全く同じです。
逆に言えば、挙動が全く同じということは、ソースの可読性・保守性・サイズなどを考えると、あえてPromise.thenで書く理由はあまりなさそうです。

IEでも動作させるクライアント側の処理を書く場合」など、何か特別な理由がない限りは、ですが...

※参考までに、CloudWatchのログも張り付けておきます。(内容は先述のパターンBのログと全く同じですが...)
f:id:Makky12:20200725183218p:plain

まとめ

以上、JavaScriptの非同期処理(Promiseとかasync/await)について発生した些細な疑問、及びその結果でした。
分かったつもりでいましたが、まだまだ非同期処理は厄介で、分かりにくい部分があるので、いろいろ調べないといけません。

というか、前回のブログから2か月近く空いてしまいました。

実は体調を崩してしまっていたのもあり、なかなかブログまで手が回りませんでした。(7月は毎週月曜を休みにしてもらったくらいですし...)

ただ、体調はちょっとづつ回復しており、先日(2020/7/15)には下記「VS Code Meetup #6」で「VS Code + Serverless FrameworkによるAWS環境構築&デプロイ確認」という内容で登壇もさせて頂いたので、これから少しづつブログの方も書いていこうと思います。


VS Code Meetup #6 (オンライン)

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