イメージで伝われ!図解JavaScriptの非同期処理
ものすごい久しぶりのブログ更新になってしまいましたが、今回はJavaScriptの非同期処理について書いてみたいと思います。
このテーマはうまく説明できない部分が毎回ちょいちょいあるのですが、こうしてまとめることで頭の中が整理されていくということもあるので、最後まで頑張りたいと思います。
前提としてブラウザの実装の詳細や仕様については分からない部分も多いため、間違っている部分もあるかもしれません。
もし、お気づきの点などありましたら、教えていただけると助かります。
目次
- 目次
- 同期と非同期の比較その1
- 同期と非同期の比較その2
- 非同期API
- 非同期処理のユースケース
- コールバック
- Promise
- async/await
- 余談であり本題のRxJS
- 余談その2 React Fiber
- 余談その3
同期と非同期の比較その1
以下の2つの例はどちらも10msごとに1/100づつ背景の色を黒に近づけていくという処理が書かれています。
同期的な実行では背景は白から黒に段々と変化するのではなく、一気に黒に変化します。
それに対し、非同期的な実行では背景は白から黒へ段々と変化していき、なめらかにアニメーションします。
同期的実行
See the Pen 同期的 by Keita Okamoto (@all-user) on CodePen.
上の「実行」を押すと1秒ほど後に背景が突然黒くなることが確認できます。
それに対し、下の「実行」を押すと背景が白から黒へとなめらかに変化することが確認できます。
非同期的実行
See the Pen 非同期的 by Keita Okamoto (@all-user) on CodePen.
この2つの挙動の違いは以下のようになります。
同期的の例では、whileでループを回しつつ、その内部でDate.now()を実行し一定時間が過ぎていたら、処理を進める、というような書き方をしています。 この場合whileループを抜けるまでこの関数の実行は完了しないことになります。
同期的実行
JavaScriptはその関数が実行されている間、他の関数が並列で実行されることは絶対にありません。1
関数の実行に紐付いて画面の更新(再描画)もブロックされるため、whileループ完了後にはじめて再描画が走り、突然背景が黒くなったように見えます。
これはJavaScriptがシングルスレッド2であることと、コールスタック、実行キューの仕組みを考えると説明できます。
ある関数が呼ばれると図中右方向に伸びる実行キューに関数が追加されます。
実行キューは左から右に順に関数を実行していき、関数の処理に入るとその関数を抜けるまで他の処理が全てブロックされます。
シングルスレッドなので実行キューの関数を一つづつしか処理できないわけです。
最初に呼び出した関数の中でwhileループを回しているため、背景の色を黒に近づけていくという処理は全部で101回実行されていますが、その間ブラウザの再描画はブロックされ、関数の実行が終わったタイミングで再描画が走ります。
非同期的実行
それに対し、非同期の例ではsetIntervalによって関数が図中右方向にスケジューリングされています。
ここで言うスケジューリングとは、実行キューに関数を追加するタイミングを時間により指定しタイマーに登録することを指します。3
一つ一つの処理が別の関数として実行キューに追加されているため、全体の処理がブラウザの再描画をブロックすることなく、背景の色を黒に近づけるという処理が一つ終わるたびに関数を抜け、ブラウザの再描画を走らせることができます。4
このようにコールスタックに積まれるのではなく、図中右方向に伸びる実行キューへの関数の追加が行われ、一つの関数から別の関数へと処理がまたがって行われることを非同期処理あるいは非同期的実行という風に呼びます。
JavaScriptの非同期処理とはまさしくこの実行キュー内の関数をまたぐような処理のことを言います。
対して、最初のwhileを使った例のように、一つの関数の中で完結するような処理を同期処理あるいは同期的実行という風に呼びます。
この記事の中でも同期処理と非同期処理、同期的実行と非同期的実行という呼び方をします。
同期と非同期の比較その2
同期と非同期の動作をより詳しく見ていくために、別の例を見てみましょう。
同期的実行
function a(i) { if (i === 0) return; b(i) } function b(i) { c(i) } function c(i) { d(i) } function d(i) { e(i) } function e(i) { a(i - 1) } a(2);
上のコードを実行した様子を図にしたものが下の図になります。
関数の中で別の関数が呼ばれると、図中下方向に伸びるコールスタックにその関数が積まれます。
積まれた関数の中で更に別の関数が呼ばれると、その関数が更にコールスタックに積まれ、と続いていきます。
もうそれ以上呼ばれる関数が無くなると、最後に積まれた関数から順に関数の実行が完了していき、最終的に一番最初に呼ばれた関数に処理が戻り、そこを抜けると、はじめてブラウザの再描画が走ります。
このように、図中下方向に積まれていく処理は全て同期処理です。
コールスタック内で行われる処理は、どれだけ多くの関数呼び出しが行われたとしても5、それによって時間がかかったとしても同期的に実行されます。
非同期的実行
function a(i) { if (i === 0) return; b(i); } function b(i) { c(i); } function c(i) { d(i); } function d(i) { e(i); } function e(i) { requestAnimationFrame(() => a(i - 1)); } a(2);
同期的のコードとほぼ同じですが、function e
の内容だけ違います。
他の関数と違い、function e
の内部では直接関数を呼び出すのではなく、非同期APIであるrequestAnimationFrame
へのコールバックの登録という形でfunction a
を呼び出しています。
このコードを実行した様子を図にしたものが下の図になります。
function e
からfunction a
に処理が移る際、図中下方向のコールスタックに積まれるのではなく、図中右方向の実行キューに登録されていることが分かります。
このように、図中右方向の実行キューへと登録される処理は全て非同期処理です。
全ての非同期処理は非同期APIを介して処理を実行キューへ送り込むことによって実現しています。
クリックイベントのハンドリング、APIリクエストのレスポンス待ちなども全て非同期APIを介してコールバックが実行キューに送り込まれることで非同期処理が行われます。
クリックイベントであれば、クリックされたタイミングで実行キューにコールバックが登録され、APIリクエストのレスポンスであれば、レスポンスが返ってきたタイミングで実行キューにコールバックが登録されます。
並行処理と並列処理
こうしてみると、非同期と言いつつも実際には本当の意味で別々の処理が同時に実行されているわけではないという事が分かります。
あくまで、メインスレッドは実行キューに登録された関数を一つづつ実行することに徹し、同時に2つ以上の関数が実行されることはありません。
JavaScriptはシングルスレッドなので並行処理と並列処理で言うところの、並行処理しかできません6。
非同期API
setIntervalのようにスケジューリングを行ったり、実行キューへ関数を追加するような非同期処理を行うAPIのことを、非同期APIや非同期関数7と呼んだりします。
代表的な非同期APIには次のようなものがあります。
- window.setTimeout
- window.setInterval
- window.requestAnimationFrame
- EventTarget.addEventListener
- XMLHttpRequest
- window.fetch
- Promise
非同期APIが使われている場合、その処理は非同期処理8となります。
逆に言うと、非同期APIが使われない限り、どれだけ複雑で重い処理をしていたとしても、それは同期処理と言えます。
非同期APIの特徴
非同期APIによく見られる特徴として以下のようなものが挙げられます。
- コールバックを受け取る
- Promiseを返す
それぞれ、後に続く処理がコールスタックではなく、実行キューに追加される挙動を連想させるものです。
あくまでよく見られる特徴なので、コールバックを受け取ったり、Promiseを返したからといって、必ずしも非同期処理が行われるとは限りません。
その他の非同期API
少し脱線しますが、Zone.jsの非同期API一覧ページを見ると他にも色々なAPIが非同期処理を行っていることが分かります。
Zone.jsはAngularの内部で使用されているライブラリで、非同期処理の内部でコンテキストを形成するためのものです。
Zone.jsは非同期API全てに対しパッチを当てることで、この機能を実現しているそうです。
addEventListenerに渡したコールバックの同期的実行
addEventListenerは使い方によっては一つの関数のコールスタックから抜けることなく、同期処理となる場合があります。
const el = document.createElement('div'); console.log(1); el.addEventListener('click', ev => console.log(2)); el.click(); console.log(3); // 1 // 2 // 3
このコードのように、イベントの発火を同期的に行うと、コールバックも同期的に呼び出されます。
このことから、イベントハンドラの登録は同期的に行われており、非同期処理になるかどうかはイベントの発火タイミングが同期的実行か非同期的実行かの違いだということが分かります。
非同期処理のユースケース
ここまで非同期処理の仕組みについて見てきましたが、今度は非同期処理を伴う具体的なユースケースを見ていきたいと思います。
非同期処理が必要なシーンにはどんなものがあるでしょうか?以下に代表的なものを挙げてみます。
こうしてみると、何を作るにも何かしらの非同期処理が発生するということが分かります。
ユーザーがキー入力をするまで、処理がすべてブロックされて画面も固まってしまう、というようなことがあっては困るわけです。
これらの非同期処理を扱うための様々な方法が、これまでに発明され発展してきました。
コールバック
非同期で実行してほしい処理を関数として引数に渡す方法です。
// DOM構築が完了したらcallbackの部分が実行される document.body.addEventListener('DOMContentLoaded', () => { /* callback */ }); // someButtonがクリックされたらcallbackの部分が実行される someButton.addEventListener('click', () => { /* callback */ });
一番スタンダードな方法ですが、処理が複雑になってくると管理が難しくなるという問題があります。
特にNode.jsでは多くのAPIが非同期APIなため、少し長めの処理を書いただけでコールバックがどんどんネストしていき、コールバックヘルと呼ばれる状態になってしまったりすることがあります。
callback hellで検索するとこんな感じの画像がたくさん出てきます🔥
Promise
Promiseの詳細な説明はここでは省略しますが、イメージで言うと、その時点では扱えないような値を、さも扱える状態であるかのように(またはその時点では完了していない待機中の処理が完了しているかのように)抽象化したオブジェクト、という感じでしょうか。
コールバックとの違いについて考えてみます。
Promiseも引数に処理を渡しているという点ではコールバックと言えます。
const p = new Promise((resolve, reject) => { setTimeout(resolve, 10000); }); p.then(() => console.log('10秒経ちました'));
一番の違いは、Promiseは「さも扱える状態であるかのように抽象化したオブジェクト」であるという点です。
コールバックと違い、Promiseはその時点で扱えるか分からない値(または待機中の処理が完了したという状態)をオブジェクトとして扱うことができるため、配列に突っ込んだり、オブジェクトの値にしたり、イベントのペイロードとして送ったり、自由に扱うことができます。
コールバックの場合、あくまでそのコールバックの中でしか、その値が扱える(または待機中の処理が完了している)ことを保証できません。
const p1 = new Promise(resolve => setTimeout(resolve, 10000) // 10秒経ったら ); const p2 = new Promise(resolve => document.body.addEventListener('click', resolve) // document.bodyがクリックされたら ); const p3 = new Promise(resolve => window.addEventListener('load', resolve) // windowのloadが完了したら ); Promise.all([p1, p2, p3]).then(() => console.log('10秒経っていてかつ、document.bodyがクリックされていてかつ、windowのloadが完了した状態') );
Promiseの登場により複雑な非同期処理をコントロールすることができるようになりました。
また、複雑な非同期処理を扱うための標準的なAPIが提供されたことで、非同期処理へのアプローチが統一されたという点も大きいと思います。
Promiseの詳細についてはJavaScript Promiseの本がおすすめです。
async/await
Promiseの登場でいくつかの問題は解消されましたが、PromiseのAPIはコールバックを渡すという点ではそれまでと同じように実行順序が分かりにくかったり、処理を追いにくかったりという部分がありました。
async/awaitはJavaScriptの構文レベルでPromiseをサポートしてしまおう、というものです。
その内容は、Async Functionという必ずPromiseを返す関数の導入と、Promiseが解決されるまで処理を中断し、解決されたら処理を再開することができるawait演算子の導入です。
この「処理を中断する」というのはこれまで説明してきた同期的実行のブロッキングとは異なり、中断した時点で関数を抜け実行キューを先に進めつつ、解決された時点で続きの処理が実行キューに登録されるといった形になります。
function a() {} function b() { return new Promise(resolve => setTimeout(resolve, 1000)) } function c() {} const fn = async () => { a(); await b(); c(); } ; fn();
上のコードを実行した様子を図にしたものが下の図です。
async/await はPromise APIのシンタックスシュガーと言っても良いかもしれません。
上記のコードをPromiseで書くと下のようになります。
function a() {} function b() { return new Promise(resolve => setTimeout(resolve, 1000)) } function c() {} const fn = () => { a(); b().then(() => { c(); }); } ; fn();
Promiseは様々な非同期処理を行うための汎用的なインターフェイスとなっていますが、実際には多くのケースで、単純にその処理が終わるのを待ってから次に進みたい、逐次的に書き下せれば十分という側面があります。
async/awaitはこのように非同期処理を逐次的に書くのに打って付けのシンタックスで、最近では様々なライブラリでasync/awaitを想定した利用方法が解説されています。
余談であり本題のRxJS
はい!ここまで長かった、ここからが余談であり本題です。
JavaScriptの非同期処理についていろいろ書いてきましたが、このRxJSのことを書きたかったからなんですね。
自分はプロダクションでの運用経験は無いのですが、数年前に存在を知って以来9注目していて、どんな用途で使うと効果的だろう、みたいなイメージを膨らませていたりします。
そんな状態ではありますが、RxJSについても触れておきたいと思います。
RxJSのObservableの考え方1
まずはざっくりとRxJSのイメージを掴みたいと思います。
下の表はRxJSの公式ページに載っているものです。
https://rxjs-dev.firebaseapp.com/guide/observable
Single | Multiple | |
---|---|---|
Pull | Function | Iterator |
Push | Promise | Observable |
Observableはこの表にあるように、複数の値をPushすることができるPromiseと言えます。
Promiseは一度値が解決(Resolved)されると、それ以降同じコールバックが呼ばれることはありません。Observableは複数回値を流すことができます。
このことはよくイベントストリームであるとか、値が次々に流れてくる様子が川のようである、という風に例えられたりします。
Promiseのことを「その時点では扱えないような値を、さも扱える状態であるかのように(またはその時点では完了していない待機中の処理が完了しているかのように)抽象化したオブジェクト」と紹介しましたが、Observableはそれに似ている部分があります。
Observableは上述したように、値が次々に流れてくる川の流れのような時間軸を、そのまま抽象化したオブジェクトと言えるかもしれません。
RxJSを使うと、その川の流れのようなオブジェクト同士を、つなげたり、束ねたり、分岐したり、フィルターしたりできます。
RxJS の Observable の考え方2
この表も以前RxJSの公式ページに載っていたものです(記憶をもとに書いているので実際と少し違うかもしれません)。
この表現もObservableのイメージを掴む上でとても役に立つと思います。
Single | Multiple | |
---|---|---|
Sync | value | Array |
Async | Promise | Observable |
厳密にはObservableは同期的に値を流すこともできます。そのためおそらく表現が変わったのだと思います。
面白いのはArrayとObservableの比較で、同期がArray、非同期がObservableとなっています。
実際、ObservableにはArrayにあるメソッドと同名のメソッドがいくつかありますし、挙動も似ています。
- map
- filter
- reduce
この3つのメソッドはObservableにも存在します。
非同期なイベントの連続を、配列を操作するかのように扱える、というイメージです。
ダブルクリックのデモ
https://codepen.io/all-user/live/ppXrbd
このデモは、RxJSを使ってダブルクリックを判定する様子を可視化したものです。
上から縦に4つのObservableが並んでいて、Observableを生成するコードとそのObservableに値が流れる様子を可視化しています。
このRxJSのエディターを使っています。
RxJSの使い所
じゃあこのRxJSは一体何に使うべきなのか、という話になると思うのですが、正直本格的なアプリケーションをRxJSを使って書いた経験がないので分かりません。
分からないのですが、もし自分がGoogle Mapのようなアプリケーションを実装するとしたら、RxJSを採用すると思います。
ユーザーのインタラクション、画像やデータのロード、拡大縮小、座標の移動など、色々な変数が複雑に絡み合い、それぞれが非同期で発生するため、それらをハンドリングするのにRxJSが役立ちそうです。
他にもゲームやエディタなどの複雑なユーザーインタラクションをハンドリングするのにも向いていると思います。
また、AngularはこのRxJSがフレームワーク自体に組み込まれています。 Angularは通常の値、Promiseに加えこのObservableがサポートされているAPIが多くあります。
余談その2 React Fiber
Reactがバージョン16に上がる際、Reactの内部実装がガッツリ書き直されるということがありました。
具体的には仮想DOMを更新してUIに反映するアルゴリズムが変更されました。それがReact Fiberです。
その前後の様子を比較した分かりやすい例が下のツイートにあります。
The Fiber Triangle demo now lets you toggle time-slicing on and off. Makes it much easier to see the effect. Thanks @giamir for the PR! 🎉 pic.twitter.com/qhsWUIyXPf
— Andrew Clark (@acdlite) March 27, 2017
動画を再生すると、初めはReact Fiberでレンダリングされた様子が映り、その後React Fiberをoffにすると画面の更新がカクつく様子が見て取れます。
React Fiberが入る前は、仮想DOMの更新を全て同期的に行っていたため、この記事でも紹介したように、その間レンダリングがブロックされてしまい、画面がカクつくようなことが起きていました。(けっこう重めのアプリケーションでないと体感的には分からないレベルだったかもしれません)
下の2つの画像はReact Fiberが入る前後でレンダリング時のコールスタックの様子を比較したものです。
React Fiber前
React Fiber後
この記事で紹介した、同期と非同期の図に似ていると思いませんか?
実はこれは上の画像に似せて描いたからです。似ていると思っていただけたら幸いです。
同期的実行
非同期的実行
Reactの内部実装でも重い同期処理によるブロッキング問題を抱えていて、その処理を分割して非同期で処理するようにしたら改善したよ、ということがあったのです。
JavaScriptの非同期処理を理解する上での一つの良い例かと思います。
React Fiberの記事
React Fiberについての詳細は下の記事が分かりやすかったです。
余談その3
2017年11月からkurashiruを運営するdelyでフロントエンドエンジニアとして頑張らせていただいております。
そのdelyで開発部LT大会というものが開催されたことがありました(もう一年以上前だった)。
各々自由なテーマで話すという趣旨で、それぞれトークテーマを持ち寄りワイワイやるという感じで、とても楽しかったです。
そのとき自分はJavaScriptの非同期処理とRxJSについて話したのですが、この記事はその時の資料をベースに書き足していったものです。
LTの持ち時間は10分だったので、当然深ぼった話は出来ず、めちゃくちゃ駆け足かつフィーリングでこうっ!みたいな感じになってしまいました。
その時話せなかったことをガッツリ書いてみようと思い立って書いてみた次第でございました。
久しぶりにCODEPENのデモを動かしてみるとRxJSのimportが壊れていて動かなくなっていたので、修正を兼ねてversion 6へのアップデートを行いましたが、APIのインターフェイスが大きく変更されているのに驚きました。
pipeを使った書き方は、きっとパイプライン演算子が来ることを見越した設計なんだろうなと思います。
パイプライン演算子も楽しみですね!
-
正確にはシングルスレッドで動作している範囲においては並列で実行されることはありません。Web Workers APIのような例外は除きます。https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API↩
-
ブラウザのメインスレッドはUIスレッドとも呼ばれ、DOMの更新や画面の再描画なども行っているため、例で示したようなブロッキングが発生します。↩
-
関数を実行するタイミングではなく、実行キューに関数を追加するタイミングなので注意が必要な場合があります。指定された時間に必ず関数が実行されるとは限りません。↩
-
実際にはコールスタックに積める数には限りがあり、それを超えるとエラーになります。それがいわゆるStack Overflowであり、おなじみのエラー文"Maximum call stack size exceeded.“です↩
-
正確にはシングルスレッドで動作している範囲においては並列で実行されることはありません。Web Workers APIのような例外は除きます。https://developer.mozilla.org/ja/docs/Web/API/Web_Workers_API↩
-
非同期関数という呼び方はES2017で入ったAync Functionのことを指して使用されている例もみるので、個人的には非同期APIという呼び方を使っています。https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Statements/async_function↩
-
TargetElement.addEventListenerの場合は同期処理となる場合もあります。詳細は後項を参照のこと。↩
-
このブログへいただいたコメントがきっかけでした。http://b.hatena.ne.jp/entry/190576326/comment/masaru_b_cl↩