はじめに
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を使用する場合
※パターンB:awaitを使わず、Promise.then()を使用する場合
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で実行した結果が下図。(余計な箇所は消してます)
結果から言えば、async/await使用時と同様、何ら問題なく実行できました。
つまりイベントハンドラでも、awaitとPromise.thenで、挙動は全く同じです。
逆に言えば、挙動が全く同じということは、ソースの可読性・保守性・サイズなどを考えると、あえてPromise.thenで書く理由はあまりなさそうです。
「IEでも動作させるクライアント側の処理を書く場合」など、何か特別な理由がない限りは、ですが...
※参考までに、CloudWatchのログも張り付けておきます。(内容は先述のパターンBのログと全く同じですが...)
まとめ
以上、JavaScriptの非同期処理(Promiseとかasync/await)について発生した些細な疑問、及びその結果でした。
分かったつもりでいましたが、まだまだ非同期処理は厄介で、分かりにくい部分があるので、いろいろ調べないといけません。
というか、前回のブログから2か月近く空いてしまいました。
実は体調を崩してしまっていたのもあり、なかなかブログまで手が回りませんでした。(7月は毎週月曜を休みにしてもらったくらいですし...)
ただ、体調はちょっとづつ回復しており、先日(2020/7/15)には下記「VS Code Meetup #6」で「VS Code + Serverless FrameworkによるAWS環境構築&デプロイ確認」という内容で登壇もさせて頂いたので、これから少しづつブログの方も書いていこうと思います。
それでは、今回はこの辺で。