Aikの技術日記

技術的な進捗とか成果とかを細々と投稿するブログです。時々雑記も。

JSの非同期処理の仕組みをまとめてみる Part1

はじめに

お久しぶりです、筆者です。

ここ最近もまた、ブログ記事書きから離れた生活を過ごしていました…。
なんなら技術勉強からもいつの間にか距離を置いていたなと。

ただ最近、JavaScriptのPromise辺りの知識に触れる機会がありまして。
そこでMDNのPromiseの記事を見漁ったのですが…。
developer.mozilla.org

上記記事内にある「マイクロタスク」という単語が気になり、さらに深掘りしていったところ…。
めちゃくちゃ訳がわからなくなりまして。

最終的には「JavaScriptでの非同期処理」のお話に行き着いたのですが…筆者の頭はパンク状態もいいとこでした。
「これはブログ記事なりにまとめないと一生わかんないままかも」と感じ、記事にした次第です。

というところで、本記事では:

  • JavaScriptの非同期処理の種別
  • JavaScriptにおける非同期処理について
  • 非同期処理その1setTimeoutについて
  • 非同期処理その2Promiseについて

…を中心にまとめていければと。
例によってクソ長くなったので、今回のPart1では「非同期処理その1setTimeoutについて」までを取り上げます。

それではいきましょう٩( ‘ω’ )و

JavaScriptの非同期処理の種別

まず、JavaScriptにおける非同期処理の方法についてまとめます。
JavaScriptで非同期処理を用いるには、下記の2種類を使うことができます:

  1. setTimeout: 時間を指定し遅延処理させられる
  2. Promise: とある処理完了後に行いたい遅延処理を定義できる

なお、2番目のPromiseECMAScript (ES6)から導入された概念なので…。
IEなどのモダンブラウザ外対応をする際はPromiseは使えません。
※どのブラウザが使えないかの対応表はこちら(Can I Use)

また、この2種類は内部的な処理の仕組み(どうやって非同期処理を実現しているか)が微妙に異なるので…。
本記事では、上記2つは分けて説明していこうかと。

JavaScriptにおける非同期処理について

まずはsetTimeoutから話していく…前に、JavaScriptにおける非同期処理のお話から。

そもそものお話なのですが、JavaScriptシングルスレッドが前提となっています。
シングルスレッド、つまり「同時に1つの処理(実際には関数)しか行えない」ということなので…。
原理的には「JavaScriptで同時に複数の処理を行う」ことは不可能なのです。

しかし、昨今の計算機スペックの増加により…シングルスレッド上でプログラムを動かすというのはなくなってきました。
現代ではPCやスマホでさえもマルチスレッド処理、つまり「同時に複数の処理を行う」ことが実行可能となっています。

…が、この仕組みはJavaScriptにとっては良いものではありません。
JavaScriptはシングルスレッド前提で動かすのが前提であるため、マルチスレッド環境で動かしても「1つのシングルスレッドを占有して処理を行うだけ」になってしまいます…。

これではマルチスレッドの利点を十分に活かせないどころか、1つのスレッドが占有されてしまい、そのスレッドでは他の処理ができなくなってしまいます…。

上記を解消する仕組みとして、JavaScript実行環境(ブラウザ、Node.jsエンジンなど)では「コールスタック」というものが手配されています。
これらがどのようなものかというと:

  • 関数実行時は必ず「コールスタック」にPushされる
    • 関数内にさらに実行関数がある場合は、「コールスタック」にその関数がPushされる
  • 処理を実行し終えると「コールスタック」から処理内容が取り出される

例えば下記の様なJSコードの:

function hoge(){
  piyo(); // ①
}
function piyo(){
  console.log("piyo");
}

hoge();

(1)時点でのコールスタックのイメージはこんな感じとなります:

| piyo() |  
| hoge() |  
ーーー

スタックなので上から順番に処理されるため、まずpiyo()が実行されhoge()が実行される、という形になります。

これにより、処理を行うスレッド側では「スタックの一番上から実行していけばいい」という形になるので…。
マルチスレッド環境であっても、動くスレッドの数はいつでも1つに固定化できますし、処理の順序も保証されます。

小咄: Stack Overflowとは JavaScriptをやっていると、「Stack Overflow」というエラー文を見受けることがあるかと思います。
このエラーは「コールスタック」にまつわるエラーです。

例えば下記の様な無限ループする関数だと:

function callMyself(){
  callMyself();
}
callMyself();

コールスタックの中身は:

| callMyself() |
| callMyself() |
| … |
| callMyself() |
| callMyself() |
ーーー

…となり、callMyselfという関数がコールスタックに延々詰められてしまいます。

コールスタック領域は計算機上にあるためもちろん有限であるため…上記の様に延々コールスタックが積まれてしまうと、「スタックに割り当てられていた領域」から溢れてしまいます。
これが「Stack Overflow」=「スタック溢れ」というエラーとなります。

…しかし、「コールスタック内部に詰められた順に処理するだけ」という単純なこの仕組みでは非同期処理を行うことができません。
実際、JavaScriptの言語体系のECMAScriptには「非同期処理」ができるsetTimeoutと言った関数は定義されていません。
※ES6からはPromiseといった非同期処理が前提の処理の定義はありますが、それは後ほど説明します

では、どうやって非同期処理を行なっているのか…。
次の章から深掘りしていきましょう。

非同期処理その1setTimeoutについて

JavaScriptの非同期処理のsetTimeoutですが、先ほど話した様にECMAScriptには定義されていません。
これはあくまでJavaScript実行環境上に手配されているものです。

setTimeoutはWeb APIsのブラウザーAPIの1つTimerで定義されているものです。
詳細: HTML Standard
そして内部的には「イベントループ」の仕組みを用いて非同期処理が行われています。

…また初出の単語が出てきましたので、軽く解説します。

Web APIsとは

Web APIsをざっくり話すと「JavaScript言語の範疇を越え、各種クライアント(ブラウザなど)の実装が楽になる様にさまざまなところから提供されているAPI」のことです。
Web APIsの分類は大きく分けて2つあり:

…となっています。
※参考: Web API の紹介 - ウェブ開発を学ぶ | MDN

例えば、先ほど言ったTimer(setTimeoutの上位存在)はブラウザAPIに属します。

Web APIsについては深掘りするととても長くなるので…。
詳細については本記事の余談に書いておきました。もしよければご参考に: 余談:Web APIsについてもっと詳しく

イベントループとは

イベントループとは「コールスタックが空になる度に」「タスクキューからのコールバック関数をコールスタックへ取り出す」処理のことです。
ちなみに「タスクキュー」というのは、ざっくり話すと「一部の関数が処理を遅延実行させるために使用する場所」です。

…用語の説明としては以上なのですが、特にイベントループは言葉だけじゃわかりにくいですよね。
というわけで、お次は実際の処理を追いながら説明していこうかと。

どの様に非同期処理が行われているのか

例えば下記の様なsetTimeout入りの処理があるとして:

function hoge(){
  console.log("hoge");
}
function piyo(){
  hoge();
  setTimeout(function(){ // ②
    console.log("setTimeout piyo");
  }, 1000)
}

piyo(); // ①

(1)時点でのコールスタックのイメージはこんな感じとなります…ここまではこれまでと同じです:

| piyo() |
| hoge() |
ーーー

お次に(2)、つまりsetTimeout関数まで来た場合…コールスタックのイメージはこんな感じとなります:

| setTimeout([コールバック処理]) |
| piyo() |
| hoge() |
ーーー

そしてsetTiemout関数が実行されると、内部にあるコールバック関数、第2引数のミリ秒数がWeb APIsのTimerへ渡されます
この時点でsetTimeout関数の処理は実は完了されてしまいます。
このため、setTimeout関数実行後、コールスタックは第2引数のミリ秒数分を待たずして空っぽとなります。

ではsetTiemout関数のコールバック関数がどの様に実行されるかを見てみましょう。

先程述べた「コールバック関数と第2引数のミリ秒数がWeb APIsのTimerへ渡された」ところから話します。
Timer内部では第2引数を元に、今回だと1000ms待ってから…。
コールバック関数を「タスクキュー」の末尾に追加します。

ここで、先ほど話に出したイベントループの出番です。
おさらいすると、イベントループとは「コールスタックが空になる度に」「タスクキューからのコールバック関数をコールスタックへ取り出す」処理のことでした。

この段階で「コールスタックは空になっている」「タスクキューにコールバック関数が格納されている」ため…。
イベントループが反応し、タスクキューからコールバック関数をコールスタックへ取り出します。
後は、コールスタック内部に関数があることで、処理が呼び出され終了です。

…処理の流れを文面で書いてみましたが…非常にわかりづらいかもですね。
もしわかりづらい場合は、上記処理をなんとGIFアニメーションでわかりやすくまとめてくれている記事がありますので、そちらも参照してみてください。
coliss.com ※上記処理の流れも、この記事を見ながら作りました

ともあれ、setTimeout関数の内部ではこの様な処理が行われています。
このためsetTimeout([function], 0)という様な「0秒後に即時に動いて欲しい」という処理を書くと、こんなことも。

ブラウザは、内部的にタイマーの最小単位(Tick)を定めて管理するため、実際には、その最小単位を超えた後にタスクキューに追加されるようになります。
そしてこの最小単位は、ブラウザごとに少しずつ異なります。
例えばChromeブラウザの場合、最小単位として4msを使うため、ChromeでsetTimeout(fn, 0)は、setTimeout(fn, 4)と同じ意味を持つようになるでしょう。
参考: JavaScriptとイベントループ | NHN Cloud Meetup

また、イベントループの仕組み上「コールスタックが空でないと」タスクキュー内部のコールバック関数を実行できません。
これにより、例えば下記の様な処理だと:

function foo() {
  console.log('foo has been called');
}
setTimeout(foo, 0);
console.log('After setTimeout');

出力はこうなってしまいます。

After setTimeout
foo has been called

この様に「意図した遅延ができない場面」というのは多々発生してしまいます…。
とはいえ、上記の様なことを考慮する必要がある場面は少ないでしょうが。

この辺りについて、もっと詳しくみたい場合はこちらの記事をご参考に:
developer.mozilla.org

小咄: イベントループとJavaScriptとの関連性

JavaScriptという言語自体を調べても、おそらくイベントループに言及されている文献は少ないと思います。
それはなぜかというと「ECMAScriptにイベントループの内容がないから」です。
ECMAScriptとは: JavaScriptの基本仕様が定められた仕様書の様なものです。

イベントループについて調べる際は、JavaScriptの実行環境という箇所に着目して調べた方が効率的かもしれません。
逆に「JavaScriptというプログラミング言語」と「イベントループ」というお話がごっちゃになっている文献は、根本概念の理解が甘い可能性があります。
(筆者のブログ記事にもいっぱいそういうのはありそうです…orz)

まとめ&おわりに

本記事では、JavaScriptにおける非同期処理の大枠とsetTimeoutでの非同期処理の仕組みについてまとめました。
色々話してしまいましたので、軽くまとめておきます:

  • JavaScriptはシングルスレッドで動くのが前提の言語である
  • JavaScript自体には非同期処理を行う機構はない
    • そもそもECMAScriptに非同期処理関連のお話がない
    • ※ES6から追加されたPromiseを除く
  • 非同期処理のsetTimeout関数はJavaScriptではなく、JavaScript実行環境に用意されている
  • setTimeout関数はイベントループとタスクキューによって非同期処理ができている

ここまでで非同期処理としてフォーカスを当てたのはsetTimeout関数のみ、つまりES6以前のJavaScript環境のお話でした。
ただ、ES6からはPromiseという新たな非同期処理ができるものが用意されています。

こちらはこちらで、また違った処理となっているため…次回記事ではそちらについて深掘りしていきます。
それでは( ˘ω˘ )

参考記事

今回の記事は下記の記事をもとに、自分なりに理解しまとめたものになります。
(というより下記の記事を見漁って頭が爆発してわからなくなったのでじっくり1記事にまとめようと決心しまとめたものです)

下記の記事を熟読すればさらに深い知識が得られるでしょう…。
筆者が読んでいった順に上から並べているので、ぜひ沼に入ってみてください。

余談:Web APIsについてもっと詳しく

MDNさんを引用して解説すると:

クライアントサイド API では、実際非常にたくさんのAPIが使えます — それらは JavaScript 言語本体の一部ではなく、あなたにスーパーパワーを与えるべく JavaScript 言語のコアの上に築かれた代物です。
それらはおおよそ二つのカテゴリに分けられます:
ブラウザー API は Web ブラウザーに組込まれていて、ブラウザーやコンピューターの環境の情報を取得し、これを使って役に立つややこしい事を行えるようにするものです。
サードパーティ API はデフォルトではブラウザーに組込まれておらず、普通はコードと情報を Web のどこから読み込まねばなりません。例えば Twitter API を使えばあなたの Web サイトにあなたの最新のツイートを表示するような事が可能になります。
参考: Web API の紹介 - ウェブ開発を学ぶ | MDN

…とのことです。
正直筆者は、setTimeoutJavaScript言語外のものであることを知らなかったので…。
意外なものがこの「ブラウザーAPI」に内包されているかもしれませんね。

もっと詳しくまとめようか…と思いましたが、それだけで1記事できそうなのでこの辺でやめておきます…。
詳しく見たい場合は、引用文にも使わせていただきましたMDNさんの記事を参考に:
developer.mozilla.org

余談:遅延が短いsetTimeout

JavaScriptsetTimeoutは、時たま下記の様な「遅延を短くするために実行される」ことが多い様です:
(筆者は残念ながら見かけたことはありませんが…)

setTimeout(()=>{
  //  処理
}, 0)

しかし内部の「処理」が0秒、つまり即時実行されることはありません。
JavaScript実行環境(つまり各種ブラウザ)によってまちまちですが、基本は4msくらい待ってから実行されることが多い様です。

この話題は「遅延が短いsetTimeout」として、取り上げている文献も多いとか:
David Baron's weblog: setTimeout with a shorter delay
WindowOrWorkerGlobalScope.setTimeout() - Web API | MDN

ただ、この現象がなぜ起こるのかというのも…。
setTimeout関数は一度タスクキューに格納されてからのちに実行される」というのを理解した状態だと、違った推察ができるかもしれません。

筆者なりの考察はありますが、それを裏付ける公式ドキュメントを提示することも実際の検証もできてないので、ここでは披露しないでおきます…。

余談:nginx,C10K問題との関連性

※ここからはJavaScriptのお話とは逸れたものです

シングルスレッド前提な言語JavaScriptを効果的に運用できる仕組みとして提案されたシングルスレッドですが…。
こちらはJavaScript実行環境の1つの「Node.js」や、当時Apache HTTP Serverと比較される事が多かった「nginx」でも採用されています。

※こんな記事を見に来るくらいならNode.jsのお話は馬の耳に念仏だとは思いますが…。
ざっくりNode.jsを説明すると「サーバーサイドで動くJavaScript」です。

シングルスレッドをサーバー側の機構が採用することにより、それ以前のサーバーでは有名な問題であった「C10K問題(クライアント数10000台問題)」に対応可能になりました。
C10K問題は何かというと:

Apache HTTP ServerなどのWebサーバソフトウェアとクライアントの通信において、クライアントが約1万台に達すると、Webサーバーのハードウェア性能に余裕があるにも関わらず、レスポンス性能が大きく下がる問題である。
参考: C10K問題 - Wikipedia

…というものです。
詳しくはこちらの参考に(めちゃくちゃわかりやすくまとまってます):
knowledge.sakura.ad.jp

Apacheとnginxについてはメリットとデメリットを比較された記事が多いですが、Nginxのメリット&デメリットの根底は「シングルスレッド機構である」という部分から派生している様にも感じるので…。
(とはいえ今はNginx一強なイメージもありますが…)
本記事を見た後にNginxやNode.jsの記事を見ると面白い発見があるかもしれません。