橘氏の自己紹介

橘ゆう氏(以下、橘):よろしくお願いします。風邪でめちゃくちゃ顔が死んでいるので、カメラオフでいきます。橘です。今日は「JSの非同期処理パターン Promise、async/awaitを理解する」というテーマについて話していきたいと思います。

簡単な自己紹介ですが、もともとDeNAにいて事業統合でそのままGOに移り、今は森下さん(森下篤氏)と同じチームで、主にサーバーサイドやMLOps周りの開発をして、たまにWebアプリの開発もやっています。

ということで、今回はJS(JavaScript)について話すことになりました。あとは技術書典にもよくいます。(スライドを示して)Twitter(現X)はあまり投稿しませんが、こんなハンドルネームとこんなアイコンでやっています。

非同期処理が非常に活躍するブラウザ上のJSの世界

:これからWebアプリの世界での非同期処理について簡単に紹介してから、今日の本題に入っていきたいと思います。

まず、この発表の内容はPCのモダンなWebブラウザ上で動くJSやJSエンジンの話に限っていて、Node.jsとか携帯端末など他のJS環境には触れません。

さて、そのブラウザ上のJSの世界はどんな世界かというと、ユーザーの操作が非常に多くて、突発的なイベントがすごく多く、いろいろな外部APIとの通信が多い世界です。

いつ来るかわからない大量のイベントと、いつ終わるかわからない外部データ通信。これらに素早くスムーズに対応する必要があって、それに加えて非同期処理という概念が今は非常に活躍する世界です。というか、非同期処理がないとキツイと言ってもいいです。

(スライドを示して)ちなみに自分が業務で開発した、めちゃくちゃ簡単な地図アプリがあるんですが、このアプリも非同期処理で溢れています。ユーザーのあらゆる動作によって地図とインタラクトができていて、例えば地図の座標とかに飛ぶ。その操作に対して、地図の画像データをAPIから取ってくる必要があるんですね。

実はそれがけっこう細かくて。例えば今表示されているエリアだけで数十個とか、場合によっては100個以上の画像データを取ってくる必要があります。これらをバックグラウンドで常に非同期で取得していて、読み込み次第表示しています。

それらを全部同期で取ってきたとしたら、地図を読み込んでいる時はユーザー操作ができなくて、ユーザーが操作している時は地図が読み込まれない。アプリとして成り立たなくなります。非同期処理の必要性が非常に痛感できる例ですね。

非同期処理が必須ながら、シングルスレッドでしか動作しない

:非同期処理が必要で仕方ない環境なんですが、ブラウザ上のJSは実はシングルスレッドでしか動作しないという難点があります。シングルスレッドで、なにも工夫しないと、普通にデータ待ちやイベント待ちで画面が固まってしまって、誰もうれしくないです。

こういう環境では並列処理などはできないと言いつつ、命令の呼び出しのタイミングなどを工夫することで、非同期処理はシミュレートはできます。「疑似的な非同期処理」と呼んでいる概念です。

疑似的な非同期処理をどう実現しているのか

:では、この疑似的な非同期処理はブラウザやJSエンジン上でどうやって実現しているかというと、まずブラウザのJSランタイムには、queue、イベントループ、Call stackの主に3つの構成要素があります。厳密にはヒープとか、他の部分もいろいろあるんですが、この話にはあまり関係ないのであえて触れません。

queueは一般的なデータqueueタイプと同じFIFOで、実行する命令を順に入れていくものです。イベントループは、データ構造というよりqueueを監視して、よしなにCall stackに命令を積むためのプログラムです。(スライドを示して)右上のコードのように、無限ループの中でqueueにメッセージが来ていないかを確認しています。

そしてCall stackはLIFOで、すぐ実行するための命令が積まれていて、上から順に実行されていきます。即時実行と遅延実行で命令を積む場所、パスが分かれていて、同期処理の命令、例えば右側のコードの最初と最後の行はそのままCall stackに追加されてすぐ実行します。

この中間の2つの行の非同期処理の命令はqueueに積まれて、あとで実行できるようになっています。例えば通信の待ち時間の中でも他の処理を進めることができます。基本的にこういうふうに疑似的非同期を実現しています。

JSの非同期処理の歴史 非同期ライブラリでがんばっていた時代

:これまではブラウザ上でのJSの仕組みとか、どうやって非同期っぽいことを実現しているかを説明してきましたが、これからはその非同期処理をどうやって実現してきたか、歴史にしたがって現在までのやり方を説明していきたいと思います。この話は主に3つのフェーズに分けて話していきます。

まず標準化されていない非同期ライブラリでがんばっていた時代がありました。もともと初期のWebページは静的なコンテンツが中心で、多くの機能を持ったり、多くの内容を表示したりとかをそんなにしていなくて、わりと同期処理でなんとかなっていた時代がありました。

ユーザーがWebアプリに求める機能や利便性とかのニーズが徐々に高まって、例えば画面遷移せずに画面の一部だけ表示をするなど、Ajax通信が必要となりました。Ajax通信の実現に非同期処理が必要になって、それでさまざまな非同期ライブラリが登場しました。(スライドを示して)これらのライブラリは、赤い枠のような、コールバック関数をいろいろな非同期処理に使っていました。

そこで生まれた2つのつらみポイントがあります。まず、コールバック地獄という現象です。コールバック関数をいろいろな処理に使うので、次々とチェーンする必要もあったりして、コードの階層構造がけっこう早く、非常に深くなっていくことがありました。これは後ほど具体例を見せますね。

そして非同期ライブラリはどんどん数が増えていて、コールバックの使い方とか、引数の指定の仕方とか、エラー処理の仕方とか、いろいろなものの書き方が揃っていませんでした。

例えば、成功と失敗の処理はライブラリによって書き方が違っていて、まちまちです。さらに、ライブラリによって中間的なステップのインターフェイスがあったりなかったりして、コードがわりと読みづらい世の中がありました。こういうつらみポイントもありました。

(スライドを示して)まずコールバック地獄が、具体的にどういう状況かというと、こんな感じのものですね。この4つの非同期処理をチェーンしたいだけで、こんなにネストが深くなっていきます。ここにエラー処理を加えると、さらにさらに深くなります。

JSの非同期処理の歴史 Promiseオブジェクトの登場

:これらの問題をどうにかならないかというニーズに対して、Promiseオブジェクトというものが登場しました。Promiseが何かというと、非同期処理が完了した時に結果やエラーを返す、もしくは表すJSのオブジェクトです。

コンストラクターに正常時とエラー処理の関数を渡していて、(非同期処理を)呼び出す時に正常終了・エラー終了の時に呼ぶインターフェイスも用意されています。resolve、reject、then、catch。このPromiseというやつによって、上記の2つの問題はだいぶ解決しました。

例えばコールバック地獄ですが、Promiseのthen、catchのインターフェイスのおかげでどうにかなりましたね。非同期関数をthenで順にチェーンするだけで書けるようになって、先ほどのような深いネストはまずいらなくなりました。いくらチェーンしてもコードの構造が深くならず、読みやすいままですね。

書き方がまちまちな問題も解決してくれて、正常終了と異常終了のケースを超えて共通の書き方に統一したことによって、コードが読みやすくなりました。

あとはエラー処理が書きやすくなって、前は各コールバックの中でif elseをいっぱい積むことが必要でしたがそれも必要なくなって、チェーンしている動作の最後にcatchをチェーンするだけで、そのあとすぐにあるPromiseのどれかにエラーが起きていてもcatchして処理できるようになりました。すごく簡潔に書けるようになりましたね。

PromiseはES6以降はJSの標準仕様になっていて、これを使っているライブラリもいっぱい増えたから、読みやすいコードが世の中に増えて。幸せなことですね。

JSの非同期処理の歴史 async/await構文の登場

:さらにPromiseをわかりやすくした概念が出てきました。async/await構文です。実は厳密に言うと、これはただのPromiseのsyntactic sugarで、裏でバリバリPromiseで動いているんですね。それによってPromiseのインターフェイスを隠蔽して、インターフェイスが表に出なくなったことによって、thenやcatchすら書く必要がなくなったんです。

(スライドを示して)例えば、Promiseを使った書き方だと左側のコードのようにthenを変えていたんですが、ちょっと複雑に見えますよね。右側のasync/awaitではthenを完全になくすことができていて、右を見ていると非常に同期っぽく見えます。

実はこれはasync/awaitの強みの1つで、非同期処理を同期処理みたいに順に書けるようになっていて。読むとすぐに何をどの順でやっているかわかります。

async/awaitの戻り値はすべてPromiseなので、呼び出しの時にawaitすると「結果を待ってから次の処理を実行しましょう」という制御ができます。fetchDataの関数の中でfetch(url)、response.json()がawaitされているので順に実行されますが、async関数自体を呼び出している側の関数では、この関数の非同期で処理されています。awaitされないと、ただのPromiseと同じようにasync関数が非同期で処理されます。

async/awaitのもう1つの良さは、例外処理がさらに楽になったことです。普通の同期処理の関数で使っているようなtry catch構文を使って、Promiseでやっていたようなresolve、rejectなどの処理すら書く必要がなくなりました。めちゃくちゃ簡単ですね(笑)。

async/awaitは圧倒的に利便性が高い

最後に、橘の所感をいくつか話します。まずPromiseを使うならPromise.all()というさらに便利な書き方があります。同時にいくつかのPromiseを取ってきて、同じ処理の中で使いたい時にこのPromise.all()を使うと……。

例えば2番目のPromiseのほうが一番時間がかかっているもので、2番目のPromiseが終わったタイミングで全員分の結果が返ってきて、わりとすごく簡潔に書けるものがあります。

あと一番の感想は、async/awaitの圧倒的な利便性のことですね。同期っぽく上から順に書ける。あと、実は私はコールバック地獄とかPromiseをバリバリ最初から自分で書くことはあまりなくて、最初からasync/awaitを使っていたので、このJS非同期処理のいろいろなつらみを自分で踏むことなんかせずに済み、非常に恵まれている開発者生活をしています。先人たちに非常に感謝です。

最後に、他の発表にもあったように、このテーマについてもっと読みたい場合は『Tech It Up』に先輩の森下さんが記事を出しているので、ぜひご参照ください。

私からは以上です。ご清聴ありがとうございました。

質疑応答

司会者:橘さん、ありがとうございました。質問がいくつか来ています。こちらの3つですね。どうチョイスしましょう。あ、増えた(笑)。

:「つらみポイントわかりみ」。ありがとうございます。わかりみですね。

森下篤(以下、森下):感想がいくつか来ていますね。

司会者:そうですね。どれにいきますかね。

:「JavaScriptを実行できるブラウザは、スマホ、IE、Chromeなど複数の環境がありますが、すべてのブラウザでasync/awaitは使えるのでしょうか」。ごめんなさい。自分はChromeでしか書いたことがなくて。森下さん、わかったりします?

森下:現行で使われている、サポートされているブラウザでは使えます。async/awaitはほぼ使える状態だと言っていいでしょう。ただ、IEと書いてあるのが難点で、IEはPromiseすら使えないので、async/awaitも使えなかったりします。

:そうですね。モダンなブラウザではない(笑)。

森下:基本的には現行でサポートされているブラウザはasync/awaitをサポートしているので、使えると思っていいと思います。

:ありがとうございます。「ジェネレーター関数function*やyieldキーワードにも触れても良かったかも」。この内容は、本当は触れたかったんですよ。ただ時間が10分しかなかったのであえてというか。申し訳ないです。これも非同期処理に関する非常に根が深い概念です。

司会者:3つ目です。「JavaScriptはシングルスレッドしか使えないと、できないことが多いのでしょうか?」。こちらはいかがでしょう?

:これは森下さんわかったりします? できないことがあるかどうか。

森下:基本的にはそんなに困ることじゃ……。JavaScriptの世界でCPUをめちゃくちゃ使ってがんばることはそこまでないと思うので、実際のブラウザの中で描画をするというのは、JavaScriptのスレッドとは別の世界で実行されているから、そこの負荷通信を別に切り出すことができれば、基本的なシングルスレッドで問題ないということが多いと思います。ですが、CPUをめちゃくちゃ使おうと思うと、ハマるとは思います。

司会者:ありがとうございます。橘さんパートは以上としたいと思います。森下さんもありがとうございました。

:ありがとうございました。