JavaScript入門 非同期処理の解説

最終更新日

非同期処理とは

非同期処理を解説する前にまずは、スレッド処理について説明します。

スレッド

スレッドとは、プログラムの開始から終了までの一連の処理の流れのことです。JavaScriptコードはJavaScriptエンジンによって1行ずつ実行されていきますが、その処理の開始から終了までを一本の糸のように表すことができることを「1つのスレッドでコードが実行されている」と表現します。

ブラウザ上でJavaScriptのコードが実行されるスレッドは、メインスレッドと呼びます。メインスレッドはあくまで1つのスレッド(シングルスレッド)のため、並列してコードを実行することはありません

そのため、メインスレッド上でJavaScriptのコードが実行される場合には、必ず実行中の処理の完了を待ってから次の処理が実行されるという決まりがあります。また、1つの処理を複数のスレッドに分けて実行することをマルチスレッドと呼びます。マルチスレッドは並列処理とも呼ばれ、C/C++やJavaなどのプログラミング言語で記述できます。

JavaScriptコードはシングルスレッドで実行しているため、メインスレッドが何らかの処理を行っている場合は、他の処理はメインスレッドの処理の完了を待つことになります。JavaScriptコードは、メインスレッド上で1行ずつ順番に実行されていきます。このように、1つのスレッドで、前の処理の完了を待ってから次の処理を実行することを「同期処理」と言います。

ブラウザが提供しているWeb APIの中でも、setTimeoutやsetInterval、queueMicrotaskなどの関数は、ブラウザの非同期処理の機能を呼び出すためのAPIです。これらのAPIを、決められた実行手順で呼び出すことで、一部の処理が非同期処理としてメインスレッドから一時的に切り離されます。

イベントループ

イベントループとは、非同期処理の管理、実行を行うための仕組みのことです。

実行コンテキスト

コードが実行される際にJavaScriptエンジンによって準備されるコードの実行環境のことです。コードが実行される際には、必ず実行コンテキストが生成されます。実行コンテキストには、グローバルコンテキスト、関数コンテキストなどの種類があります。

コールスタック(実行コンテキストスタック)

実行コンテキストが積み重なってできたものをコールスタックと呼びます。コードが実行されるときには必ず実行コンテキストが生成されるため、コールスタックには必ずコンテキストが積まれている状態となります。このため、コールスタックが空でない場合にはメインスレッドが使用中であることを表します。

タスクキュー

実行待ちのタスク(非同期での実行が予約されている関数)が格納されているキューのことです。キューとは、データの出し入れをリスト形式で管理するデータ構造のことです。キューからデータを取り出すときは、古いものから順番に取り出します。この仕組みはFIFO(First In, First Out:最初に入れたmのを最初に取り出す)と呼びます。非同期処理はこのタスクキューにタスクとして格納され、コールスタックが空の状態のときに古いものから実行されます。

イベントループ

タスクキューに格納されたタスクを順番に実行していく仕組みです。イベントループは、定期的にコールスタックを監視し、コールスタックが空のときにタスクキューから一番古いタスクを取り出して実行します。

イベントループの動作

イベントループの確認

<script>
                                    //※1 コードの実行開始

    let val = 0;
    setTimeout( function task() {  //※2 タスクの登録
        val = 1; 
    },0);       
    console.log( val );             //※3 値の出力
    >0                              //valは1ではなく0.すなわち※2より前に※3が実行されていることを表す。

                                    //グローバルコンテキストの消滅
</script>

上記のコードは、setTimeoutの第2引数を0に設定しているため、非同期処理にならず、setTimeoutのコールバック関数は※3よりも前に実行されそうに思えます。しかし、setTimeoutのコールバック関数(task)は次のような流れで処理されるので、結果としては※3の後に※2のコールバック関数(task)が実行されます。

※1 コードの実行開始

コードの実行を開始する前に、JavaScriptエンジンによってグローバルコンテキストがコールスタックに積まれます。そのため、コールスタックは図1のようになります。なお、この時点ではタスクキューは空で、Web API(setTimeout)もまだ呼び出されていないため、空の状態になっています。

図1 グローバルコンテキストの生成

※2 タスクの登録

setTimeoutを通して、コールバック関数(task)がタスクキューに実行待ちの状態で登録されます。図2では、グローバルコンテキストからWeb APIのsetTimeoutを実行することで、task関数がタスクキューに登録されています。

図2 タスクキューにタスクが登録される

※3 値の出力

タスクキューに登録された関数taskはコールバックが空になるまで実行されないため、タスクキューで実行待ちの状態で待機しています(図3)。いつまで実行が待機するかというと、現在実行中のコンテキストであるグローバルコンテキストのコードがすべて完了し、コールスタックが空になるまでです。そのため、consle.log( var )がtaskよりも先に実行され、コンソールに0が表示されます。

図3 グローバルコンテキスト内のコードを最後まで実行

※4 グローバルコンテキストの消滅

※3のconsole.log( val )の処理の後にはコードが続かないため、ようやくグローバルコンテキストは消滅し。コールスタックが空の状態になります。これをイベントループが検知し、実行待ちのtask関数をタスクキューから取得・実行します(図4)。また、task関数の実行に伴い、task関数の関数コンテキストが新たに生成されます。他に実行待ちのタスクがタスクキューに存在する場合は、task関数のコンテキストが消滅した後にイベントループによって同様に処理されます。

図4 グローバルコンテキストの消滅後

タスクソース

タスクキューに登録されるタスクは、開発者が登録可能なタスクから、JavaScriptエンジンが内部的に使用してるタスクまで、様々なものがあります。これらのタスクは、タスクソース(task source)と呼ばれるカテゴリーで分類されています。このカテゴリ分けによって、ブラウザはソースに応じて個別の処理を実行できるようになっています。例えば、ユーザーの画面入力はユーザービリティに影響するため、他のタスクより優先して処理を行う、というようにタスクのカテゴリによって処理を制御します。

タイマータスクソース( Timer Task Source)

setTimeout、setIntervalなどのタイマー処理によって呼び出されるタスクです

UIタスクソース(User Interaction Task Source)

ユーザーの画面操作(イベント)をトリガーにして呼び出されるタスクです

ネットワークタスクソース(Networking Task Source)

ネットワークのレスポンスをトリガーにして実行されるタスクです。

DOM操作タスクソース(DOM Mainpulation Task Source)

DOM操作を反映するときに、JavaScriptエンジンによって実行されるタスクです。

履歴横断タスクソース(History Traversal Task Source)

History APIと呼ばれる、ブラウザの履歴操作を行うときに実行されるタスクです


非同期処理のハンドリング

非同期処理はコールスタックに積みあがっている実行コンテキストがすべて消滅した後に実行されます。そのため、非同期処理で処理した値を取得して何らかの処理を行うには、コールバック関数を使った非同期処理のハンドリングが必要になります。

非同期処理内で変更した値が取得できない

let val = -1;
function timer(){
    setTimeout( function() { 
        //0~10のランダムな値を取得
        val = Math.floor( Math.random() * 11 );  //この反映の後に後続の処理を実行したい
    } , 1000);
}
timer();
console.log( val );                              //後続の処理を実行しても非同期処理による変更が反映されていない
>-1

コールバック関数を使った非同期処理のハンドリング

let val = -1;
function timer( callback ){
    setTimeout( function task() { 
        val = Math.floor( Math.random() * 11 );  //非同期での値の変更
        callback( val );                         //callback関数(operations)に引数valを渡して実行
    } , 1000);
}
function operations( val ) {                     //非同期処理の実行後に実行したい処理を関数内に記述
    console.log( val ); 
}
timer( operations );                             //コールバック関数としてtimer関数に渡す
>5                                               //0~10のランダムな値が1秒後にコンソールに出力される

上記のコードでは、operations関数内に、非同期処理によって値が変更された後に実施したい処理をまとめて記述しています。これをコールバック関数として、非同期処理による値の変更を行った後に実行することによって、変更された値を取得できます。

コールバック地獄

コールバック関数を使った非同期処理のハンドリングは、簡単に記述できる反面、問題があります。それは、複数の非同期処理をハンドリングする際にネストが深くなり、コードの可読性が悪くなる点です。

setTimeout(function () {
  setTimeout(function () {
    setTimeout(function () {
      setTimeout(function () {   //非同期処理をつなげるほどネストが深くなっていく
        略
      }, 1000);
    } , 1000);
  }, 1000);
}, 1000);