はてだBlog(仮称)

私的なブログど真ん中のつもりでしたが、気づけばWebサイト系のアプリケーション開発周りで感じたこと寄りの自分メモなどをつれづれ述べています。2020年6月現在、Elasticsearch、pandas、CMSなどに関する話題が多めです。...ですが、だんだんとより私的なプログラムのスニペット置き場になりつつあります。ブログで述べている内容は所属組織で販売している製品などに関するものではなく、また所属する組織の見解を代表するものではありません。

JavaScriptの非同期処理に入門するためのPromise、async、awaitのつまみ食い

自分のために状況証拠的に学ぶJavaScriptのPromise、async、awaitメモです。

同じような境遇の方にはなんらかジャンプ台としてお役に立てるかもネと思って、まとめております。

もっと雑に分量少なめでまとめたものはこちら↓です。

itdepends.hateblo.jp

明らかなウソはないように極力努めましたが、お気に召せばうまくご参考いただきつつ、のちのち末尾の参考書籍・リンク等から公式や体系的な情報を得るようにお願いいたします。

プロローグ:Promiseとはなんぞや

Promise/Fureture(Deferなども同類と思われる。以下、代表して単にPromiseと表記)の歴史を紐解くところは正しく説明できる自信がないので行いませんが、おおよそ次のような背景を思い描いておくとありがたみが実感しやすいです。

  • Promise/Future(Deferなども同類と思われる。以下、単にPromise)は、非同期処理のデザインパターン
  • 非同期処理を同期処理風のシンタックスにみせかけるようにしてくれる便利な「何か」
  • 多くのプログラミング言語で標準の構文として取り入られつつある
  • JavaScriptでは、併せて語れることの多い、async、awaitはPromiseを下敷きにした頻出イディオムのさらなるシンタックスシュガー
  • JavaScriptの著名なライブラリのうち、位置付けからすると非同期(並行での実行)が望ましいような関数・メソッドは、処理結果そのものではなく、Promiseを戻すようにデザインされたものが増えている

Promiseの良いところ

非同期処理のコーディングって?そもそもどうなのという話

時間のかかる処理を裏街道で行いつつ、本筋をしばらく進めて最後に美味しいところををかっさらうといった、非同期処理の良さを享受したいというシーンはしばしば発生します。

ここで、ひとまず軽い気持ちで、次のような非同期の処理を含む関数を使ったロジック(注:JavaScript風の擬似コード)を考えます。

result = 非同期の処理P(arg,arg2,arg3) //・・・(1)
何か別の処理A // 同期的な処理(つまり非同期ではない処理)
何か別の処理B // 同期的な処理(つまり非同期ではない処理)
何か別の処理C // 同期的な処理(つまり非同期ではない処理)
処理A、Bなどやresultを使った後続の処理や例外処理 // ・・・(2)
あるいはresultをリターンして、呼び出し側で使う。// ・・・(3)

しかし、(擬似コードにこういう議論をするのはやや妙な話ですが)実際は上記の記述では、期待したとおりに動作しません*1

上記のPのような非同期処理は並行か並列かスレッドかプロセスかタイムシェアリングかはともかく、確かに(1)の記述の時点で実行可能にはなるのですが、非同期処理なので当然「即時完了」されるわけではなく、評価はプログラム記述上の次の行に進んでいくとともに、resultに期待したような値がセットされることは保証されません。

結果、(2)、(3)のあたりでエラーになったり、その時点では解決/未解決のある種不安定な値を扱ってしまうことになります。

コールバック関数引き渡しスタイルでの実現とコールバック地獄の出現

Promise登場以前のJavaScriptでの原始からの作法としては、非同期の処理Pの引数にコールバック関数を渡す方法で非同期処理の要件を取り扱うことができます。

非同期処理を扱う関数の戻り値を使うのではなく、非同期処理の結果を使う関数を非同期処理に渡して「いってこい」するのです。

しかしながら、要件が少しでも複雑になるとコールバック関数のネストが深くなり、可読性が著しく落ちること、いわゆるコールバック地獄を引き起こすことになります。

そこで可読性アップのために登場するのが、Promiseです。

  • Promiseデザインパターンでは、非同期処理Pが、Pの本来の処理Xとともに、Xで得られた結果の値と後述のおまじないをかけあわせたPromiseをリターンするようにする。
  • 呼び出し側のメインの処理フローでは、戻ってきたPromiseをしばらく引き回しつつ、Promiseオブジェクトの約束事として存在するthenメソッドに該当の非同期処理が完了した際のロジックを配置できる。

呼び出す方と呼ばれる方の「お約束」のもとのみんなが幸せになる仕組みです。

Promiseのある世界

Promiseにより、Promiseが存在しない例に比べると、、極力ネストが深くなることを避けた、また、ソースコード上の登場順と処理の順序がおおよそ一致する次のようなイメージの記述が可能になります。

/* 以下は擬似JavaScriptです。 */

const promise = 非同期の処理P2(arg,arg2,arg3) //・・・(1) ※P2はPromiseを戻すようにデザインされている。
// この時点では、promiseにはPromiseオブジェクトが入っている。
何か別の処理A // 同期的な処理(つまり非同期ではない処理)
何か別の処理B // 同期的な処理(つまり非同期ではない処理)
何か別の処理C // 同期的な処理(つまり非同期ではない処理)
// Promiseオブジェクトに「then」メソッドを呼び出す記述をすると、このタイミングで処理P2を同期(待ち合わせ)するように動作させることができる。
promise.then(value=>{ 
    //P2の処理が完了した場合に、このブロックが処理される。
   処理Aの結果や(1)の処理の本来の処理結果(value)や resultを使った後続の処理や例外処理 // ・・・(2)
} )

なお、thenメソッドはメソッドチェーンで記述できるので、複数の非同期処理やある種の待ち合わせ相当を安全に記述することができます。

(1)Promiseを戻すためのJavaScriptでのおまじない

前項で「Promiseをリターンするように」と言いました。

Promise自体を自前実装するのはかなりめんどくさいと思いますが、2021年のいまや、JavaScriptでは(JavaScriptが動作する「まともな環境」では)Promiseは標準サポートの範囲と前置きなく言っても良い状況だと思います。

そして、おおよそ次の要領で非同期の処理をラップしてやることで、Promiseを戻すように出来ます。

function 非同期の処理P2(引数){
    なんらかの処理
    return Promise((resolve,reject)=>{
        // Promise未使用の非同期の処理 ※さらにコールバック関数を引数にとる処理となることもある
        なんらかの処理                        
        resolve(非同期の処理の結果得られる値);  // resolve(XMLHttpRequest#responseTextを指す変数) のような記載になることが多い。
    }

}

なお、ここでは作法の説明は深追いしません。理解は無用という意味ではありません。ただ、私のような使う側に重心があるヒトは、上記を念頭におきつつも、Promiseを戻すようにデザインされた外部のライブラリにまずは慣れて実感を得るという習熟の仕方もあると思います。

(ちなみに、後述のasyncキーワードがこの「Promiseを戻す」仕組みを成立させるためのさらなるシンタックスシュガーになっています。asyncを見慣れてからからここに戻ってくるというのがおすすめです。)

なお、同じ理由で、ここまでもそうでしたが、以降も正常系の話を中心に記載することにします。

当然、異常系についても適切にハンドリングされるべきですので、ここではググリングのためのキーワードを列挙しておくに留めます。

  • Promise#reject
  • Promise#catch
  • Promise#finally
  • await式導入時のtryとcatch

(2) async、awaitの登場

なぜawaitか、そしてasyncか(Promise#thenでも満足できないヒトのために...)

Promise導入により、また、「then」メソッドを用いることで、非同期処理を含むロジックを同期的な見栄えのスタイルでコーディングできることの雰囲気を示しました。

最初は感心するものの、そのうち、これこれの場合、実際どうやって記述していけばよいのだろうという疑問がいくつか湧いてきます。

  • 非同期の処理を複数実行して、これら全てがそろったら得られた値を全て組み合わせてなんらか次の演算を行いたい。 (thenのメソッドチェーンでは非同期処理を直列化できそうだが、全部揃ったらというのには向かないかも...)
  • 時間を要する複数の非同期の処理はそれぞれで開始しておきたいが、結果をまとめる際には処理Aの結果を先に使って次に処理Bの結果を使うという順序性を制御したい。
  • エラーが発生した場合、特に複数の非同期の処理を実行している中で、thenにごにょごにょしていくことでエラーハンドリングを行うにはどうしたら良い?(うまくやりきる自身がない)。

他にも、コールバック地獄こそ回避されそうですが、Promise#thenの中で一方通行のコールバック関数を渡す形はさけられず、ロジックの最終の出口めがけてthenのメソッドチェーンが連なり、少し窮屈になりそうな予感もあります(し、実際そうです)。

つまるところ、もっと直感的にしたい、普段から見慣れているスタイルでロジック記述したいな〜と欲が出てくるのです。

そして、こういったところをより直感的なシンタックスでプログラム実現、制御できるための仕掛けとして、asyncやawaitが登場します。

async

非同期処理を呼び出し側で取り扱いやすくするためのPromiseですが、一方で、非同期処理側からすると、元からある「非Promiseな非同期処理」自体をPromiseの作法に合わせるために、return new Promise(xxx,yyy)でラッピングしてしてやる必要があります。

Promiseをnewするブロックって、ネストはさして深くないものの、その割に分かりにくいと感じません? 

asyncキーワードは、その関数がPromiseオブジェクトを戻すようにラッピングして(ある種のデコレートをして)くれるシンタックスシュガーです。

具体例としては、

async function 内部ロジックによらずPromiseを戻す関数(arg) {
    return ("解決したい値" + arg);
}

function 内部ロジックによらずPromiseを戻す関数(arg) {
    return new Promise((resolve,reject) => {
           resolve("解決したい値" + arg);
    } 
}

シンタックスシュガーです。

asyncは、別の非同期処理を含めやりたいこと自体を素直に関数化したロジックを最初に記述しておき、そこにasyncキーワードを付けるだけで、関数側に手間をかけずに、呼び出し側に「Promise」の利便性を提供できるようになるおまじないです。

await

await(await式)はそれを適用したPromise*2オブジェクトが「解決されるまで待ち」ます。

すごく雑に言うと、「then」のメソッドチェーンを作らなくても、「then」のご利益にあやかることができます。

Promise#thenメソッドでの記述スタイルは不要です。

また、「解決されるまで待つ」ので、戻り値は、Promiseが抱きかかえて抽象化してくれている非同期処理の処理結果の戻り値になります。 そうです。Promise相手とはなりますが、関数の戻り値を得て、それを次ステップの処理に利用するという、「普通」のスタイルのコーディングが可能になります。

例えば、Promiseオブジェクトが格納されている変数をprmsとすると、

const r = await prms;

で、rに処理結果が入ってきます。

Promiseを戻す非同期処理の関数の実際の処理結果を実際に使いたいタイミングで、該当のPromiseを格納した変数にawaitを適用すると、非同期処理の最終的な処理結果を戻り値として確保できます。

ちなみに、awaitはそれを適用したPromiseが「解決されるまで待つ」のですが、実際は、当該関数の評価もawait式が現れたところで一旦ストップするようです。

そのPromiseのインスタンスがFulfilledになるまで待つだけではなくて、とにかくそこで待つのです。

ここで、await式が現れると、その次のステップ以降は、評価が進んでいきません。本当にストップするみたいです。

これは、私のようなヒトの想像する挙動とある意味一致するので、それ自体は都合が良いことかと思います。

もちろん、この関数自体が自分が呼び出した非同期関数の終了を待つのは良いのですが、当該関数をさらに呼び出す側も待たされてしまうと、呼び出し元のプログラム全体が無用に停止したかのように振る舞ってしまうことになります。

それはそれで困るよね、ということで、awaitを使っているロジックを含む関数は、asyncを付けて宣言(つまり当該関数自体はPromiseを戻すように)しなさいというのが、言語仕様として規定されています。

深く考えずにawaitをおまじない的に適用した際に、asyncがないぞ!と、しばしばコンパイルエラーになります。 しかし、背景としてはこういうことだったりしますし、エラーでの指摘に従い、雰囲気でasyncを付けちゃって臭いものにフタをした気持ちになりますが、それ自体は「そうするべきもの」なので、なんら後ろめたくない話です。

(3) 実際の非同期処理制御の(筆者がよく出くわす)パターンのまとめ

講釈は以上でおわり、さいごに、非同期処理制御の(筆者がよく出くわす)パターンの早見例をまとめて記載します。

なお、以下の例のHOGE()、HOGE1()、...はPromiseを返す関数です。

サンプル...というほどのコード量ではありませんが、Node.jsのv12.16で確認しています。

例1. 非同期処理が処理するまで待つ

const rslt = await HOGE()

HOGE()が非同期処理であり(かつPromiseを戻してくれる場合)、その一方、今回の用途では終了を待った方が良いということであれば、この記述でOKです。

また、複数の非同期処理を直列化したいなら、

const rslt1 = await HOGE1()
const rslt2 = await HOGE2(rslt1)

のようにすれば良いです。

例2. IO待ちなどが発生する非同期処理をスタートさせつつIO待ちの間は他の処理を進めるようにしたい

これが一番多い例だと思いますが、1つの非同期処理を動かしておきながら、メインの同期的な処理を実施して、両方が揃った後に、次の非同期処理をしたいという例です。

await式により、必要なタイミングで処理を合流させることができそうです。

const prms1 = HOGE1()
const f1 = FUGA2()
const f2 = FUGA3()
const prms2 = await HOGE2(f1,f2,await prms1)

例3. 複数の非同期処理の実行の待ち合わせ(複数の非同期処理を開始した上で、それら全てが終了するのを待つ)

非同期処理が重要になるケースでは、順次は複数(しかも2つ以上)の処理をスタートさせておき、最後にそれらの全てが出揃ってから、全ての結果を統合して利用したいというケースが非常に多いと思います。

このような場合は、「Promise.all(Promiseオブジェクトの配列)」を使うことで、全部がでそろうまで待ち合わせが可能となります。 (また、awaitを使うとそれの見通しが非常に良くなりますので定型句として使えると思います。)*3

const tasks = [] 
tasks.push(HOGE1())
tasks.push(HOGE2())
tasks.push(HOGE3())
const rslts = await Promise.all(tasks)
 // rsltsは、tasksの配列の順番の処理の戻り値の配列。

/////ここでは説明自体の分かりやすさのため、promise1、promise2...のように直接初期化していますが、実際はtasksに
/////様々な並列実行させたい処理を抱えるPromiseを配列にappendするスタイルになると思います。

注目:上記の例では、rsltsに、HOGE1、2、3の戻り値の値が配列のこの順序でセットされます。

例4. 複数の(非同期処理の)並列実行のうち最初に完了したものを使う

await Promise.allは、「全ての終了を待つ」ですが、await Promise.raceは、どれか一つ完了するまで待つです。

浮気性な要件にはぴったりです。

const promise1 = HOGE1()
const promise2 = HOGE2()
const promise3 = HOGE3()

tasks = [promise1,promise2,promise3]
const rsltX = await Promise.race(tasks)
// rsltXには、HOGE1、HOGE2、HOGE3のうち最初に処理が完了したものの戻り値が入る。

付録(参考リンク/参考書籍)

promisesaplus.com

azu.github.io

developer.mozilla.org

developer.mozilla.org

Node.jsデザインパターン 第2版

qiita.com

*1:よりやっかいな話として、(1)が処理A、Bなどに比べて相対的に早く全てのの処理を完了した場合、一見期待したとおりに動作したように見えることもしばしば...

*2:正確には、Promiseでなくとも、「thenable」であれば良いようですが、ここでは触れません。

*3:Promise.allはawait、async以前から存在するPromiseデザインパターン側のメソッドですが、2021年においては、awaitとペアで覚えておくのが分かりやすかろうというのが筆者の感触でしたので、ここでは、await Promise.all(hogehoge)までを定型句としてご紹介しています。