JavaScriptの非同期処理をできる限り正確に理解する

JavaScript

プログラミング初心者が詰まりやすいポイントとして、非同期処理はよくあげられるかと思います。実際初心者にとっては概念自体もわかりにくいし、なぜか記事によって言い回しやら説明の仕方やらが違うので調べれば調べるほど混乱してきて、心が折れそうになります。
そもそも言語によってシングルスレッドだったりマルチスレッドだったりと仕様は大きく異なっています。それによって非同期処理の仕組みだって異なってくるのに、全てをまとめて説明するのは無理があります。

そこで今回は、言語をJavaScriptに絞り、なるべく公式リファレンスに基づいた解説記事を書いていきたいと思います。
この記事によってなんとなくでも「非同期処理の本質」を掴めたら、今後swiftでもPHPでも、他の言語における非同期処理を仕組みを理解することは比較的楽になるかと思います。

できる限り正しい説明を心がけますが、理解が追いついていないせいで間違った記述をすることは大いにあり得るのであくまでこの記事は参考程度にとどめておいた方が無難かと思われます!

簡単のため実際のコーディングには純粋なJavascriptを、実行環境としてはブラウザ(GoogleChrome)を用いることにします。

非同期処理とは?

そもそも非同期処理とはどういう仕組みだったでしょうか?

普通、プログラムはコードが書かれた順番に上から実行されていきます。前の行の処理が完了してはじめて次の行の処理が実行されるわけです。
このような、書かれた順番通りにプログラムが実行されていく仕組みを「同期処理」と言います。

しかし、このような仕組みではひとたび「実行に時間のかかる処理(大抵は通信を行ってデータを取ってくる処理)」がプログラムの途中に入ってきた途端、プログラム全体の実行完了に多くの時間がかかるようになってしまいます。
これでは最近のせっかちなスマホユーザーにとって、非常に心象がよくないアプリケーションになってしまいます。
例えばサーバーからデータを取ってくるのに時間がかかってしまったとき、UIが長いことフリーズしたままではUXを大きく損ねますよね。UIは固めず、ローディングスピナーなどのアニメーション処理を先に実行させておくことでユーザーの機嫌をとりながら、サーバーからのデータの取得が完了したら改めてUIの描画処理を実行するのがスマートです。

この時間のかかるような処理は裏側で実行させつつ、完了を待たずに次の処理の実行を進めていき、その処理が完了したタイミングでまた好きな処理を実行させる、という理想を実現する仕組みが「非同期処理」です。

JavaScriptの仕様

非同期処理について詳しい説明に入る前に、JavaScriptにおけるプログラム実行の仕組みについて知っておく必要があります。

シングルスレッド

JavaScriptでは、プログラムの実行はシングルスレッドで行われます。JavaScriptに限らず、多くのGUIフレームワークではシングルスレッドでプログラムが実行されます。
シングルスレッドに関する解説はIT用語辞典を読んでサクッと理解しましょう。
JavaScriptのプログラムはシングルスレッドにおいて、関数単位で実行されるのが特徴です。

蛇足ですが、JavaScriptがなぜシングルスレッドなのかというと主に2つの理由があります。

  1. 過去の技術的資産の多くがシングルスレッド前提で作られていること。
  2. マルチスレッドでは順番を保持したままロックをかけることが非常に困難であること。

です。(しかし最近では、ServiceWorkerというツールを使ってJavaScriptもマルチスレッド化できるようになったようです。)

JavaScriptはシングルスレッドで動き、基本的にブラウザのメインスレッド(UIスレッド)のみで実行されます。メインスレッドは表示の更新やスクロールなど、UIに関する処理も行なっているため、同期処理で時間のかかる処理が挟み込まれると、ブラウザのUIが固まってしまうのです。

JavaScriptインタプリタの仕組み

JavaScriptでは、JavaScriptインタプリタというヤツがコードを読み取ってプログラムとして実行してくれます。

さて、JavaScriptインタプリタはシングルスレッドで動くので、2つ以上の処理を同時に実行できません。
しかし、非同期処理の仕組みは「処理Aの完了を待たずして、処理Aの実行は継続しつつ次の処理Bに進む」といったものでした。
これでは処理Aの実行と処理Bの実行が同時に起こってしまい、シングルスレッドでは処理しきれなくなってしまうはずです。

JavaScriptインタプリタは、このような問題を同期関数・非同期関数とキュー・スタックを使ったモデルによって解決しています。

キュー・スタックとは

キューとスタックは、データ構造の1種です。
データ構造とは、データの出し入れの方法のことです。

キューにデータを入れると、データを入れた順番通りに、データが取り出されます。
一方スタックにデータを入れると、データを入れた順番と逆順に、データが取り出されます。

図で示すとこうなります。

JavaScriptインタプリタによるプログラム実行のモデル

JavaScriptインタプリタによるプログラムの実行(ランタイム)は、タスクキュー・コールスタック・ヒープの3領域+外部のWebAPIにおいて行われます。
コールスタックには関数が、ヒープには変数など諸々のデータが格納されます。タスクキューには後ほど説明する、非同期関数のコールバック関数が登録されていきます。
コールスタックには関数が次々と追加されていき、関数が実行終了するごとに取り除かれていきます。

JavaScriptにおける関数の中には、WebAPIを使用して処理を行う特殊なものがあります。
たとえば、setInterval関数やsetTimeout関数はTimerAPIを使い、addEventListenerなどのイベント系関数はDOMAPIを使います。

これらの関数は、与えられた処理内容を外部のWebAPIにいわば投げてしまいます。外部WebAPIに「これをやってくれ!」と頼んだあとは、関数としては実行終了してしまい、コールスタックから取り除かれてJavaScriptインタプリタは次の関数の実行を進めていきます。
JavaScriptインタプリタがどんどん次の関数を実行している間にも、処理を任せられたWebAPIの方は独自に処理を実行し続けています。これらの関数は、実行時点で引数にコールバック関数を取ることが多いかと思います。WebAPIの方で処理が完了した時、指定しておいたコールバック関数はタスクキューに追加されます。そして、コールスタックの中身が空になった(コード上の全ての関数を実行しおわった)とき、イベントループによってタスクキューに登録されている関数がコールスタック上に追加され、実行されます。

このように、処理をWebAPIに任せる関数を使うことで「2つ以上の処理を同時に進め、処理が完了したタイミングで指定した処理を実行する」非同期処理が実現できます。JavaScriptはシングルスレッドなので、別のスレッドを立ててマルチスレッド的に並列処理をしているわけではなく、WebAPIさんという「他の人」に代わりに処理を実行してもらうことで擬似的に並行処理を行なっているのがわかるかと思います。

このような動作をする特殊な関数たちを、非同期関数と呼ぶことにします。
逆にJavaScriptの世界の中だけで処理を実行し、1つの処理が終わるまでは次の処理を進めることができないような普通の関数を、同期関数と呼ぶことにします。

これら同期関数と非同期関数がどのように動いているのか、順を追ってみていきましょう。

同期関数のみの場合

同期関数しかコード上にない場合は、基本的に以下のように処理されていきます。

  1. コードが上から順番に実行されていきます。
  2. 同期関数(関数Aと呼ぶ)が呼び出されているコードを発見すると、一旦止まって(ここでいう「止まる」とはコードを1行ずつ実行していくのを止めるということです)関数Aをコールスタックに追加します。
  3. 関数Aの中のコードを1行ずつ実行していきます。
  4. 関数Aの中のコードでまた同期関数(関数Bと呼ぶ)の呼び出しを発見したら(いわゆるコールバック関数などが存在する時です)また一旦止まって関数Bをコールスタックに追加します。
  5. 関数Bの中のコードを1行ずつ実行します。
  6. 関数Bの中のコードを全て実行し終えると、コールスタックから関数Bを削除して関数Aの中のコードに戻ります。
  7. 関数Aの続きのコードを実行していきます。
  8. 関数Aの中のコードを全て実行し終えると、コールスタックから関数Aを削除します。
  9. コールスタックの中身が空になると、次の行のコードの実行に進みます。

ここでスタックの特徴通り、関数A→関数Bの順にコールスタックに追加されたのに、削除されるのは関数B→関数Aの順番になっていることがわかります。

同期関数のみの場合の具体例

具体例として、以下のコードを見ていきましょう。


//関数内で別の関数を呼び出す同期関数たち
function executeA(callback){
    console.log(2);
    callback();
}

function executeB(callback){
    console.log(3);
    callback();
}


//引数に指定される同期関数たち(コールバック関数)

const say5 = function() {
    executeB(say4);
    console.log(5);
}

const say4 = function(){
    console.log(4);
}


//関数の実行

console.log(1);
executeA(say5);
console.log(6);

JavaScriptインタプリタの気持ちになって、順を追って処理の内容を見ていきましょう。

  1. コードが上から順番に実行されていきます。
  2. 同期関数console.log(1)が呼び出されています!コールスタックにconsole.log(1)を積み上げましょう。
  3. console.log(1)の中のコード(これは組み込み関数なので省略します)を実行し、コンソールに1を出力しました。
  4. console.log(1)の中のコードが全て実行し終わったので、コールスタックからconsole.log(1)を削除します。
  5. コールスタックの中身が空になったので、次の行に進みます。
  6. 同期関数executeA(say5)が呼び出されています!コールスタックにexecuteA(say5)を積み上げましょう。
  7. executeA(say5)の中のコードを1行ずつ実行していきます。
  8. 同期関数console.log(2)が呼び出されているので、さっきと同様にコールスタックに積み上げてから中身を実行、コンソールに2を出力します。
  9. console.log(2)の中のコードが全て実行し終わったので、コールスタックからconsole.log(2)を削除しつつexecuteA(say5)の中のコードに戻ります。
  10. executeA(say5)の続きのコードを実行していきます。
  11. 引数に指定された同期関数say5が呼び出されています!コールスタックにsay5()を積み上げましょう。
  12. say5()の中のコードを1行ずつ実行していきます。
  13. 同期関数executeB(say4)が呼び出されています!コールスタックにexecuteB(say4)を積み上げましょう。
  14. executeB(say4)の中のコードを1行ずつ実行していきます。
  15. 同期関数console.log(3)が呼び出されています!同様にコールスタックへ積み上げ、コンソールに3を出力してコールスタックから削除し、executeB(say4)の中のコードに戻りましょう
  16. executeB(say4)の続きのコードを実行していきます。
  17. 引数に指定された同期関数say4が呼び出されています!コールスタックにsay4()を積み上げましょう。
  18. say4()の中のコードを1行ずつ実行していきます。
  19. 同期関数console.log(4)が呼び出されているので、コールスタックへ積み上げ、コンソールに4を出力してコールスタックから削除し、say4()の中のコードに戻りましょう。
  20. say4()の中のコードが全て実行し終わったので、コールスタックからsay4()を削除しつつexecuteB(say4)の中のコードに戻ります。
  21. executeB(say4)の中のコードが全て実行し終わったので、コールスタックからexecuteB(say4)を削除しつつsay5()の中のコードに戻ります。
  22. 同期関数console.log(5)が呼び出されているので、コールスタックに追加→コンソールに5を出力→コールスタックから削除してsay5()の中のコードへ帰還です。
  23. say5()の中のコードが全て実行し終わったので、コールスタックからsay5()を削除しつつexecuteA(say5)の中のコードに戻ります。
  24. executeA(say5)の中のコードが全て実行し終わったので、コールスタックからexecuteA(say5)を削除します。
  25. やっとコールスタックの中身が空になりました!次の行に進みます。
  26. 同期関数console.log(6)が呼び出されているのでコールスタックに追加→コンソールに6を出力→コールスタックから削除です。
  27. コールスタックの中身が空になりました。次の行に進みますがもうコードがありません。プログラム実行完了です!

JavaScriptインタプリタくん、長い旅路お疲れ様でした。
結果、この出力は


1
2
3
4
5
6

となります。

スタックオーバーフロー

このコールスタック、無限に関数を積み重ねることはできません。何事にも限界はあります。
コールスタックの許容量を超えてしまうことをスタックオーバーフローといい、エラーになってしまいます。
これは関数の再帰的な実行を行うときによく出てきます。

非同期関数が含まれる場合

非同期関数も普通にコールスタックに追加され、中身が実行されます。
しかし、先ほども述べたように非同期関数は処理の内容をWebAPIに任せ、そこで実行が行われるのでした。

WebAPIの処理が完了した時、コールバック関数はタスクキューに追加されます。
コールスタックが空になった時、イベントループによってタスクキューに登録された関数が登録された順に取り出され、コールスタックの中に突っ込みまれます。
あとは通常通り関数が実行され、コールスタックから取り除かれる流れが始まります。

以下のような流れで処理されていきます。

  1. コードが上から順番に実行されていきます。
  2. 非同期関数(関数Aと呼ぶ)が呼び出されているコードを発見、関数Aをコールスタックに追加します。
  3. 関数Aの中のコードを実行した結果、WebAPIに処理を任せるように書いてあるのでWebAPIに処理の内容を投げます。
  4. コールスタックから関数Aを削除し、次の行のコードに移ります。
  5. 同期関数(関数Bと呼ぶ)が呼び出されているコードを発見、関数Bをコールスタックに追加します。
  6. 関数B内のコードを実行していきます。
  7. 同時にWebAPIの方でも処理が完了していたようです。コールバック関数(CB1と呼ぶ)がタスクキューに登録されます。
  8. 関数Bが実行完了したので、コールスタックから関数Bを削除し、次の行のコードに進みます。
  9. 非同期関数(関数Cと呼ぶ)が呼び出されているコードを発見、関数Cをコールスタックに追加します。
  10. 関数Cの中のコードを実行した結果、WebAPIに処理を任せるように書いてあるのでWebAPIに処理の内容を投げます。
  11. コールスタックから関数Cを削除し、次の行のコードに移ります。
  12. 同期関数(関数Dと呼ぶ)が呼び出されているコードを発見、関数Dをコールスタックに追加します。
  13. 関数D内のコードを実行していきます。
  14. 関数Dが実行完了したので、コールスタックから関数Dを削除し、次の行のコードに進みます。
  15. 同期関数(関数Eと呼ぶ)が呼び出されているコードを発見、関数Eをコールスタックに追加します。
  16. 同時にWebAPIの方でも処理が完了していたようです。コールバック関数(CB2と呼ぶ)がタスクキューに登録されます。
  17. 関数E内のコードを実行していきます。
  18. 関数Eが実行完了したので、コールスタックから関数Eを削除し、次の行のコードに進みます。
  19. コールスタックの中身が空になりコードも全て読み終わったので、イベントループによってタスクキューの中のCB1がコールスタックに追加されます。
  20. CB1の中のコードが実行されます。完了後、コールスタックから削除されます。
  21. コールスタックの中身が空になりコードも全て読み終わったので、イベントループによってタスクキューの中のCB2がコールスタックに追加されます。
  22. CB2の中のコードが実行されます。完了後、コールスタックから削除されます。
  23. コールスタックもタスクキューも空なので、一旦処理終了です。

具体例に関してはテキストで表すには複雑すぎるので書きません。

これらの流れに関しては、英語にはなりますが以下の動画が非常に参考になるのでぜひ視聴をオススメします。

setTimeout関数と非同期処理

余談となりますが、上で説明したような内容を知らない人はsetTimeout関数の仕様に関してよく勘違いしてしまいます。
ここまで読んでくださった皆さんはsetTimeoutが非同期処理を行う関数だということがわかっているかと思います。
正確にいうと、setTimeout関数は「引数に登録されたコールバック関数を、TimerAPIを使って指定秒後にタスクキューに登録する」関数です。


setTimeout(function(){console.log("setTimeoutの中の人です")}, 1000);
console.log("Hello");

このコードを実行するとどうなるでしょう?
多くの人が期待する結果は、

1秒後にコンソールに


setTimeoutの中の人です
Hello

が出力されることです。

しかし現実は


Hello
setTimeoutの中の人です

です。

setTimeout(function(){},1000)

の意味は「1秒後にfunction()を実行する」ではありません。
正確には「1秒後にタスクキューにfunction()を登録する」なのです。

したがって


Hello
setTimeoutの中の人です

のような出力結果になるわけです。

また、


setTimeout(function(){console.log("setTimeoutの中の人です")}, 0);
console.log("Hello");

このコードも


Hello
setTimeoutの中の人です

のように出力されます。
何故でしょう?0秒後にタスクキューに登録するのだから、すぐさま

console.log("setTimeoutの中の人です")

が実行されてもいいはずです。

しかし、上でも述べたようにタスクキューに登録されたコールバック関数は、「JavaScriptのコードが全て実行され、コールスタックの中身が空になった時」に初めてイベントループによってコールスタックに追加されます。
そのため、確かにsetTimeoutが実行されるとすぐさまタスクキューにコールバック関数は登録されますが、一旦console.log("Hello");が読み取られ、実行されてコールスタックから取り除かれるのを待ってから実行されることになります。

まとめ

非同期処理が、複数のタスクを並行して行わせるような処理であることを理解できたでしょうか。その実現方法は言語によって異なりますが、JavaScriptの場合はシングルスレッドという特性上、WebAPIに処理を委ねることによって実現しています。

実際にコードを書くときも、コールスタック・タスクキュー・WebAPIのイメージをしながら書き進めていくと、スムーズに非同期処理の実装ができるのではないでしょうか。どのような仕組みでこのコードが実行されるのか?を意識したコーディングができるようになってきたら、プログラミング初心者から中級者への道のりはかなり近くなってくるかと思います!

参考リンク

https://tsuyopon.xyz/2018/12/28/what-is-a-queue-and-how-async-functions-behave/
https://developer.mozilla.org/ja/docs/Web/JavaScript/EventLoop
https://moutend.github.io/blog/2014/09/20/article/

コメント

タイトルとURLをコピーしました